@ducking-mind/proxy-app-cli 0.1.1 → 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 CHANGED
@@ -36,6 +36,8 @@ proxy-app publish --version 0.1.0 # 切换到指定已上传版本,也可回
36
36
  `publish --version <version>` 可选择历史已发布版本回滚,也可发布已上传草稿;不修改本地 manifest,
37
37
  不要求本地版本号与目标一致,不构建或上传。目标尚未上传时直接报错,目标已是当前线上版本时不重复切换。
38
38
 
39
+ 桌面宿主不属于本 CLI 的开源交付范围。CLI 只生成公开应用包;桌面安装包、更新元数据和签名材料由内部私有发布流水线生成。
40
+
39
41
  首次创建应用:
40
42
 
41
43
  ```bash
@@ -159,8 +161,7 @@ CLI 从已导出的变量读取配置,不会自动加载 `.env` 文件。请
159
161
 
160
162
  创建应用时,`--category` 支持 `ecommerce`(电商)、`comic`(漫剧)和 `other`(其他,默认)。分类用于应用市场展示,创建后可在“编辑应用资料”中调整。
161
163
 
162
- 本地平台可直接使用默认地址,不需要任何配置。远程平台若 Portal 与 API 使用同一域名,
163
- 只设置 `PROXY_PLATFORM_URL=https://platform.example` 即可;CLI 自动使用该站点的 `/api` 与登录页。若平台把 API 部署在独立域名,再额外设置 `PROXY_API_BASE_URL` 或 `PROXY_PORTAL_URL` 覆盖对应地址。单地址配置时,请清除已导出的旧覆盖变量。
164
+ 默认使用线上平台 `https://www.duckingmind.com`,API 地址为 `https://www.duckingmind.com/api`,登录页也使用该域名。其他环境若 Portal 与 API 使用同一域名,只设置 `PROXY_PLATFORM_URL=https://platform.example` 即可;若两者使用不同地址,再额外设置 `PROXY_API_BASE_URL` 或 `PROXY_PORTAL_URL` 覆盖对应地址。连接本地平台时,可分别设置 `PROXY_API_BASE_URL=http://localhost:9003/api` 与 `PROXY_PORTAL_URL=http://localhost:9004`。
164
165
  管理命令(创建、发布等)仍需 `PROXY_USER_ACCESS_TOKEN`;本地开发使用浏览器授权,不需要这个变量。
165
166
  若 shell 已全局导出 `PROXY_USER_ACCESS_TOKEN` 或旧别名 `PROXY_ACCESS_TOKEN`,开发命令会优先使用该令牌;想在浏览器中选择创建者账户时先运行 `unset PROXY_USER_ACCESS_TOKEN PROXY_ACCESS_TOKEN`。
166
167
 
@@ -1,14 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
- import { lstat, mkdir, readFile, stat, writeFile } from 'node:fs/promises'
4
+ import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
5
5
  import { resolve, sep } from 'node:path'
6
6
  import { pathToFileURL } from 'node:url'
7
7
 
8
- const runtimeDirectory = await stat(new URL('../desktop/agent/protocol.mjs', import.meta.url)).then(() => '../desktop/agent/').catch(error => {
9
- if (error.code !== 'ENOENT') throw error
10
- return './agent-runtime/'
11
- })
8
+ const runtimeDirectory = './agent-runtime/'
12
9
  const { normalizeCapabilityDeclaration, normalizeManifestAgent } = await import(new URL(`${runtimeDirectory}protocol.mjs`, import.meta.url))
13
10
  const { copyAgentPackage } = await import(new URL(`${runtimeDirectory}package-guide.mjs`, import.meta.url))
14
11
 
@@ -1,8 +1,11 @@
1
+ /** Public Agent guide and example package validation helpers. The private host
2
+ * owns lifecycle, transport, authorization, and release behavior. */
3
+
1
4
  import { createHash } from 'node:crypto'
2
5
  import { constants } from 'node:fs'
3
6
  import { lstat, mkdir, open, realpath } from 'node:fs/promises'
4
7
  import { join, resolve } from 'node:path'
5
- import { normalizeManifestAgent, toolNameFor, validateValue } from './protocol.mjs'
8
+ import { normalizeManifestAgent, validateValue } from './protocol.mjs'
6
9
 
7
10
  const MAX_TEXT_BYTES = 64 * 1024
8
11
  const MAX_PACKAGE_BYTES = 256 * 1024
@@ -112,47 +115,6 @@ async function readAgentPackage(root, manifest) {
112
115
  return readAgentPackageWithReader(manifest, entry => readPackageFile(packageRoot, entry))
113
116
  }
114
117
 
115
- /** Load the same bounded, schema-validated package resources for Web apps.
116
- * The platform-provided versioned entry anchors every URL; no account bearer
117
- * credential is sent to application content, even when hosted on a CDN.
118
- */
119
- export async function loadWebAgentPackage(manifest, { entryURL, platformOrigin, fetchImpl = fetch, signal } = {}) {
120
- signal = signal ? AbortSignal.any([signal, AbortSignal.timeout(15000)]) : AbortSignal.timeout(15000)
121
- const entry = new URL(entryURL, platformOrigin)
122
- check(['https:', 'http:'].includes(entry.protocol) && !entry.username && !entry.password, 'invalid Web entry URL')
123
- check(entry.protocol === 'https:' || ['localhost', '127.0.0.1', '[::1]'].includes(entry.hostname), 'insecure Web entry URL')
124
- const runtimeEntry = 'index.html'
125
- const suffix = runtimeEntry.split('/').map(encodeURIComponent).join('/')
126
- check(entry.pathname.endsWith(`/${suffix}`), 'Web entry does not match the declared application entry')
127
- const prefix = entry.pathname.slice(0, -suffix.length)
128
- const { files, ...data } = await readAgentPackageWithReader(manifest, async path => {
129
- const url = new URL(entry)
130
- url.pathname = prefix + path.split('/').map(encodeURIComponent).join('/')
131
- url.hash = ''
132
- const response = await fetchImpl(url, { signal, redirect: 'error', credentials: 'omit' })
133
- check(response.ok, `Web package entry unavailable: ${path} (${response.status})`)
134
- check(!response.headers.get('content-length') || Number(response.headers.get('content-length')) <= MAX_TEXT_BYTES, `entry exceeds 64 KiB: ${path}`)
135
- check(response.body, `empty Web package response: ${path}`)
136
- const reader = response.body.getReader()
137
- const chunks = []
138
- let length = 0
139
- try {
140
- while (true) {
141
- const { value, done } = await reader.read()
142
- if (done) break
143
- length += value.length
144
- check(length <= MAX_TEXT_BYTES, `entry exceeds 64 KiB: ${path}`)
145
- chunks.push(value)
146
- }
147
- } finally { await reader.cancel().catch(() => {}) }
148
- const bytes = Buffer.concat(chunks)
149
- let content
150
- try { content = new TextDecoder('utf-8', { fatal: true }).decode(bytes) } catch { throw new Error(`Invalid UTF-8 agent package entry: ${path}`) }
151
- return { bytes, content }
152
- })
153
- return data
154
- }
155
-
156
118
  /** Read only declared, bounded files from the selected application version. */
