@ducking-mind/proxy-app-cli 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -52,22 +52,59 @@ npm run dev
52
52
 
53
53
  应用目录内统一使用 `npm run dev`,参数可以通过 `npm run dev -- --port 5175` 传入。新项目的 `dev` 脚本调用 CLI,`dev:server` 脚本启动底层 Vite;CLI 完成授权和平台检查后再调用 `dev:server`。旧项目只有底层 `dev` 脚本时,仍可直接使用 `proxy-app dev` 启动。缓存开发账户无权访问应用时,CLI 会自动清除缓存并请求新的创建者授权。
54
54
 
55
+ ### 应用服务端运行时
56
+
57
+ 应用函数有一次性请求函数和任务函数两种契约,由 `@ducking-mind/proxy-app-sdk` 提供。CLI 只负责开发授权、
58
+ 构建、校验、打包和发布,不承载云端 JavaScript 执行引擎。应用在本地使用 SDK 的
59
+ `createAppRuntimeDevPlugin()` 时,handler 会挂到现有 Vite server 的 `/api/*` 中,不会额外启动
60
+ Node 服务;这是一次性请求函数的本地模式。任务函数始终通过 `/v1/app-runtime/tasks` 进入 Gateway;
61
+ 开发时 CLI 将本地 `runtime/*.js|*.mjs` 登记到应用当前草稿版本,Gateway 持久化任务并派发给 Runtime Host,
62
+ 和发布版本使用同一条任务链路。开发者只能预览自己的未发布函数;已上传或发布版本的函数源码不可被本地开发覆盖,修改时必须递增 `manifest.version`。
63
+
64
+ 运行时应用不得在 `manifest.json` 声明数据库。Runtime Host 是无状态 QuickJS 执行器,不连接数据库、
65
+ 不提供文件系统和 Secret 能力。函数需要平台数据时,通过 `context.platform.request()` 调用 Gateway
66
+ 公开接口;任务的状态、租约、进度、重试和结果由 Gateway 任务面持久化。
67
+
68
+ #### 本地容器后端(过渡联调)
69
+
70
+ manifest 可选择声明 Node 22 应用后端:
71
+
72
+ ```json
73
+ {
74
+ "backend": {
75
+ "type": "container",
76
+ "runtime": "node22",
77
+ "entry": "server/index.mjs",
78
+ "port": 8080,
79
+ "health_check": "/healthz",
80
+ "routes": ["/api/*"]
81
+ }
82
+ }
83
+ ```
84
+
85
+ 入口必须位于 `server/`,并由应用监听 CLI 提供的 `HOST` 与 `PORT`。`proxy-app dev` 完成平台开发检查后,会在 Docker Engine 中以 Node 22 启动后端,并与前端 Vite 服务一起管理;容器以非 root 用户运行,根文件系统只读,源码和依赖描述只读挂载,依赖安装在临时文件系统中,且设置 CPU、内存和进程数限制。后端端口仅绑定到本机回环地址,前端访问同源 `/api/*` 会转发到该后端。开发环境要求 Docker Engine 已启动,依赖必须有 `package-lock.json` 或 Yarn Classic `yarn.lock`。`health_check`、`routes`、`resources.max_concurrency` 当前只做格式校验并写入包内;CPU 和内存会用于本地容器限额,CLI 尚未执行健康探测或本地路由限制。Docker 开发容器用于本地兼容联调,不代表云端多租户安全隔离,也不限制应用代码访问公网。
86
+
87
+ `proxy-app pack` 会将构建后的前端和 `server/` 源码放入 ZIP。平台目前没有应用 handler 的构建、共享运行时和 Gateway 路由能力,因此 `upload` 与 `publish` 会拒绝此类 manifest;打包产物仅供本地检查和后续云端构建链路使用。后端暂时没有可信的用户身份上下文或 Server SDK,不能把浏览器提交的用户 ID、权限或身份头作为服务端授权依据。Node 容器是本地联调过渡方案,云端目标架构见[无状态 JavaScript 服务端运行时](../../docs/APP_CONTAINER_RUNTIME_DESIGN.md)。
88
+
55
89
  `init` 生成的项目统一使用根目录 `index.html` 入口,并包含一个可被主应用注册的