157
119
  export async function loadAgentPackage(root, manifest) {
158
120
  const { files, ...data } = await readAgentPackage(root, manifest)
@@ -180,14 +142,3 @@ export async function copyAgentPackage(source, destination, manifest) {
180
142
  }
181
143
  return data
182
144
  }
183
-
184
- /** The API topic is generated from the same declaration tools/list publishes. */
185
- export function renderAgentReference(manifest) {
186
- const agent = normalizeManifestAgent(manifest)
187
- if (!agent) return ''
188
- const lines = [`# ${manifest.name || manifest.id} API`, '', `Application: ${manifest.id}`, `Application version: ${manifest.version}`, `Contract version: ${manifest.agent.contract_version || manifest.version}`, `Application targets: ${(agent.targets || ['desktop']).join(', ')}`, '', 'Generated from the active package manifest. Read the overview and workflow topics for business rules.', '']
189
- for (const capability of agent.capabilities) {
190
- lines.push(`## ${toolNameFor(agent.namespace, capability.name)}`, '', capability.description, '', `Targets: ${capability.targets.join(', ')}; access: ${capability.access}; idempotent: ${capability.idempotent}.`, '', 'Input schema:', '```json', JSON.stringify(capability.input_schema, null, 2), '```', '', 'Output schema:', '```json', JSON.stringify(capability.output_schema, null, 2), '```', '')
191
- }
192
- return lines.join('\n')
193
- }
@@ -1,12 +1,10 @@
1
1
  /**
2
- * Shared protocol primitives for exposing Proxy Desktop applications to an
3
- * external agent (for example, through MCP). This module has no Electron or
4
- * transport dependency and is safe to use from the desktop host and tests.
2
+ * Public compatibility primitives for an app Agent contract (for example,
3
+ * the `proxy.desktop.agent/v1` data format). This module validates application
4
+ * declarations and schemas; runtime registration, transport, authorization,
5
+ * and host lifecycle remain in the private desktop implementation.
5
6
  */
6
7
 
7
- import { serializeAgentError } from './runtime-status.mjs'
8
- import { createHash } from 'node:crypto'
9
-
10
8
  export const AGENT_PROTOCOL = 'proxy.desktop.agent/v1'
11
9
  export const AGENT_PROTOCOL_VERSION = 1
12
10
 
@@ -38,17 +36,6 @@ export function isAppManifestID(value) {
38
36
  return isPublicAppSlug(value) || (typeof value === 'string' && value === value.trim() && PLATFORM_APP_ID_RE.test(value))
39
37
  }
40
38
 
41
- // Only metadata from the authenticated platform (or the trusted account
42
- // broker) may approve a historical manifest identity. Never infer an alias by
43
- // adding/removing a prefix, and never alias one stable platform ID to another.
44
- export function appManifestIdentityMatches(value, metadata) {
45
- if (!isAppManifestID(value) || !metadata) return false
46
- const id = metadata.app_id || metadata.id
47
- if (PLATFORM_APP_ID_RE.test(value)) return value === id
48
- if (value === (metadata.app_slug || metadata.slug)) return true
49
- return Array.isArray(metadata.app_aliases) && metadata.app_aliases.some(alias => isPublicAppSlug(alias) && alias === value)
50
- }
51
-
52
39
  export function assertAppId(value) {
53
40
  assert(isAppManifestID(value) || (typeof value === 'string' && value === value.trim() && LEGACY_APP_ID_RE.test(value)), `invalid app id: ${value}`)
54
41
  return value
@@ -164,86 +151,6 @@ export function normalizeManifestAgent(manifest) {
164
151
  return Object.freeze({ enabled: true, namespace, targets, contractVersion, summary, keywords, guide: agent.guide, examples: agent.examples, capabilities })
165
152
  }
166
153
 
167
- const canonical = value => Array.isArray(value) ? value.map(canonical) : isRecord(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value
168
- /** Same contract identity for market metadata and installed packages, independent of transport/runtime state. */
169
- export function describeAgentContract(manifest) {
170
- const agent = normalizeManifestAgent(manifest)
171
- if (!agent) return null
172
- const contract = { appId: manifest.id, namespace: agent.namespace, contractVersion: agent.contractVersion, targets: agent.targets,
173
- capabilities: [...agent.capabilities].sort((a, b) => a.name.localeCompare(b.name)) }
174
- return { enabled: true, namespace: agent.namespace, targets: agent.targets, contractVersion: agent.contractVersion,
175
- contractHash: createHash('sha256').update(JSON.stringify(canonical(contract))).digest('hex'),
176
- capabilityCount: agent.capabilities.length,
177
- desktopCapabilityCount: agent.capabilities.filter(capability => capability.targets.includes('desktop')).length,
178
- webCapabilityCount: agent.capabilities.filter(capability => capability.targets.includes('web')).length,
179
- capabilities: agent.capabilities.map(({ name, title, description, access, targets, billable, completion }) => ({ name, title, description, access, targets, billable, completion })) }
180
- }
181
-
182
- export function normalizeRuntimeCapability(input) {
183
- assert(isRecord(input), 'runtime capability must be an object')
184
- const name = assertCapabilityName(input.name)
185
- if (input.handlerVersion !== undefined) {
186
- assert(Number.isInteger(input.handlerVersion) && input.handlerVersion > 0, `invalid handlerVersion for ${name}`)
187
- }
188
- if (input.available !== undefined) assert(typeof input.available === 'boolean', `invalid available for ${name}`)
189
- return Object.freeze({ name, handlerVersion: input.handlerVersion || 1, available: input.available !== false })
190
- }
191
-
192
- export function validateRuntimeRegistration(manifest, registration, { target = 'desktop' } = {}) {
193
- assert(AGENT_TARGETS.includes(target), 'invalid runtime target')
194
- assert(isRecord(registration), 'runtime registration must be an object')
195
- const agent = normalizeManifestAgent(manifest)
196
- assert(agent, 'agent capabilities are not enabled in manifest')
197
- assertAppId(registration.appId)
198
- assertVersion(registration.version)
199
- assert(registration.appId === manifest.id, 'runtime app id does not match manifest')
200
- assert(registration.version === manifest.version, 'runtime app version does not match manifest')
201
- assert(Array.isArray(registration.capabilities) && registration.capabilities.length <= 64, 'runtime capabilities must be an array with at most 64 entries')
202
- const codeFirst = manifest.agent.contract_source === 'code'
203
- const dynamic = registration.dynamic === true && !codeFirst
204
- const declarations = new Map(agent.capabilities.map((capability) => [capability.name, capability]))
205
- const runtime = []
206
- const registeredDeclarations = []
207
- const seen = new Set()
208
- for (const raw of registration.capabilities) {
209
- const capability = normalizeRuntimeCapability(raw)
210
- assert(!seen.has(capability.name), `duplicate runtime capability: ${capability.name}`)
211
- seen.add(capability.name)
212
- let declared = declarations.get(capability.name)
213
- const hasContract = ['title', 'description', 'access', 'input_schema', 'inputSchema', 'output_schema', 'outputSchema', 'targets', 'completion', 'idempotent', 'destructive', 'open_world', 'billable'].some(field => Object.hasOwn(raw, field))
214
- let supplied
215
- if (codeFirst || dynamic || hasContract) {
216
- if (codeFirst || dynamic) assert(raw.access !== undefined && (raw.input_schema ?? raw.inputSchema) !== undefined && (raw.output_schema ?? raw.outputSchema) !== undefined, `runtime capability requires a complete code declaration: ${capability.name}`)
217
- supplied = normalizeCapabilityDeclaration(raw, { defaultTargets: agent.targets })
218
- assert(supplied.targets.every(item => agent.targets.includes(item)), `${capability.name}.targets must be a subset of application targets`)
219
- assert(supplied.targets.includes(target), `runtime capability does not support ${target}: ${capability.name}`)
220
- }
221
- // Code-first applications provide the complete contract at runtime. The
222
- // names-only form remains a compatibility path for already packaged
223
- // applications whose generated manifest contains the contract.
224
- if (declared) {
225
- assert(declared.targets.includes(target), `runtime capability does not support ${target}: ${capability.name}`)
226
- if (codeFirst) {
227
- assert(JSON.stringify(canonical(supplied)) === JSON.stringify(canonical(declared)), `runtime capability contract differs from packaged code contract: ${capability.name}`)
228
- }
229
- } else if (!dynamic) {
230
- assert(false, `runtime capability is not declared: ${capability.name}`)
231
- }
232
- if (dynamic) {
233
- declared = supplied
234
- declarations.set(capability.name, declared)
235
- }
236
- registeredDeclarations.push(declared)
237
- if (capability.available) runtime.push({ ...capability, declaration: declared })
238
- }
239
- return Object.freeze({
240
- appId: registration.appId,
241
- version: registration.version,
242
- capabilities: runtime,
243
- declarations: registeredDeclarations,
244
- })
245
- }
246
-
247
154
  export function toolNameFor(namespace, capability) {
248
155
  assertNamespace(namespace)
249
156
  assertCapabilityName(capability)
@@ -252,33 +159,6 @@ export function toolNameFor(namespace, capability) {
252
159
  return name
253
160
  }
254
161
 
255
- export function toMcpTool({ namespace, appId, version, contractVersion = version, contractHash, capability, runtime }) {
256
- const declaration = normalizeCapabilityDeclaration({ ...capability, name: capability.name })
257
- const tool = {
258
- name: toolNameFor(namespace, declaration.name),
259
- title: declaration.title,
260
- description: [declaration.description || `${appId} ${declaration.name}`, declaration.billable ? 'May incur model or service charges.' : '', declaration.completion === 'accepted' ? 'Returns acceptance only; use the application task query to check completion.' : ''].filter(Boolean).join(' '),
261
- inputSchema: declaration.input_schema,
262
- _meta: { proxy: { appId, version, contractVersion, ...(contractHash ? { contractHash } : {}), capability: declaration.name, billable: declaration.billable, completion: declaration.completion, targets: declaration.targets } },
263
- }
264
- if (declaration.output_schema) tool.outputSchema = declaration.output_schema
265
- tool.annotations = { readOnlyHint: declaration.access === ACCESS.READ, destructiveHint: declaration.destructive, idempotentHint: declaration.idempotent, openWorldHint: declaration.open_world }
266
- if (runtime) tool._meta.proxy.runtime = runtime
267
- return Object.freeze(tool)
268
- }
269
-
270
- export function createInvocation({ requestId, appId, appVersion, capability, args, deadlineAt, cancellationToken }) {
271
- assert(typeof requestId === 'string' && requestId.length > 0 && requestId.length <= 128, 'invalid request id')
272
- assertAppId(appId); assertVersion(appVersion); assertCapabilityName(capability)
273
- assert(args === undefined || isRecord(args), 'agent arguments must be an object')
274
- return Object.freeze({ requestId, appId, appVersion, capability, args: args || {}, deadlineAt, cancellationToken })
275
- }
276
-
277
- export function serializeError(error) {
278
- return serializeAgentError(error)
279
- }
280
-
281
-
282
162
  // Deliberately bounded JSON Schema subset. Reject unsupported keywords instead
283
163
  // of advertising validation that the host cannot actually enforce.
284
164
  const keywords = new Set(['type', 'properties', 'required', 'additionalProperties', 'items', 'enum', 'minLength', 'maxLength', 'minimum', 'maximum', 'minItems', 'maxItems', 'description', 'title', 'default'])
@@ -314,10 +314,11 @@ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', rel
314
314
  let requiresFreshLogin = forceLogin
315
315
  let developerToken = explicitToken
316
316
  if (!developerToken && !forceLogin) developerToken = await wait(Promise.resolve(developerCredentialStore?.load()))
317
- // Do not silently reuse an already signed-in browser account when no
318
- // developer credential is available. The login page will require the
319
- // user to choose the creator account explicitly.
320
- if (!developerToken && !explicitToken) requiresFreshLogin = true
317
+ // A missing developer cache does not mean that the Portal session is
318
+ // invalid. Let the Portal reuse its current signed-in account; the
319
+ // registration endpoint still enforces that it is the app creator.
320
+ // Force account selection only when replacing a rejected credential.
321
+ if (!developerToken && !explicitToken) requiresFreshLogin = false
321
322
  checkActive()
322
323
  if (developerToken) {
323
324
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ducking-mind/proxy-app-cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "CLI for creating, building and publishing Proxy platform apps",
5
5
  "repository": {
6
6
  "type": "git",
@@ -25,12 +25,13 @@
25
25
  },
26
26
  "files": [
27
27
  "proxy-app.mjs",
28
+ "platform-settings.mjs",
28
29
  "local-dev-relay.mjs",
29
30
  "local-dev-credentials.mjs",
30
31
  "artifact-security.mjs",
31
32
  "agent-contract.mjs",
32
- "agent-runtime/*.mjs",
33
- "release-runtime/*.mjs",
33
+ "agent-runtime/protocol.mjs",
34
+ "agent-runtime/package-guide.mjs",
34
35
  "prepare-agent-runtime.mjs"
35
36
  ],
36
37
  "scripts": {
@@ -43,7 +44,6 @@
43
44
  "access": "public"
44
45
  },
45
46
  "dependencies": {
46
- "@ducking-mind/proxy-app-desktop-runtime": "^0.1.0",
47
47
  "fflate": "^0.8.2"
48
48
  },
49
49
  "engines": {
@@ -0,0 +1,19 @@
1
+ export const DEFAULT_PLATFORM_URL = 'https://www.duckingmind.com'
2
+
3
+ export function platformAPIBaseURL(env = process.env) {
4
+ const explicit = env.PROXY_API_BASE_URL?.trim()
5
+ if (explicit) return explicit.replace(/\/$/, '')
6
+ const platform = env.PROXY_PLATFORM_URL?.trim() || DEFAULT_PLATFORM_URL
7
+ let url
8
+ try { url = new URL(platform) } catch { throw new Error('PROXY_PLATFORM_URL must be a valid platform origin, such as https://platform.example') }
9
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
10
+ throw new Error('PROXY_PLATFORM_URL must be a platform origin without a path, credentials, query, or hash')
11
+ }
12
+ return `${url.origin}/api`
13
+ }
14
+
15
+ export function platformPortalURL(env = process.env) {
16
+ const explicit = env.PROXY_PORTAL_URL?.trim() || env.PROXY_PUBLIC_URL?.trim()
17
+ if (explicit) return explicit
18
+ return env.PROXY_PLATFORM_URL?.trim() || DEFAULT_PLATFORM_URL
19
+ }
@@ -1,43 +1,10 @@
1
- import { copyFile, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises'
1
+ import { access } from 'node:fs/promises'
2
2
 
3
- const source = new URL('../desktop/agent/', import.meta.url)
4
- const destination = new URL('./agent-runtime/', import.meta.url)
5
- const modules = new Set()
6
- async function collect(name) {
7
- if (modules.has(name)) return
8
- if (!/^[a-z][a-z0-9-]*\.mjs$/.test(name)) throw new Error(`Invalid agent runtime module: ${name}`)
9
- modules.add(name)
10
- const content = await readFile(new URL(name, source), 'utf8')
11
- for (const match of content.matchAll(/(?:from\s*|import\s*)['"]\.\/([^'"]+)['"]/g)) await collect(match[1])
12
- await writeFile(new URL(name, destination), content)
3
+ // These modules are deliberately maintained as public, dependency-free copies.
4
+ // They contain the application capability contract and package validation only;
5
+ // the proprietary desktop host and its build scripts are never copied into an
6
+ // npm package.
7
+ for (const file of ['./agent-runtime/protocol.mjs', './agent-runtime/package-guide.mjs']) {
8
+ await access(new URL(file, import.meta.url))
13
9
  }
14
-
15
- // Include the transitive local imports, never the Electron entry points.
16
- // Repacking an installed npm package reuses its already bundled runtime.
17
- const hasSource = await stat(source).then(() => true).catch(error => { if (error.code !== 'ENOENT') throw error; return false })
18
- if (hasSource) {
19
- await mkdir(destination, { recursive: true })
20
- await collect('protocol.mjs')
21
- await collect('package-guide.mjs')
22
- for (const entry of await readdir(destination)) if (!modules.has(entry)) await rm(new URL(entry, destination), { recursive: true, force: true })
23
- } else {
24
- await import(new URL('package-guide.mjs', destination))
25
- }
26
- // The package contract resolver uses this same bundled validation, including
27
- // when it is run as a project's direct build helper.
28
10
  await import(new URL('./agent-contract.mjs', import.meta.url))
29
-
30
- // The standalone CLI also exposes Electron release helpers. Keep a small copy
31
- // in the npm package so an installed CLI does not depend on the monorepo's
32
- // app/desktop directory. Source checkouts still use the live modules below.
33
- const releaseSource = new URL('../desktop/scripts/', import.meta.url)
34
- const releaseDestination = new URL('./release-runtime/', import.meta.url)
35
- const releaseModules = ['release-config.mjs', 'release-target.mjs']
36
- const hasReleaseSource = await stat(new URL(releaseModules[0], releaseSource)).then(() => true).catch(error => {
37
- if (error.code !== 'ENOENT') throw error
38
- return false
39
- })
40
- if (hasReleaseSource) {
41
- await mkdir(releaseDestination, { recursive: true })
42
- for (const name of releaseModules) await copyFile(new URL(name, releaseSource), new URL(name, releaseDestination))
43
- }
package/proxy-app.mjs CHANGED
@@ -1,18 +1,16 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
- import { existsSync, readFileSync } from 'node:fs'
5
- import { cp, lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
4
+ import { lstat, mkdir, readFile, readdir, rename, rm, stat, writeFile } from 'node:fs/promises'
6
5
  import { createHash, randomUUID } from 'node:crypto'
7
- import { createRequire } from 'node:module'
8
- import { basename, dirname, extname, join, resolve, sep } from 'node:path'
9
- import { fileURLToPath } from 'node:url'
6
+ import { dirname, join, resolve, sep } from 'node:path'
10
7
  import { createServer as createTCPServer } from 'node:net'
11
8
  import { zipSync } from 'fflate'
12
9
  import { createLocalDevelopmentRelay } from './local-dev-relay.mjs'
13
10
  import { createLocalCredentialStore } from './local-dev-credentials.mjs'
14
11
  import { assertSafeArtifactFile, assertSafeArtifactDirectory } from './artifact-security.mjs'
15
12
  import { resolveAgentContract } from './agent-contract.mjs'
13
+ import { platformAPIBaseURL, platformPortalURL } from './platform-settings.mjs'
16
14
 
17
15
  const MAX_BUNDLE_BYTES = 50 * 1024 * 1024
18
16
  const ALLOWED_PERMISSIONS = new Set(['user.profile.read', 'platform.capabilities.read', 'storage.user', 'notification', 'theme.read', 'locale.read'])
@@ -37,24 +35,8 @@ const appBinding = app => ({
37
35
  ...(publicAppAliases(app).length ? { app_aliases: publicAppAliases(app) } : {}),
38
36
  })
39
37
 
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))
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))
58
40
 
59
41
  function usage() {
60
42
  console.log(`在应用目录内,常用命令:
@@ -79,22 +61,18 @@ pack 无需提前执行 build 或 validate;已上传的版本由 publish 直
79
61
  proxy-app pack [app-dir] [-o <output.zip>]
80
62
  proxy-app upload [app-dir] [-o <output.zip>]
81
63
  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
64
 
85
65
  upload 只上传;publish 显式切换线上版本。首次使用 publish 仍需申请应用开放。
86
66
  --version 仅选择已上传版本,不构建、不上传、不修改本地 manifest。
87
67
  -o 与 --output 等价,也兼容原来的 <app-dir> <output.zip> 写法。
88
68
  --source 仅供 pack/upload 调试源码,跳过构建,不用于正式发布。
89
- desktop-pack 会在输出目录生成 desktop-release.json 和 electron-updater metadata;发布配置
90
- 使用 desktop/release.config.json,API Key 只用于发布请求,不会写入安装包。
91
69
  应用分类:ecommerce(电商)、comic(漫剧)、other(其他,默认)。
92
70
 
93
71
  Publish environment:
94
72
  PROXY_PLATFORM_URL Optional single-origin platform URL; derives API (/api) and login Portal
95
- PROXY_API_BASE_URL default: http://localhost:9003/api
73
+ PROXY_API_BASE_URL default: https://www.duckingmind.com/api
96
74
  Optional override when the platform uses a separate API host
97
- PROXY_PORTAL_URL Optional login Portal override; default: http://localhost:9004
75
+ PROXY_PORTAL_URL Optional login Portal override; default: https://www.duckingmind.com
98
76
  PROXY_USER_ACCESS_TOKEN Current platform user session for app management/CI only; never passed to the app
99
77
  PROXY_ACCESS_TOKEN Legacy alias for PROXY_USER_ACCESS_TOKEN
100
78
 
@@ -165,8 +143,6 @@ function commandArguments(command, args) {
165
143
  pack: { values: ['--output'], flags: ['--source'], max: 2 },
166
144
  upload: { values: ['--output'], flags: ['--source'], max: 2 },
167
145
  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
146
  }
171
147
  const config = configurations[command]
172
148
  if (!config) throw new Error(`unknown command: ${command}`)
@@ -214,27 +190,6 @@ function apiSettings() {
214
190
  return { token, baseURL }
215
191
  }
216
192
 
217
- function platformAPIBaseURL() {
218
- const explicit = process.env.PROXY_API_BASE_URL?.trim()
219
- if (explicit) return explicit.replace(/\/$/, '')
220
- const platform = process.env.PROXY_PLATFORM_URL?.trim()
221
- if (!platform) return 'http://localhost:9003/api'
222
- let url
223
- try { url = new URL(platform) } catch { throw new Error('PROXY_PLATFORM_URL must be a valid platform origin, such as https://platform.example') }
224
- if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') {
225
- throw new Error('PROXY_PLATFORM_URL must be a platform origin without a path, credentials, query, or hash')
226
- }
227
- return `${url.origin}/api`
228
- }
229
-
230
- function platformPortalURL() {
231
- const explicit = process.env.PROXY_PORTAL_URL?.trim() || process.env.PROXY_PUBLIC_URL?.trim()
232
- if (explicit) return explicit
233
- const platform = process.env.PROXY_PLATFORM_URL?.trim()
234
- if (platform) return platform
235
- return 'http://localhost:9004'
236
- }
237
-
238
193
  function responseError(body, fallback) {
239
194
  const message = typeof body?.error === 'string' ? body.error : body?.error?.message || body?.message
240
195
  const code = body?.code || body?.error?.code
@@ -652,207 +607,6 @@ async function packApp(appDir, output, { allowSource = false, allowSourceMaps =
652
607
  return { ...info, outputPath }
653
608
  }
654
609
 
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
610
  function defaultAgentNamespace(identifier) {
857
611
  return identifier.replace(/^app\./, '').replace(/[^a-z0-9]+/g, '_').slice(0, 48) || 'app'
858
612
  }
@@ -919,9 +673,9 @@ async function writeProject(root, app) {
919
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')
920
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')
921
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`)
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`)
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`)
923
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')
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`)
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`)
925
679
  }
926
680
 
927
681
  async function replaceIdentifier(root, previous, next) {
@@ -1166,8 +920,6 @@ try {
1166
920
  else if (command === 'pack') await packApp(appDir, output, { allowSource: options.includes('--source') })
1167
921
  else if (command === 'upload') await uploadApp(appDir, output, { allowSource: options.includes('--source') })
1168
922
  else if (command === 'publish') await publishApp(appDir, output, option(options, '--version'))
1169
- else if (command === 'desktop-build') await desktopBuildApp(appDir, options)
1170
- else if (command === 'desktop-pack') await desktopPackApp(appDir, output, options)
1171
923
  }
1172
924
  } catch (error) {
1173
925
  console.error(`proxy app: ${error instanceof Error ? error.message : String(error)}`)
@@ -1,70 +0,0 @@
1
- /** Descriptive renderer state only; neither status nor recovery hints grant access. */
2
- const states = new Set(['ready', 'auth_required', 'loading', 'no_document', 'busy', 'error'])
3
- const actions = new Set(['wait', 'user_login', 'open_document', 'retry', 'inspect_state'])
4
- const record = value => value !== null && typeof value === 'object' && !Array.isArray(value)
5
- const check = (condition, message) => { if (!condition) throw new TypeError(message) }
6
- const keys = (value, allowed, field) => check(Object.keys(value).every(key => allowed.includes(key)), `Unexpected ${field} field`)
7
-
8
- export function normalizeNextAction(value) {
9
- check(record(value), 'nextAction must be an object')
10
- keys(value, ['type', 'capability'], 'nextAction')
11
- check(actions.has(value.type), 'Invalid nextAction type')
12
- if (value.capability !== undefined) check(typeof value.capability === 'string' && /^[a-z][a-z0-9_-]{0,127}$/.test(value.capability), 'Invalid nextAction capability')
13
- return { type: value.type, ...(value.capability === undefined ? {} : { capability: value.capability }) }
14
- }
15
-
16
- export function normalizeRuntimeStatus(value) {
17
- check(record(value), 'Runtime status must be an object')
18
- keys(value, ['state', 'reason', 'retryable', 'nextAction', 'context'], 'runtime status')
19
- check(states.has(value.state), 'Invalid runtime state')
20
- const result = { state: value.state }
21
- if (value.reason !== undefined) {
22
- check(typeof value.reason === 'string' && value.reason.length <= 512, 'Invalid runtime reason')
23
- result.reason = value.reason
24
- }
25
- if (value.retryable !== undefined) {
26
- check(typeof value.retryable === 'boolean', 'Invalid runtime retryable value')
27
- result.retryable = value.retryable
28
- }
29
- if (value.nextAction !== undefined) result.nextAction = normalizeNextAction(value.nextAction)
30
- if (value.context !== undefined) {
31
- check(record(value.context), 'Runtime context must be an object')
32
- keys(value.context, ['activeDocumentId'], 'runtime context')
33
- if (value.context.activeDocumentId !== undefined) check(typeof value.context.activeDocumentId === 'string' && value.context.activeDocumentId.length <= 128, 'Invalid activeDocumentId')
34
- result.context = value.context.activeDocumentId === undefined ? {} : { activeDocumentId: value.context.activeDocumentId }
35
- }
36
- return result
37
- }
38
-
39
- /** Keep recovery metadata across process boundaries without forwarding arbitrary fields. */
40
- export function serializeAgentError(error) {
41
- const result = {
42
- code: typeof error?.code === 'string' ? error.code.slice(0, 128) : 'AGENT_HANDLER_ERROR',
43
- message: typeof error?.message === 'string' ? error.message.slice(0, 2048) : 'Application handler failed',
44
- }
45
- // Preserve public platform diagnostics through the renderer/child/MCP
46
- // boundaries, without forwarding credentials or arbitrary error fields.
47
- if (Number.isInteger(error?.status) && error.status >= 100 && error.status <= 599) result.status = error.status
48
- if (typeof error?.requestId === 'string') result.requestId = error.requestId.slice(0, 256)
49
- if (typeof error?.authReason === 'string') result.authReason = error.authReason.slice(0, 128)
50
- if (typeof error?.reason === 'string') result.reason = error.reason.slice(0, 512)
51
- if (typeof error?.retryable === 'boolean') result.retryable = error.retryable
52
- if (typeof error?.executionStarted === 'boolean') result.executionStarted = error.executionStarted
53
- if (error?.nextAction !== undefined) {
54
- try { result.nextAction = normalizeNextAction(error.nextAction) } catch { /* Invalid hints cannot obscure the actual error. */ }
55
- }
56
- const recovery = {
57
- AGENT_OUTCOME_UNKNOWN: { reason: 'execution_outcome_unknown', retryable: false, nextAction: { type: 'inspect_state' } },
58
- AGENT_WRITE_BUSY: { reason: 'write_pending_or_unknown', retryable: false, nextAction: { type: 'inspect_state' } },
59
- AGENT_IDEMPOTENCY_CONFLICT: { reason: 'idempotency_conflict', retryable: false, nextAction: { type: 'inspect_state' } },
60
- AGENT_APP_UNAVAILABLE: { reason: 'app_unavailable', retryable: true, nextAction: { type: 'wait' } },
61
- AGENT_APP_NOT_READY: { reason: 'app_not_ready', retryable: true, nextAction: { type: 'wait' } },
62
- }[result.code]
63
- if (recovery) for (const [key, value] of Object.entries(recovery)) if (result[key] === undefined) result[key] = value
64
- // An unknown execution outcome must never acquire an automatic retry hint.
65
- if (result.code === 'AGENT_OUTCOME_UNKNOWN') {
66
- result.retryable = false
67
- result.nextAction = { type: 'inspect_state' }
68
- }
69
- return result
70
- }
@@ -1,154 +0,0 @@
1
- import { access, readFile } from 'node:fs/promises'
2
- import { dirname, join, resolve } from 'node:path'
3
- import { fileURLToPath } from 'node:url'
4
-
5
- // This file deliberately contains no credentials. A developer may copy
6
- // release.config.example.json to release.config.json (which is gitignored)
7
- // and put the publish key in that local file.
8
- export const DEFAULT_RELEASE_CONFIG = Object.freeze({
9
- product: 'duckingmind',
10
- updateURL: '',
11
- updateChannel: 'latest',
12
- apiURL: '',
13
- apiKey: '',
14
- releaseNotes: '',
15
- artifactBaseURL: '',
16
- })
17
-
18
- export function parseConfigOption(args) {
19
- const rest = []
20
- let configPath
21
- let envOverrides = false
22
- for (let index = 0; index < args.length; index += 1) {
23
- const value = args[index]
24
- if (value === '--env-overrides') { envOverrides = true; continue }
25
- if (value === '--config') {
26
- const next = args[++index]
27
- if (!next || next.startsWith('--')) throw new Error('Missing value for --config')
28
- configPath = next
29
- continue
30
- }
31
- if (value.startsWith('--config=')) {
32
- configPath = value.slice('--config='.length)
33
- if (!configPath) throw new Error('Missing value for --config')
34
- continue
35
- }
36
- rest.push(value)
37
- }
38
- return { args: rest, configPath, envOverrides }
39
- }
40
-
41
- function stringValue(value, fallback = '') {
42
- return value == null ? fallback : String(value).trim()
43
- }
44
-
45
- function flattenConfig(value) {
46
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('发布配置必须是 JSON 对象')
47
- const update = value.update && typeof value.update === 'object' ? value.update : {}
48
- const release = value.release && typeof value.release === 'object' ? value.release : {}
49
- const build = value.build && typeof value.build === 'object' ? value.build : {}
50
- return {
51
- product: stringValue(value.product ?? release.product, 'duckingmind'),
52
- updateURL: stringValue(value.updateURL ?? value.updateUrl ?? value.update_url ?? update.url ?? update.updateURL ?? update.update_url),
53
- updateChannel: stringValue(value.updateChannel ?? value.channel ?? update.channel, 'latest'),
54
- apiURL: stringValue(value.apiURL ?? value.apiUrl ?? value.api_url ?? release.apiURL ?? release.apiUrl ?? release.api_url),
55
- apiKey: stringValue(value.apiKey ?? value.api_key ?? release.apiKey ?? release.api_key),
56
- releaseNotes: stringValue(value.releaseNotes ?? value.release_notes ?? value.notes ?? release.releaseNotes ?? release.release_notes ?? release.notes),
57
- artifactBaseURL: stringValue(value.artifactBaseURL ?? value.artifactBaseUrl ?? value.artifact_base_url ?? release.artifactBaseURL ?? release.artifactBaseUrl ?? release.artifact_base_url),
58
- buildTarget: { platform: stringValue(build.platform), arch: stringValue(build.arch) },
59
- releaseDirs: release.directories ?? ['release'],
60
- recordPaths: release.records ?? [],
61
- }
62
- }
63
-
64
- function envOverrides(env = process.env) {
65
- const keys = {
66
- product: 'PROXY_DESKTOP_PRODUCT',
67
- updateURL: 'PROXY_DESKTOP_UPDATE_URL',
68
- updateChannel: 'PROXY_DESKTOP_UPDATE_CHANNEL',
69
- apiURL: 'PROXY_DESKTOP_RELEASE_API_URL',
70
- apiKey: 'PROXY_DESKTOP_RELEASE_API_KEY',
71
- releaseNotes: 'PROXY_DESKTOP_RELEASE_NOTES',
72
- artifactBaseURL: 'PROXY_DESKTOP_ARTIFACT_BASE_URL',
73
- }
74
- return Object.fromEntries(Object.entries(keys).filter(([, key]) => env[key] != null && String(env[key]).trim() !== '').map(([name, key]) => [name, String(env[key]).trim()]))
75
- }
76
-
77
- async function firstExisting(paths) {
78
- for (const path of paths) {
79
- try {
80
- await access(path)
81
- return path
82
- } catch {}
83
- }
84
- return undefined
85
- }
86
-
87
- export function defaultConfigPaths({ root, cwd = process.cwd() }) {
88
- const candidates = [join(root, 'release.config.json'), join(root, 'release.config.local.json')]
89
- if (cwd && resolve(cwd) !== resolve(root)) {
90
- candidates.push(join(cwd, 'desktop', 'release.config.json'), join(cwd, 'desktop', 'release.config.local.json'))
91
- }
92
- return [...new Set(candidates)]
93
- }
94
-
95
- export function isAllowedReleaseURL(value) {
96
- let parsed
97
- try { parsed = new URL(String(value || '').trim()) } catch { return false }
98
- const loopbackHTTP = parsed.protocol === 'http:' && ['localhost', '127.0.0.1', '[::1]'].includes(parsed.hostname)
99
- return (parsed.protocol === 'https:' || loopbackHTTP) && !parsed.username && !parsed.password && !parsed.search && !parsed.hash
100
- }
101
-
102
- export async function loadReleaseConfig({ root = releaseConfigRoot(), cwd = process.env.INIT_CWD || process.cwd(), configPath, env = process.env, envOverrides: allowEnv = false } = {}) {
103
- const explicit = configPath || (allowEnv ? env.PROXY_DESKTOP_RELEASE_CONFIG : '')
104
- const selected = explicit
105
- ? resolve(cwd, explicit)
106
- : await firstExisting(defaultConfigPaths({ root, cwd }))
107
- let fileConfig = {}
108
- if (selected) {
109
- try {
110
- fileConfig = JSON.parse(await readFile(selected, 'utf8'))
111
- } catch (error) {
112
- // JSON parser errors may contain excerpts from the key. Never echo them.
113
- throw new Error(`无法读取发布配置 ${selected}: ${error.code || 'invalid JSON'}`)
114
- }
115
- } else if (explicit) {
116
- throw new Error(`发布配置不存在: ${selected}`)
117
- }
118
- const values = { ...DEFAULT_RELEASE_CONFIG, ...flattenConfig(fileConfig), ...(allowEnv ? envOverrides(env) : {}) }
119
- const configDirectory = selected ? dirname(selected) : root
120
- for (const field of ['releaseDirs', 'recordPaths']) {
121
- if (!Array.isArray(values[field]) || values[field].some(path => typeof path !== 'string' || !path.trim())) throw new Error(`发布配置 release.${field === 'releaseDirs' ? 'directories' : 'records'} 必须是路径数组`)
122
- values[field] = values[field].map(path => resolve(configDirectory, path))
123
- }
124
- if (allowEnv) {
125
- if (env.PROXY_DESKTOP_PLATFORM) values.buildTarget.platform = env.PROXY_DESKTOP_PLATFORM
126
- if (env.PROXY_DESKTOP_ARCH) values.buildTarget.arch = env.PROXY_DESKTOP_ARCH
127
- }
128
- return { ...values, path: selected }
129
- }
130
-
131
- export function validateReleaseConfig(config, { requireUpdateURL = false, requirePublish = false } = {}) {
132
- const updateURL = stringValue(config?.updateURL)
133
- const channel = stringValue(config?.updateChannel || 'latest')
134
- if (requireUpdateURL && !updateURL) throw new Error('发布配置缺少 update.url')
135
- if (updateURL) {
136
- let parsed
137
- const resolved = updateURL.replaceAll('{platform}', 'darwin').replaceAll('{arch}', 'arm64')
138
- try { parsed = new URL(resolved) } catch { throw new Error('发布配置 update.url 必须是 HTTPS 或本机回环 HTTP 地址') }
139
- if (!isAllowedReleaseURL(resolved)) throw new Error('发布配置 update.url 必须是无凭据、无查询参数的 HTTPS 或本机回环 HTTP 地址')
140
- }
141
- if (!['latest', 'beta'].includes(channel)) throw new Error('发布配置 update.channel 必须是 latest 或 beta')
142
- if (requirePublish) {
143
- let parsed
144
- const apiURL = stringValue(config?.apiURL)
145
- try { parsed = new URL(apiURL) } catch { throw new Error('发布配置 release.apiUrl 必须是 HTTPS 或本机回环 HTTP 地址') }
146
- if (!isAllowedReleaseURL(apiURL)) throw new Error('发布配置 release.apiUrl 必须是无凭据、无查询参数的 HTTPS 或本机回环 HTTP 地址')
147
- if (!stringValue(config?.apiKey)) throw new Error('发布配置 release.apiKey 不能为空')
148
- }
149
- return { ...config, updateURL, updateChannel: channel }
150
- }
151
-
152
- export function releaseConfigRoot() {
153
- return join(dirname(fileURLToPath(import.meta.url)), '..')
154
- }
@@ -1,48 +0,0 @@
1
- import { isAllowedReleaseURL } from './release-config.mjs'
2
-
3
- const platforms = new Map([
4
- ['--mac', 'darwin'], ['--macos', 'darwin'], ['-m', 'darwin'],
5
- ['--win', 'win32'], ['--windows', 'win32'], ['-w', 'win32'],
6
- ['--linux', 'linux'], ['-l', 'linux'],
7
- ])
8
- const architectures = new Set(['x64', 'arm64', 'ia32'])
9
-
10
- // An update feed is specific to one OS/CPU. Build each target separately so
11
- // the packaged feed and the generated release record always describe its bytes.
12
- export function releaseTarget(args, host = process) {
13
- const requestedPlatforms = new Set()
14
- const requestedArchitectures = new Set()
15
- for (let i = 0; i < args.length; i += 1) {
16
- const value = args[i]
17
- const platformFlag = value.split('=')[0]
18
- if (platforms.has(platformFlag)) requestedPlatforms.add(platforms.get(platformFlag))
19
- if (value === '--armv7l') throw new Error('Unsupported release architecture: armv7l')
20
- if (value === '--universal') throw new Error('Release builds require a separate arm64 or x64 package; --universal is unsupported')
21
- const arch = value === '--arch' ? args[++i] : value.replace(/^--(?:arch=)?/, '')
22
- if (architectures.has(arch)) requestedArchitectures.add(arch)
23
- else if (value === '--arch' || value.startsWith('--arch=')) throw new Error(`Unsupported release architecture: ${arch}`)
24
- }
25
- if (requestedPlatforms.size > 1 || requestedArchitectures.size > 1) {
26
- throw new Error('Build one platform and architecture at a time; publish their release records together')
27
- }
28
- const platform = [...requestedPlatforms][0] || host.platform
29
- const arch = [...requestedArchitectures][0] || host.arch
30
- if (!['darwin', 'win32', 'linux'].includes(platform) || !architectures.has(arch) || (platform === 'darwin' && arch === 'ia32')) {
31
- throw new Error(`Unsupported release target: ${platform}/${arch}`)
32
- }
33
- return { platform, arch }
34
- }
35
-
36
- export function releaseFeedURL(value, target) {
37
- const resolved = String(value || '').trim().replaceAll('{platform}', target.platform).replaceAll('{arch}', target.arch).replace(/\/+$/, '')
38
- let url
39
- try { url = new URL(resolved) } catch { throw new Error('发布配置 update.url 必须是 HTTPS 或本机回环 HTTP 更新目录地址') }
40
- if (!isAllowedReleaseURL(resolved)) {
41
- throw new Error('发布配置 update.url 必须是无凭据、无查询参数或片段的 HTTPS 或本机回环 HTTP 更新目录地址')
42
- }
43
- const routeTarget = /\/(darwin|win32|linux)\/(arm64|x64|ia32)$/.exec(url.pathname)
44
- if (routeTarget && (routeTarget[1] !== target.platform || routeTarget[2] !== target.arch)) {
45
- throw new Error(`Update URL target does not match ${target.platform}/${target.arch}; use {platform}/{arch} placeholders`)
46
- }
47
- return resolved
48
- }