56
- `read_metadata` 示例、`src/agent-tools.mjs`、`agent/guide.md`、工作流和校验示例。能力定义只在代码模块中维护,
57
- 源码 manifest 不包含 Agent 配置;CLI 自动发现 `src/agent-tools.mjs`,处理器注册使用同一份完整定义。构建自动生成发布契约,
58
- 不要同时手工维护 manifest 的能力清单。新增能力的最短流程见
90
+ `read_metadata` 示例、`src/agent-tools.mjs`、`agent/guide.md`、工作流和校验示例。能力和处理器在 `registerAgentTools`
91
+ 中的同一条 `registry.register(declaration, handler)` 路径实现;CLI 执行该函数收集构建目录。源码 manifest 不包含 Agent 配置,
92
+ 也不支持单独导出的能力数组。新增能力的最短流程见
59
93
  [应用开发指南](../docs/APP_DEVELOPMENT.md)。
60
94
 
61
95
  新项目的 manifest 不需要 `apis`、`permissions`、`backend`、`pricing`。省略时默认使用平台 Gateway,
62
96
  开放全部当前公开接口和 KV;收费在“我的应用”中设置,提交开放申请前必须填写按调用抽成比例。已有应用可保留显式数组来缩小能力范围,显式空数组表示不使用。
63
- 所有应用都由 Portal 以同源顶层文档启动,资料、KV 和 Gateway 请求使用 App Session Cookie。本地开发源码会自动选择 `local`。
97
+ 已发布应用由 Portal 以同源顶层文档启动。浏览器在 Portal 登录后,使用 HttpOnly 设备 Cookie 换取绑定应用版本的 `ast_*` App Session,并只保存在当前标签页;资料、KV 和 Gateway 请求通过该会话访问 Gateway。SDK 的 `local` 模式通过 Portal 登录弹窗获取同样的 App Session,Vite 只代理 Gateway `/v1`;任务函数仍由 Gateway 持久化并派发给 Runtime Host,不在 Vite 中执行。
64
98
  入口统一为应用根目录的 `index.html`。旧应用即使仍带有 `runtime` 或 `desktop` 字段也会继续兼容读取;这些字段不参与运行方式选择,历史 iframe 运行方式仍不再支持。
65
99
 
66
- 已有项目后续发布时,将旧 `manifest.agent` 中的元数据和能力数组迁移到 `src/agent-tools.mjs` 的
67
- `agentMetadata` 和 `capabilities`,再从源码 manifest 删除 `agent`。已经发布的旧版应用包仍保持兼容;迁移后由 CLI 从代码声明生成新版发布能力目录。
100
+ 已有项目迁移时,将旧 `manifest.agent` 的元数据移入 `agentMetadata`,并把每项能力的 Schema 与实际处理器合并到
101
+ `registerAgentTools` 中的注册调用,再从源码 manifest 删除 `agent`。已经发布的旧版应用包仍保持宿主兼容;新版 CLI
102
+ 只从注册实现生成发布目录,声明数组形式的源码会被拒绝。
68
103
 
69
104
  开发和发布已有应用时,将 `manifest.id` 设为平台应用的公开 slug(或稳定 `app_...` ID),即可直接运行
70
105
  `proxy-app dev`、`proxy-app upload` 和 `proxy-app publish`;不需要本地绑定文件、`link` 或 `repair`。
106
+
107
+ 本地开发默认将 `/v1` 代理到线上 Gateway `https://api.duckingmind.com/v1`,与远程平台签发的 App Session 保持同一数据面。需要整套本地平台联调时,显式设置 `PROXY_PLATFORM_GATEWAY_URL` 覆盖默认地址。
71
108
  `.proxy-app.json` 仅作为旧项目的兼容缓存,仍可由 `create` / `link` / `repair` 生成;`upload`、`publish` 和 `status` 不读取它,始终按 `manifest.id` 查询平台应用。
72
109
  公开 slug 直接使用输入值,例如 `canvas`、`my-app` 或 `app.proxy.canvas`,不会自动添加前缀;
73
110
  长度为 1–120,规则为 `^[a-z0-9]+(?:[.-][a-z0-9]+)*$`。`validate`、`pack`、`dev` 都使用同一规则,
@@ -119,14 +156,13 @@ proxy-app pack apps/my-app dist/my-app.zip
119
156
  CLI 会继续检查并启动应用;十分钟内未完成会超时,也可用 Ctrl+C 取消。
120
157
  本地开发不要求手工配置 token。授权失败时,页面会显示当前授权账户、开发者登录页面、API 地址和应用 slug,便于区分账户、slug 与平台地址不匹配。若看到旧式通用错误页,请先停止旧的开发进程,再从应用目录运行 `npm run dev`。
121
158
 
122
- macOS 默认分别缓存开发者授权和应用运行用户授权,按平台地址、应用、项目目录及用途隔离。
159
+ macOS 默认将开发者授权保存在系统钥匙串,按平台地址、应用和项目目录隔离。运行用户的 Portal Cookie 由 Portal 管理,App Session 只保存在当前浏览器标签页。
123
160
  每次重启都重新登记当前 manifest 与本地入口,开发权限不能依赖上一次检查结果。
124
- 完成开发准备后,应用内用户登录才创建短期 App Session;用户的登录、缓存恢复、退出和切换账户
125
- 均不调用开发登记接口,也不清除开发授权。未发布草稿仍受平台访问权限限制,非创建者需使用其有权访问的已发布版本。
161
+ 开发检查通过后,CLI 直接启动 Vite,不预取用户 App Session。
162
+ 应用打开后通过 Portal 登录弹窗取得当前用户的 App Session。开发时 Vite 的同源桥仅转发 HttpOnly 设备 Cookie 和 CLI 为当前草稿签发的预览许可,不读取 Portal JWT,也不缓存 App Session;生产版本直接由 Portal Cookie 换取已发布版本会话。预览许可绑定单个应用和版本,用户 AST 仍归属于各自的 Portal 账户。
126
163
 
127
- 开发账户错误时,重新运行 `proxy-app dev apps/my-app` 即可。CLI 会在登记返回账户无权访问的 401、403 或 404 时清除开发账户缓存,并打开一次新的创建者授权入口;运行用户的授权不会被清除。
128
- 退出开发服务会撤销本次 App Session,保留两类缓存。应用调用 `app.auth.logout()` 只清除运行用户授权;
129
- `app.auth.login({ forceLogin: true })` 只切换运行用户。平台确认缓存授权过期后才要求重新登录,临时故障保留缓存。
164
+ 开发账户错误时,重新运行 `proxy-app dev apps/my-app` 即可。CLI 会在登记返回账户无权访问的 401、403 或 404 时清除开发者授权缓存,并打开一次新的创建者授权入口。运行用户会话独立于 CLI。
165
+ 应用内的认证状态通过 Gateway `GET /v1/profile` 预检;App Session 过期或无效时,应用登录按钮会打开 Portal,通过 HttpOnly 设备 Cookie 换取新会话。若设备 Cookie 不存在,Portal 登录后会签发 Cookie 并重试换票;账户切换后重新加载应用以隔离旧账户工作区。
130
166
 
131
167
  其他系统或钥匙串不可用时,仅使用当前进程内存,不会退回明文文件保存 token。
132
168
  如需禁用持久缓存,可运行:
@@ -135,7 +171,7 @@ macOS 默认分别缓存开发者授权和应用运行用户授权,按平台
135
171
  proxy-app dev apps/my-app --no-auth-cache
136
172
  ```
137
173
 
138
- 此选项禁用两类缓存,不读取或更新已有的钥匙串授权。CLI 不会把授权凭据写入项目、构建产物或本地应用的浏览器存储。
174
+ 此选项禁用开发者授权的钥匙串缓存,不读取或更新已有的钥匙串授权。CLI 不会把用户会话写入项目或构建产物。
139
175
 
140
176
  `build`、`validate`、`pack` 只验证本地产物,不代表远程绑定有效。`doctor` 的绑定检查也是本地结构检查。
141
177
  上传和发布继续在平台验证应用归属与版本,开发启动前置检查不能替代它们。
@@ -164,6 +200,7 @@ CLI 从已导出的变量读取配置,不会自动加载 `.env` 文件。请
164
200
  默认使用线上平台 `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`。
165
201
  管理命令(创建、发布等)仍需 `PROXY_USER_ACCESS_TOKEN`;本地开发使用浏览器授权,不需要这个变量。
166
202
  若 shell 已全局导出 `PROXY_USER_ACCESS_TOKEN` 或旧别名 `PROXY_ACCESS_TOKEN`,开发命令会优先使用该令牌;想在浏览器中选择创建者账户时先运行 `unset PROXY_USER_ACCESS_TOKEN PROXY_ACCESS_TOKEN`。
203
+ 本地开发时,Portal 在登录后设置 HttpOnly 设备 Cookie;应用通过 Portal 登录弹窗换取自己的 App Session,并将它保存在当前标签页。Vite 只在本地换票时转发设备 Cookie,不读取或转存 Portal JWT,也不保存或续期用户 App Session。
167
204
 
168
205
  manifest、运行时 API 和发布规范请参阅仓库文档:
169
206
 
@@ -1,9 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawn } from 'node:child_process'
4
+ import { realpathSync } from 'node:fs'
4
5
  import { lstat, mkdir, readFile, writeFile } from 'node:fs/promises'
5
6
  import { resolve, sep } from 'node:path'
6
- import { pathToFileURL } from 'node:url'
7
+ import { fileURLToPath, pathToFileURL } from 'node:url'
7
8
 
8
9
  const runtimeDirectory = './agent-runtime/'
9
10
  const { normalizeCapabilityDeclaration, normalizeManifestAgent } = await import(new URL(`${runtimeDirectory}protocol.mjs`, import.meta.url))
@@ -37,7 +38,7 @@ async function assertSafeEntry(root, entry) {
37
38
  // the build validating Node's cached version. The child receives a path only
38
39
  // after it has passed the project-local source checks above.
39
40
  async function loadContract(target) {
40
- const worker = `import { writeFileSync } from 'node:fs';\nconst module = await import(process.argv[1]);\nwriteFileSync(3, JSON.stringify({ agentMetadata: module.agentMetadata, capabilities: module.capabilities }));\nprocess.exit(0);`
41
+ const worker = `import { writeFileSync } from 'node:fs';\nconst module = await import(process.argv[1]);\nif (typeof module.registerAgentTools !== 'function') throw new Error('src/agent-tools.mjs must export registerAgentTools(); capability arrays are not supported');\nconst capabilities = [];\nconst cleanup = module.registerAgentTools({ register(declaration) { capabilities.push(declaration); return () => {}; } });\nif (cleanup !== undefined && !Array.isArray(cleanup)) throw new Error('registerAgentTools must return an array of cleanup functions or undefined');\nwriteFileSync(3, JSON.stringify({ agentMetadata: module.agentMetadata, capabilities }));\nprocess.exit(0);`
41
42
  return new Promise((resolvePromise, reject) => {
42
43
  const child = spawn(process.execPath, ['--input-type=module', '-e', worker, pathToFileURL(target).href], { stdio: ['ignore', 'ignore', 'pipe', 'pipe'] })
43
44
  const output = [], errors = []
@@ -55,7 +56,7 @@ async function loadContract(target) {
55
56
  if (failure) return reject(failure)
56
57
  if (code !== 0) return reject(new Error(`cannot load agent contract source: ${Buffer.concat(errors).toString().slice(0, 2048).trim() || `process exited with ${code}`}`))
57
58
  try { resolvePromise(JSON.parse(Buffer.concat(output).toString())) }
58
- catch { reject(new Error('src/agent-tools.mjs must export JSON-serializable agentMetadata and capabilities')) }
59
+ catch { reject(new Error('src/agent-tools.mjs must expose JSON-serializable agentMetadata and capabilities through registerAgentTools()')) }
59
60
  })
60
61
  })
61
62
  }
@@ -63,7 +64,9 @@ async function loadContract(target) {
63
64
  /**
64
65
  * Resolve the code-owned Agent contract for a local source project.
65
66
  * `src/agent-tools.mjs` is discovered by convention; source manifests never
66
- * opt into or enumerate Agent capabilities. Installed packages carry the
67
+ * opt into or enumerate Agent capabilities. The module's registration function
68
+ * is executed with a collector, so the package contract is derived from the
69
+ * same code path that binds runtime handlers. Installed packages carry the
67
70
  * generated contract so MCP can discover tools before launching the app.
68
71
  */
69
72
  export async function resolveAgentContract(root, manifest) {
@@ -79,7 +82,7 @@ export async function resolveAgentContract(root, manifest) {
79
82
  throw error
80
83
  }
81
84
  const { agentMetadata, capabilities } = await loadContract(target)
82
- if (!Array.isArray(capabilities) || capabilities.length > 64) throw new Error('src/agent-tools.mjs must export capabilities as an array with at most 64 entries')
85
+ if (!Array.isArray(capabilities) || capabilities.length > 64) throw new Error('src/agent-tools.mjs must register at most 64 capabilities')
83
86
  if (!isRecord(agentMetadata)) throw new Error('src/agent-tools.mjs must export agentMetadata as an object')
84
87
  const allowedMetadata = new Set(['namespace', 'targets', 'contract_version', 'summary', 'keywords', 'guide', 'examples'])
85
88
  for (const key of Object.keys(agentMetadata)) if (!allowedMetadata.has(key)) throw new Error(`unsupported agentMetadata field: ${key}`)
@@ -113,7 +116,11 @@ export async function writeAgentContract(root, destination) {
113
116
  return manifest
114
117
  }
115
118
 
116
- if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
119
+ const invokedDirectly = process.argv[1] && (() => {
120
+ try { return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) } catch { return false }
121
+ })()
122
+
123
+ if (invokedDirectly) {
117
124
  try {
118
125
  const [, , root = '.', destination = 'dist'] = process.argv
119
126
  await writeAgentContract(root, destination)
@@ -0,0 +1,199 @@
1
+ import { spawn } from 'node:child_process'
2
+ import { createHash } from 'node:crypto'
3
+ import { lstat, readFile, readdir } from 'node:fs/promises'
4
+ import { join, resolve } from 'node:path'
5
+
6
+ export const LOCAL_BACKEND_IMAGE = 'node:22-bookworm-slim'
7
+
8
+ const LOCKFILES = Object.freeze([
9
+ { file: 'package-lock.json', install: 'npm ci --prefix /workspace --cache /tmp/npm-cache --no-audit --no-fund' },
10
+ { file: 'yarn.lock', install: 'corepack yarn@1.22.22 --cwd /workspace install --frozen-lockfile --non-interactive --cache-folder /tmp/yarn-cache --modules-folder /workspace/node_modules' },
11
+ ])
12
+
13
+ function shellQuote(value) {
14
+ return `'${String(value).replaceAll("'", "'\\''")}'`
15
+ }
16
+
17
+ function dockerMountPath(value) {
18
+ return resolve(value).replaceAll('\\', '\\\\').replaceAll(',', '\\,')
19
+ }
20
+
21
+ function dockerMount(source, target, readOnly = false) {
22
+ return `type=bind,src=${dockerMountPath(source)},dst=${target}${readOnly ? ',readonly' : ''}`
23
+ }
24
+
25
+ function safeContainerLabel(value) {
26
+ return String(value).replace(/[^A-Za-z0-9_.-]/g, '-').slice(0, 120) || 'app'
27
+ }
28
+
29
+ async function backendSources(serverDir) {
30
+ const root = await lstat(serverDir).catch((error) => {
31
+ if (error.code === 'ENOENT') throw new Error('container backend source directory is missing: server/')
32
+ throw error
33
+ })
34
+ if (!root.isDirectory() || root.isSymbolicLink()) throw new Error('container backend source must be a regular server/ directory')
35
+
36
+ const visit = async (directory, relative = '') => {
37
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
38
+ const path = join(directory, entry.name)
39
+ const name = relative ? `${relative}/${entry.name}` : entry.name
40
+ if (entry.isSymbolicLink()) throw new Error(`container backend source cannot contain symbolic links: server/${name}`)
41
+ if (entry.name === 'node_modules') throw new Error('server/node_modules is not mounted; declare backend dependencies in the app package.json')
42
+ if (entry.name === '.npmrc' || entry.name === '.env' || entry.name.startsWith('.env.')) {
43
+ throw new Error(`sensitive file cannot be mounted into the local backend container: server/${name}`)
44
+ }
45
+ if (entry.isDirectory()) await visit(path, name)
46
+ else if (!entry.isFile()) throw new Error(`container backend source contains an unsupported file: server/${name}`)
47
+ }
48
+ }
49
+ await visit(serverDir)
50
+ }
51
+
52
+ async function dependencySetup(root) {
53
+ const found = []
54
+ for (const item of LOCKFILES) {
55
+ const path = join(root, item.file)
56
+ const info = await lstat(path).catch((error) => {
57
+ if (error.code === 'ENOENT') return null
58
+ throw error
59
+ })
60
+ if (!info) continue
61
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error(`backend dependency lock must be a regular file: ${item.file}`)
62
+ found.push({ ...item, path })
63
+ }
64
+ if (found.length > 1) throw new Error('container backend requires exactly one lockfile; remove the unused npm/yarn/pnpm lockfiles')
65
+ const selected = found[0]
66
+ if (!selected) {
67
+ throw new Error('container backend requires a dependency lockfile (package-lock.json or a Yarn Classic yarn.lock); run npm install or yarn install first')
68
+ }
69
+ if (selected?.file === 'yarn.lock' && !(await readFile(selected.path, 'utf8')).startsWith('# yarn lockfile v1')) {
70
+ throw new Error('local container development currently supports Yarn Classic lockfiles; use package-lock.json for other package managers')
71
+ }
72
+ return { install: selected.install, lockfile: selected }
73
+ }
74
+
75
+ function gatewayForContainer(value) {
76
+ try {
77
+ const url = new URL(value)
78
+ if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) throw new Error('invalid gateway URL')
79
+ if (['localhost', '127.0.0.1', '::1'].includes(url.hostname)) url.hostname = 'host.docker.internal'
80
+ return url.toString()
81
+ } catch {
82
+ throw new Error('PROXY_PLATFORM_GATEWAY_URL must be an absolute HTTP(S) URL')
83
+ }
84
+ }
85
+
86
+ export async function createBackendContainerSpec({ appDir, backend, appID, version, gatewayURL, dockerBin = 'docker', dockerEnv = process.env } = {}) {
87
+ const root = resolve(appDir)
88
+ const serverDir = join(root, 'server')
89
+ await backendSources(serverDir)
90
+
91
+ const packagePath = join(root, 'package.json')
92
+ const packageInfo = await lstat(packagePath).catch((error) => {
93
+ if (error.code === 'ENOENT') throw new Error('container backend requires an app package.json')
94
+ throw error
95
+ })
96
+ if (!packageInfo.isFile() || packageInfo.isSymbolicLink()) throw new Error('container backend package.json must be a regular file')
97
+
98
+ const { install, lockfile } = await dependencySetup(root)
99
+ const identity = safeContainerLabel(appID)
100
+ const rootHash = createHash('sha256').update(root).digest('hex').slice(0, 10)
101
+ const containerName = `proxy-app-dev-${identity.slice(0, 36)}-${rootHash}-${process.pid}`
102
+ const cpu = ((backend.resources?.cpu_millicores ?? 1000) / 1000).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')
103
+ const memory = backend.resources?.memory_mib ?? 512
104
+ const env = {
105
+ HOME: '/tmp/home',
106
+ TMPDIR: '/tmp',
107
+ COREPACK_HOME: '/tmp/corepack',
108
+ NODE_ENV: 'development',
109
+ PORT: String(backend.port),
110
+ HOST: '0.0.0.0',
111
+ PROXY_APP_ID: appID,
112
+ PROXY_APP_VERSION: version,
113
+ PROXY_APP_BACKEND_HOST: '0.0.0.0',
114
+ PROXY_APP_BACKEND_PORT: String(backend.port),
115
+ ...(gatewayURL ? { PROXY_PLATFORM_GATEWAY_URL: gatewayForContainer(gatewayURL) } : {}),
116
+ ...(backend.resources?.max_concurrency ? { PROXY_APP_MAX_CONCURRENCY: String(backend.resources.max_concurrency) } : {}),
117
+ }
118
+
119
+ const args = [
120
+ 'run', '--rm', '--init', '--pull=missing',
121
+ `--name=${containerName}`,
122
+ '--label=proxy.app.dev=true',
123
+ `--label=proxy.app.id=${safeContainerLabel(appID)}`,
124
+ `--label=proxy.app.version=${safeContainerLabel(version)}`,
125
+ '--user=1000:1000',
126
+ '--read-only',
127
+ '--cap-drop=ALL',
128
+ '--security-opt=no-new-privileges:true',
129
+ '--pids-limit=128',
130
+ `--memory=${memory}m`,
131
+ `--memory-swap=${memory}m`,
132
+ `--cpus=${cpu}`,
133
+ '--ulimit=nofile=1024:1024',
134
+ '--add-host=host.docker.internal:host-gateway',
135
+ `--publish=127.0.0.1:${backend.port}:${backend.port}/tcp`,
136
+ '--tmpfs=/workspace:rw,nosuid,nodev,size=1024m,uid=1000,gid=1000,mode=755',
137
+ '--tmpfs=/tmp:rw,nosuid,nodev,size=256m,uid=1000,gid=1000,mode=1777',
138
+ `--mount=${dockerMount(serverDir, '/workspace/server', true)}`,
139
+ `--mount=${dockerMount(packagePath, '/workspace/package.json', true)}`,
140
+ ...(lockfile ? [`--mount=${dockerMount(lockfile.path, `/workspace/${lockfile.file}`, true)}`] : []),
141
+ '--workdir=/workspace',
142
+ ...Object.entries(env).map(([name, value]) => `--env=${name}=${value}`),
143
+ '--entrypoint=/bin/sh',
144
+ LOCAL_BACKEND_IMAGE,
145
+ '-lc',
146
+ `set -eu; mkdir -p /tmp/home; ${install}; exec node --watch -- ${shellQuote(`/workspace/${backend.entry}`)}`,
147
+ ]
148
+
149
+ return {
150
+ command: dockerBin,
151
+ args,
152
+ cwd: root,
153
+ env: dockerClientEnvironment(dockerEnv),
154
+ name: '应用后端容器',
155
+ containerName,
156
+ image: LOCAL_BACKEND_IMAGE,
157
+ }
158
+ }
159
+
160
+ export function dockerClientEnvironment(source = process.env) {
161
+ const allowed = [
162
+ 'PATH', 'HOME', 'USERPROFILE', 'DOCKER_HOST', 'DOCKER_CONTEXT',
163
+ 'DOCKER_CONFIG', 'DOCKER_CERT_PATH', 'DOCKER_TLS_VERIFY',
164
+ 'DOCKER_API_VERSION', 'DOCKER_DEFAULT_PLATFORM', 'TMPDIR', 'TEMP', 'TMP', 'SYSTEMROOT',
165
+ ]
166
+ return Object.fromEntries(allowed.filter((name) => source[name] !== undefined).map((name) => [name, source[name]]))
167
+ }
168
+
169
+ export async function verifyDockerEngine({ dockerBin = 'docker', env = dockerClientEnvironment(), timeoutMs = 8_000 } = {}) {
170
+ await new Promise((resolvePromise, reject) => {
171
+ const child = spawn(dockerBin, ['info', '--format={{.ServerVersion}}'], { stdio: ['ignore', 'ignore', 'pipe'], env })
172
+ let settled = false
173
+ let stderr = ''
174
+ let timer
175
+ const finish = (error) => {
176
+ if (settled) return
177
+ settled = true
178
+ clearTimeout(timer)
179
+ if (error) reject(error)
180
+ else resolvePromise()
181
+ }
182
+ timer = setTimeout(() => {
183
+ child.kill('SIGTERM')
184
+ finish(new Error('Docker Engine 检查超时;请确认 Docker Desktop 或 Docker Engine 已启动。'))
185
+ }, timeoutMs)
186
+ timer.unref?.()
187
+ child.stderr.on('data', (chunk) => {
188
+ if (stderr.length < 4_000) stderr += chunk.toString('utf8').slice(0, 4_000 - stderr.length)
189
+ })
190
+ child.once('error', (error) => {
191
+ finish(new Error(`无法启动 Docker CLI:${error.message}`))
192
+ })
193
+ child.once('close', (code) => {
194
+ if (code === 0) return finish()
195
+ const detail = stderr.trim().split('\n').at(-1)
196
+ finish(new Error(`Docker Engine 不可用${detail ? `:${detail}` : ''};请启动 Docker Desktop 或 Docker Engine 后重试。`))
197
+ })
198
+ })
199
+ }
@@ -31,9 +31,9 @@ function credentialAccount({ baseURL, portalURL, appID, projectPath, purpose })
31
31
  const identity = [
32
32
  normalizeURL(baseURL), normalizeURL(portalURL || baseURL), appID.trim(), project,
33
33
  ]
34
- // Keep existing runtime cache identities intact. Developer authorization is
35
- // a separate capability and must survive an app user's logout/account switch.
36
- if (purpose === 'development') identity.push('development')
34
+ // Deliberately isolate developer authorization from any older runtime
35
+ // credential entries that may remain in the OS keychain.
36
+ identity.push('development')
37
37
  return createHash('sha256').update(JSON.stringify(identity)).digest('hex')
38
38
  }
39
39
 
@@ -92,13 +92,13 @@ function runSecurityCommand(file, args, { input = '', timeoutMs }) {
92
92
  */
93
93
  export function createLocalCredentialStore({
94
94
  baseURL, portalURL = '', appID, projectPath,
95
- purpose = 'runtime',
95
+ purpose = 'development',
96
96
  platform = process.platform,
97
97
  runCommand = runSecurityCommand,
98
98
  timeoutMs = TIMEOUT_MS,
99
99
  stateDirectory = join(homedir(), 'Library', 'Caches', 'proxy-app', 'credentials'),
100
100
  }) {
101
- if (!['runtime', 'development'].includes(purpose)) throw new Error('未知的授权缓存用途。')
101
+ if (purpose !== 'development') throw new Error('本地凭证缓存只支持开发者授权。')
102
102
  const account = credentialAccount({ baseURL, portalURL, appID, projectPath, purpose })
103
103
  const markerPath = join(stateDirectory, account + '.disabled')
104
104
  const boundedTimeout = Number.isFinite(timeoutMs) ? Math.max(1, Math.min(timeoutMs, TIMEOUT_MS)) : TIMEOUT_MS