@ducking-mind/proxy-app-cli 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Proxy App contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,105 @@
1
+ # @ducking-mind/proxy-app-cli
2
+
3
+ CLI for developing, validating, packaging, and publishing applications for the
4
+ Proxy App platform.
5
+
6
+ ## Install
7
+
8
+ The CLI requires Node.js 20.19 or newer. Install it from npm after the package
9
+ is published:
10
+
11
+ ```bash
12
+ npm install --global @ducking-mind/proxy-app-cli
13
+ ```
14
+
15
+ When working in this repository, run it from the repository root instead:
16
+
17
+ ```bash
18
+ npm run app -- --help
19
+ ```
20
+
21
+ ## Commands
22
+
23
+ `<app-dir>` is the directory containing `manifest.json` and the app's build
24
+ script.
25
+
26
+ ```bash
27
+ # Create a starter app and connect it to a platform app
28
+ proxy-app init apps/my-app --name "My App"
29
+ proxy-app create apps/my-app
30
+
31
+ # Link an existing local app to a platform app by its platform ID
32
+ proxy-app link apps/my-app <app-id>
33
+
34
+ # Run the app's own build script and validate the resulting dist/ directory
35
+ proxy-app build apps/my-app
36
+ proxy-app validate apps/my-app
37
+ proxy-app doctor apps/my-app
38
+
39
+ # Package the built app (manifest.json is placed at the ZIP root)
40
+ proxy-app pack apps/my-app
41
+
42
+ # Upload a version without submitting it for review
43
+ proxy-app upload apps/my-app
44
+
45
+ # Upload a missing version and submit it for platform review
46
+ proxy-app publish apps/my-app
47
+
48
+ # Inspect the local binding and remote version state
49
+ proxy-app status apps/my-app
50
+ ```
51
+
52
+ Release `pack`, `upload`, and `publish` require a successful build and use the
53
+ `dist/` output. `--source` is available on `pack` and `upload` only for local
54
+ debugging; source packages are not release packages. The app manifest version
55
+ must be increased for a new artifact. `publish` submits the version for review;
56
+ it does not bypass platform review or make an app immediately public.
57
+
58
+ ## Management authentication
59
+
60
+ Management commands (`create`, `link`, `upload`, `publish`, and authenticated
61
+ `status`) use the current platform Portal user's access token. The CLI does not
62
+ provide a `login` command and never passes this token to the app runtime.
63
+
64
+ 1. Sign in to the platform Portal as the app owner.
65
+ 2. In the browser developer tools, open the Portal origin's local storage and
66
+ copy the `access_token` value.
67
+ 3. Export it only in the shell used for the command:
68
+
69
+ ```bash
70
+ export PROXY_API_BASE_URL="https://your-platform.example/api"
71
+ printf 'Portal access token: '
72
+ read -rs PROXY_USER_ACCESS_TOKEN
73
+ export PROXY_USER_ACCESS_TOKEN
74
+ printf '\n'
75
+
76
+ proxy-app create apps/my-app
77
+ proxy-app build apps/my-app
78
+ proxy-app upload apps/my-app
79
+ proxy-app publish apps/my-app
80
+
81
+ unset PROXY_USER_ACCESS_TOKEN
82
+ ```
83
+
84
+ For a local platform, the default API base is
85
+ `http://localhost:9003/api`. `PROXY_ACCESS_TOKEN` is accepted as a legacy alias
86
+ for `PROXY_USER_ACCESS_TOKEN`. Never commit either variable, browser cookies,
87
+ App Session tokens, or provider credentials.
88
+
89
+ ## Local development
90
+
91
+ ```bash
92
+ proxy-app dev apps/my-app --port 5173
93
+ ```
94
+
95
+ Development uses a loopback Relay and the signed-in user's short-lived App
96
+ Session. The default runtime requests use HTTP. Pass `--legacy-bridge` only when
97
+ running an app that explicitly needs the v1 Bridge compatibility path. The
98
+ management token is not injected into the app process.
99
+
100
+ See the repository documentation for the manifest, runtime API, and release
101
+ contract:
102
+
103
+ - [`docs/APP_MARKET.md`](../docs/APP_MARKET.md)
104
+ - [`docs/API_REFERENCE.md`](../docs/API_REFERENCE.md)
105
+ - [`docs/ARCHITECTURE.md`](../docs/ARCHITECTURE.md)
@@ -0,0 +1,438 @@
1
+ import { createServer } from 'node:http'
2
+ import { randomUUID } from 'node:crypto'
3
+ import { Readable } from 'node:stream'
4
+
5
+ const HOP_BY_HOP_HEADERS = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade'])
6
+ const SENSITIVE_HEADERS = new Set(['host', 'authorization', 'cookie', 'origin', 'referer', 'content-length', 'x-proxy-app-relay'])
7
+ const PLATFORM_AUTH_TIMEOUT_MS = 30 * 1000
8
+
9
+ function copyRequestHeaders(request) {
10
+ const headers = new Headers()
11
+ for (const [name, value] of Object.entries(request.headers)) {
12
+ const lower = name.toLowerCase()
13
+ if (HOP_BY_HOP_HEADERS.has(lower) || SENSITIVE_HEADERS.has(lower)) continue
14
+ if (Array.isArray(value)) headers.set(name, value.join(', '))
15
+ else if (value !== undefined) headers.set(name, value)
16
+ }
17
+ return headers
18
+ }
19
+
20
+ function copyResponseHeaders(response, target) {
21
+ response.headers.forEach((value, name) => {
22
+ const lower = name.toLowerCase()
23
+ if (!HOP_BY_HOP_HEADERS.has(lower) && !['content-encoding', 'content-length', 'set-cookie'].includes(lower)) target.setHeader(name, value)
24
+ })
25
+ }
26
+
27
+ async function readBody(request, limit = 16 * 1024 * 1024) {
28
+ const chunks = []
29
+ let size = 0
30
+ for await (const chunk of request) {
31
+ size += chunk.length
32
+ if (size > limit) throw new Error('local development request body is too large')
33
+ chunks.push(chunk)
34
+ }
35
+ return Buffer.concat(chunks)
36
+ }
37
+
38
+ async function readJSON(response) {
39
+ const text = await response.text().catch(() => '')
40
+ if (!text.trim()) return {}
41
+ try { return JSON.parse(text) } catch { return { message: text } }
42
+ }
43
+
44
+ function unwrap(payload) {
45
+ return payload && Object.prototype.hasOwnProperty.call(payload, 'data') ? payload.data : payload
46
+ }
47
+
48
+ function errorMessage(payload, fallback) {
49
+ const error = typeof payload?.error === 'string' ? payload.error : payload?.error?.message
50
+ return error || payload?.message || fallback
51
+ }
52
+
53
+ function errorCode(payload) {
54
+ const error = payload?.error
55
+ const code = typeof error === 'object' && error ? error.code : undefined
56
+ return String(code || payload?.code || '').trim()
57
+ }
58
+
59
+ async function retryableSessionExpiry(response, runtimeRequest) {
60
+ if (response?.status !== 401) return false
61
+ const payload = await readJSON(response.clone())
62
+ const error = payload?.error
63
+ const reason = typeof error === 'object' && error ? error.auth_reason : undefined
64
+ const executionStarted = typeof error === 'object' && error && Object.prototype.hasOwnProperty.call(error, 'execution_started')
65
+ ? error.execution_started
66
+ : payload?.execution_started
67
+ return (runtimeRequest ? errorCode(payload) : reason) === 'APP_SESSION_EXPIRED' && executionStarted === false
68
+ }
69
+
70
+ function relayError(code, message, stage, statusCode = 502, cause) {
71
+ const error = new Error(message)
72
+ error.code = code
73
+ error.stage = stage
74
+ error.statusCode = statusCode
75
+ if (cause) error.cause = cause
76
+ return error
77
+ }
78
+
79
+ function visibleErrorMessage(error) {
80
+ switch (error?.code) {
81
+ case 'APP_PLATFORM_UNAVAILABLE': return '无法连接平台,请确认平台服务已启动或网络可用后重试。'
82
+ case 'APP_PLATFORM_TIMEOUT': return '平台响应超时,请稍后重试。'
83
+ case 'APP_LOGIN_SESSION_EXPIRED': return '平台登录态已失效,请重新登录。'
84
+ case 'APP_LOGIN_NOT_ALLOWED': return '当前账户无权使用此应用,请切换账户后重试。'
85
+ case 'APP_APP_REGISTRATION_FAILED':
86
+ if (error?.upstreamCode === 'APP_LOCAL_MANIFEST_CONFLICT' || error?.message === 'local development version already exists with a different manifest; bump manifest.version') return '平台登录已完成,但本地 manifest 与已上传或发布的同版本内容不同。请更新 manifest.version 后重启 proxy-app dev。'
87
+ if (error?.statusCode === 403) return '当前登录账户无权开发此应用,请使用创建该应用的账户登录。'
88
+ if (error?.statusCode === 404) return '应用不存在,或当前账户不是应用创建者。请检查 .proxy-app.json 中的应用绑定。'
89
+ return '应用开发登记失败(HTTP ' + error?.statusCode + '),请根据请求编号检查平台日志。'
90
+ case 'APP_SESSION_CREATE_FAILED': return '用户 App Session 创建失败,请稍后重试。'
91
+ case 'APP_SESSION_INVALID': return '平台返回的用户 App Session 无效,请稍后重试。'
92
+ default: return String(error?.message || '平台登录失败,请重试。').slice(0, 240)
93
+ }
94
+ }
95
+
96
+ function htmlEscape(value) {
97
+ return String(value).replace(/[&<>"']/g, (character) => ({
98
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
99
+ }[character]))
100
+ }
101
+
102
+ function safeScriptJSON(value) {
103
+ return JSON.stringify(value)
104
+ .replace(/</g, '\\u003c')
105
+ .replace(/>/g, '\\u003e')
106
+ .replace(/&/g, '\\u0026')
107
+ .replace(/\u2028/g, '\\u2028')
108
+ .replace(/\u2029/g, '\\u2029')
109
+ }
110
+
111
+ function safeURL(value, label) {
112
+ let parsed
113
+ try { parsed = new URL(value) } catch { throw new Error(label + ' must be a valid http(s) URL') }
114
+ if (!['http:', 'https:'].includes(parsed.protocol) || parsed.username || parsed.password || parsed.search || parsed.hash) {
115
+ throw new Error(label + ' must be an http(s) URL without credentials, query, or hash')
116
+ }
117
+ return parsed.origin + parsed.pathname.replace(/\/$/, '')
118
+ }
119
+
120
+ function normalizeGatewayURL(value) {
121
+ const normalized = safeURL(value, 'platform returned gateway URL')
122
+ return normalized.endsWith('/v1') ? normalized : normalized + '/v1'
123
+ }
124
+
125
+ function jsonResponse(response, status, payload) {
126
+ response.statusCode = status
127
+ response.setHeader('Content-Type', 'application/json')
128
+ response.setHeader('Cache-Control', 'no-store')
129
+ response.end(JSON.stringify(payload))
130
+ }
131
+
132
+ /**
133
+ * Real local development relay. It stores only the current signed-in user's
134
+ * Portal token in memory and gives the app an App Session, never the Portal
135
+ * token. The token is obtained through the platform login callback, not from
136
+ * the developer's shell environment, so model calls belong to the user who
137
+ * opened the local app.
138
+ */
139
+ export async function createLocalDevelopmentRelay({ baseURL, portalURL = '', relaySecret, appID, appSlug, version, manifest, entryURL, legacyBridge = false }) {
140
+ if (!baseURL || !relaySecret || !appID || !appSlug || !version || !entryURL) throw new Error('local development relay configuration is incomplete')
141
+ const portal = safeURL(baseURL, 'platform API URL')
142
+ const portalOrigin = safeURL(portalURL || portal, 'platform Portal URL')
143
+ const state = { portalToken: '', session: null, refresh: null, login: null, authGeneration: 0, authState: '', authStateIssuedAt: 0, closed: false, revoked: new Set() }
144
+ let server
145
+ let relayURL = ''
146
+
147
+ const portalJSON = async (path, init = {}, stage = 'platform', authToken) => {
148
+ const token = authToken === undefined ? state.portalToken : authToken
149
+ if (!token) {
150
+ throw relayError('APP_LOGIN_REQUIRED', 'platform user login is required', stage, 401)
151
+ }
152
+ const controller = new AbortController()
153
+ const timeout = setTimeout(() => controller.abort(), PLATFORM_AUTH_TIMEOUT_MS)
154
+ let response
155
+ try {
156
+ response = await fetch(portal + path, {
157
+ ...init,
158
+ signal: controller.signal,
159
+ headers: { Authorization: 'Bearer ' + token, ...(init.headers || {}) },
160
+ })
161
+ } catch (error) {
162
+ clearTimeout(timeout)
163
+ if (error?.name === 'AbortError') throw relayError('APP_PLATFORM_TIMEOUT', 'platform request timed out', stage, 504, error)
164
+ throw relayError('APP_PLATFORM_UNAVAILABLE', 'platform request could not be reached', stage, 502, error)
165
+ }
166
+ clearTimeout(timeout)
167
+ const payload = await readJSON(response)
168
+ if (!response.ok) {
169
+ if (response.status === 401) throw relayError('APP_LOGIN_SESSION_EXPIRED', 'platform user session expired', stage, 401)
170
+ if (response.status === 403) throw relayError('APP_LOGIN_NOT_ALLOWED', 'platform user is not allowed to use this app', stage, 403)
171
+ const code = stage === 'app_registration' ? 'APP_APP_REGISTRATION_FAILED' : stage === 'app_session' ? 'APP_SESSION_CREATE_FAILED' : 'APP_PLATFORM_REQUEST_FAILED'
172
+ const failure = relayError(code, errorMessage(payload, 'platform request failed: HTTP ' + response.status), stage, response.status)
173
+ failure.requestId = response.headers.get('x-request-id') || ''
174
+ failure.upstreamCode = errorCode(payload)
175
+ throw failure
176
+ }
177
+ return unwrap(payload)
178
+ }
179
+
180
+ const revoke = async (session, authToken) => {
181
+ const id = session?.session_id
182
+ if (!id || state.revoked.has(id) || !(authToken === undefined ? state.portalToken : authToken)) return
183
+ state.revoked.add(id)
184
+ await portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions/' + encodeURIComponent(id), { method: 'DELETE' }, 'platform', authToken).catch(() => undefined)
185
+ }
186
+
187
+ const clearAuthentication = async () => {
188
+ const current = state.session
189
+ const token = state.portalToken
190
+ state.authGeneration += 1
191
+ state.session = null
192
+ state.portalToken = ''
193
+ state.refresh = null
194
+ state.login = null
195
+ state.authState = ''
196
+ state.authStateIssuedAt = 0
197
+ await revoke(current, token)
198
+ }
199
+
200
+ const assertAuthentication = (generation) => {
201
+ if (state.closed) throw new Error('local development relay is closed')
202
+ if (generation !== state.authGeneration) throw relayError('APP_AUTH_CHANGED', 'local development authentication changed', 'app_session', 409)
203
+ }
204
+
205
+ const register = (token) => portalJSON('/portal/user/app-workspace/' + encodeURIComponent(appID) + '/local-development', {
206
+ method: 'POST',
207
+ headers: { 'Content-Type': 'application/json' },
208
+ body: JSON.stringify({ entry_url: entryURL, manifest }),
209
+ }, 'app_registration', token)
210
+
211
+ const refresh = async (force = false, generation = state.authGeneration) => {
212
+ assertAuthentication(generation)
213
+ if (state.refresh) return state.refresh
214
+ const refreshToken = state.portalToken
215
+ const previous = force ? state.session : null
216
+ const pending = portalJSON('/portal/user/apps/' + encodeURIComponent(appID) + '/sessions', {
217
+ method: 'POST',
218
+ headers: { 'Content-Type': 'application/json' },
219
+ body: JSON.stringify({ version, runtime_protocol: legacyBridge ? 1 : 2 }),
220
+ }, 'app_session', refreshToken).then(async (raw) => {
221
+ if (!raw || typeof raw.access_token !== 'string' || typeof raw.gateway_url !== 'string') throw relayError('APP_SESSION_INVALID', 'platform returned an invalid App Session', 'app_session', 502)
222
+ const next = { ...raw, gateway_url: normalizeGatewayURL(raw.gateway_url) }
223
+ if (state.closed || state.authGeneration !== generation || state.portalToken !== refreshToken) {
224
+ await revoke(next, refreshToken)
225
+ assertAuthentication(generation)
226
+ throw relayError('APP_AUTH_CHANGED', 'local development authentication changed', 'app_session', 409)
227
+ }
228
+ state.session = next
229
+ if (previous && previous.session_id !== next.session_id) await revoke(previous, refreshToken)
230
+ return next
231
+ }).finally(() => {
232
+ if (state.refresh === pending) state.refresh = null
233
+ })
234
+ state.refresh = pending
235
+ return pending
236
+ }
237
+
238
+ const loginDetails = () => {
239
+ if (!state.authState || Date.now() - state.authStateIssuedAt > 10 * 60 * 1000) {
240
+ state.authState = randomUUID()
241
+ state.authStateIssuedAt = Date.now()
242
+ }
243
+ const query = new URLSearchParams({ proxy_app_auth: '1', proxy_app_state: state.authState, proxy_app_relay: relayURL })
244
+ return { login_url: portalOrigin + '/#/login?' + query, auth_state: state.authState, relay_url: relayURL }
245
+ }
246
+
247
+ const requireLogin = (response, reason) => jsonResponse(response, 401, {
248
+ code: 'APP_LOGIN_REQUIRED',
249
+ error: {
250
+ code: 'APP_LOGIN_REQUIRED',
251
+ message: reason ? visibleErrorMessage(reason) + ' 请重新登录后继续。' : '请登录平台账户后继续',
252
+ },
253
+ ...loginDetails(),
254
+ })
255
+
256
+ const callbackHTML = (stateValue, type, error) => {
257
+ const origin = new URL(entryURL).origin
258
+ const isSuccess = type === 'login-complete'
259
+ const message = isSuccess
260
+ ? { protocol: 'proxy-app/auth/v1', type, state: stateValue }
261
+ : {
262
+ protocol: 'proxy-app/auth/v1',
263
+ type: 'login-failed',
264
+ state: stateValue,
265
+ error: {
266
+ code: error?.code || 'APP_LOGIN_FAILED',
267
+ message: visibleErrorMessage(error),
268
+ stage: error?.stage || 'login',
269
+ status: error?.statusCode,
270
+ request_id: error?.requestId || undefined,
271
+ upstream_code: error?.upstreamCode || undefined,
272
+ },
273
+ }
274
+ const title = isSuccess ? '登录成功,正在返回应用...' : visibleErrorMessage(error)
275
+ return '<!doctype html><meta charset="utf-8"><title>平台登录</title><meta name="viewport" content="width=device-width,initial-scale=1"><p>' + htmlEscape(title) + '</p><script>const m=' + safeScriptJSON(message) + ';if(window.opener){window.opener.postMessage(m,' + safeScriptJSON(origin) + ');setTimeout(()=>window.close(),120)}</script>'
276
+ }
277
+
278
+ const forward = (target, request, rawBody, token, appVersion) => {
279
+ const headers = copyRequestHeaders(request)
280
+ if (token) headers.set('Authorization', 'Bearer ' + token)
281
+ if (appVersion) headers.set('X-App-Version', appVersion)
282
+ return fetch(target, {
283
+ method: request.method,
284
+ headers,
285
+ body: ['GET', 'HEAD'].includes(request.method || '') ? undefined : rawBody,
286
+ redirect: 'manual',
287
+ })
288
+ }
289
+
290
+ const close = async () => {
291
+ if (state.closed) return
292
+ state.closed = true
293
+ await state.refresh?.catch(() => undefined)
294
+ await state.login?.catch(() => undefined)
295
+ const current = state.session
296
+ state.session = null
297
+ await revoke(current)
298
+ if (server?.listening) await new Promise((resolve) => server.close(resolve))
299
+ }
300
+
301
+ try {
302
+ server = createServer(async (request, response) => {
303
+ try {
304
+ if (state.closed) throw new Error('local development relay is closed')
305
+ const parsed = new URL(request.url || '/', 'http://127.0.0.1')
306
+ const runtimePrefix = '/api/app-runtime/v1'
307
+ const authPath = parsed.pathname.startsWith(runtimePrefix + '/auth/') ? parsed.pathname.slice(runtimePrefix.length) : parsed.pathname
308
+ const callback = authPath === '/auth/callback' && request.method === 'POST'
309
+ if (!callback && request.headers['x-proxy-app-relay'] !== relaySecret) {
310
+ jsonResponse(response, 403, { code: 'LOCAL_DEV_RELAY_UNAUTHORIZED', error: 'local development relay authentication failed' })
311
+ return
312
+ }
313
+ const rawBody = await readBody(request)
314
+ if (callback) {
315
+ const form = new URLSearchParams(rawBody.toString('utf8'))
316
+ const receivedState = form.get('state') || ''
317
+ const valid = receivedState && receivedState === state.authState && Date.now() - state.authStateIssuedAt <= 10 * 60 * 1000
318
+ response.setHeader('Content-Type', 'text/html; charset=utf-8')
319
+ response.setHeader('Cache-Control', 'no-store')
320
+ if (!valid) {
321
+ response.statusCode = 400
322
+ response.end(callbackHTML(receivedState, 'login-failed', relayError('APP_LOGIN_STATE_INVALID', 'login request expired', 'state_validation', 400)))
323
+ return
324
+ }
325
+ state.authState = ''
326
+ state.authStateIssuedAt = 0
327
+ const previous = state.session
328
+ const previousToken = state.portalToken
329
+ const generation = ++state.authGeneration
330
+ const token = String(form.get('access_token') || '').trim()
331
+ state.session = null
332
+ state.portalToken = token
333
+ state.refresh = null
334
+ const pending = (async () => {
335
+ await revoke(previous, previousToken)
336
+ assertAuthentication(generation)
337
+ if (!token) throw relayError('APP_LOGIN_TOKEN_INVALID', 'platform returned an invalid user session token', 'token_validation', 400)
338
+ await register(token)
339
+ assertAuthentication(generation)
340
+ return refresh(true, generation)
341
+ })()
342
+ state.login = pending
343
+ try {
344
+ await pending
345
+ assertAuthentication(generation)
346
+ response.end(callbackHTML(receivedState, 'login-complete'))
347
+ } catch (error) {
348
+ if (state.authGeneration === generation) await clearAuthentication()
349
+ response.statusCode = error?.statusCode || 502
350
+ response.end(callbackHTML(receivedState, 'login-failed', error))
351
+ } finally {
352
+ if (state.login === pending) state.login = null
353
+ }
354
+ return
355
+ }
356
+ if (parsed.pathname === '/health') { jsonResponse(response, 200, { ok: true, app_id: appID, app_slug: appSlug, app_version: version, runtime_protocol: legacyBridge ? 1 : 2, authenticated: Boolean(state.session) }); return }
357
+ if (authPath === '/auth/start') { jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), ...loginDetails() } }); return }
358
+ if (authPath === '/auth/logout' && request.method === 'POST') {
359
+ await clearAuthentication()
360
+ jsonResponse(response, 200, { data: { success: true } })
361
+ return
362
+ }
363
+ if (authPath === '/auth/status') { jsonResponse(response, 200, { data: { authenticated: Boolean(state.session), app_id: appID, app_version: version } }); return }
364
+ if (parsed.pathname === '/bridge') {
365
+ if (!legacyBridge) {
366
+ jsonResponse(response, 404, { error: { code: 'APP_LEGACY_BRIDGE_DISABLED', message: 'Legacy Bridge is disabled; use the HTTP runtime or start proxy-app dev with --legacy-bridge for an old application.' } })
367
+ return
368
+ }
369
+ if (!state.session) { requireLogin(response); return }
370
+ let bridgeRequest
371
+ try { bridgeRequest = JSON.parse(rawBody.toString('utf8') || '{}') } catch { jsonResponse(response, 400, { code: 'APP_BRIDGE_INVALID_REQUEST', error: 'invalid bridge request' }); return }
372
+ bridgeRequest.appId = appSlug
373
+ const generation = state.authGeneration
374
+ const upstream = await forward(portal + '/portal/user/apps/' + encodeURIComponent(appID) + '/bridge', request, Buffer.from(JSON.stringify(bridgeRequest)), state.portalToken)
375
+ if (state.authGeneration !== generation) {
376
+ await upstream.body?.cancel().catch(() => undefined)
377
+ assertAuthentication(generation)
378
+ }
379
+ if (upstream.status === 401) {
380
+ await clearAuthentication()
381
+ requireLogin(response, relayError('APP_LOGIN_SESSION_EXPIRED', 'platform user session expired', 'bridge', 401))
382
+ return
383
+ }
384
+ response.statusCode = upstream.status
385
+ copyResponseHeaders(upstream, response)
386
+ if (upstream.body) Readable.fromWeb(upstream.body).pipe(response); else response.end()
387
+ return
388
+ }
389
+ const runtimeRequest = parsed.pathname === runtimePrefix || parsed.pathname.startsWith(runtimePrefix + '/')
390
+ const gatewayRequest = parsed.pathname === '/v1' || parsed.pathname.startsWith('/v1/')
391
+ if (runtimeRequest || gatewayRequest) {
392
+ if (!state.session) { requireLogin(response); return }
393
+ const prefix = runtimeRequest ? runtimePrefix : '/v1'
394
+ const suffix = parsed.pathname.slice(prefix.length) || '/'
395
+ const target = (session) => (runtimeRequest ? portal + '/app-runtime/v1' : session.gateway_url) + suffix + parsed.search
396
+ const generation = state.authGeneration
397
+ let current = state.session
398
+ let upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
399
+ if (await retryableSessionExpiry(upstream, runtimeRequest)) {
400
+ await upstream.arrayBuffer().catch(() => undefined)
401
+ try {
402
+ assertAuthentication(generation)
403
+ current = state.session && state.session.access_token !== current.access_token ? state.session : await refresh(true, generation)
404
+ } catch (error) {
405
+ assertAuthentication(generation)
406
+ if (error?.code === 'APP_LOGIN_SESSION_EXPIRED' || error?.code === 'APP_LOGIN_NOT_ALLOWED') {
407
+ await clearAuthentication()
408
+ requireLogin(response, error)
409
+ return
410
+ }
411
+ throw error
412
+ }
413
+ assertAuthentication(generation)
414
+ upstream = await forward(target(current), request, rawBody, current.access_token, current.app_version)
415
+ }
416
+ if (state.authGeneration !== generation) {
417
+ await upstream.body?.cancel().catch(() => undefined)
418
+ assertAuthentication(generation)
419
+ }
420
+ response.statusCode = upstream.status
421
+ copyResponseHeaders(upstream, response)
422
+ if (upstream.body) Readable.fromWeb(upstream.body).pipe(response); else response.end()
423
+ return
424
+ }
425
+ jsonResponse(response, 404, { code: 'LOCAL_DEV_ROUTE_NOT_FOUND', error: 'local development route not found' })
426
+ } catch (error) {
427
+ jsonResponse(response, error?.statusCode || (error?.message === 'local development relay is closed' ? 503 : 502), { code: error?.code || 'LOCAL_DEV_RELAY_ERROR', error: error instanceof Error ? error.message : String(error) })
428
+ }
429
+ })
430
+ await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve) })
431
+ const address = server.address()
432
+ relayURL = 'http://127.0.0.1:' + address.port
433
+ return { relayURL, close }
434
+ } catch (error) {
435
+ await close()
436
+ throw error
437
+ }
438
+ }
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ducking-mind/proxy-app-cli",
3
+ "version": "0.1.0",
4
+ "description": "CLI for creating, building and publishing Proxy platform apps",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/duckingmind/app.git",
8
+ "directory": "cli"
9
+ },
10
+ "homepage": "https://github.com/duckingmind/app/tree/main/cli#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/duckingmind/app/issues"
13
+ },
14
+ "type": "module",
15
+ "bin": {
16
+ "proxy-app": "./proxy-app.mjs"
17
+ },
18
+ "files": [
19
+ "proxy-app.mjs",
20
+ "local-dev-relay.mjs"
21
+ ],
22
+ "scripts": {
23
+ "test": "node --check proxy-app.mjs",
24
+ "prepublishOnly": "npm test"
25
+ },
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "dependencies": {
30
+ "fflate": "^0.8.2"
31
+ },
32
+ "engines": {
33
+ "node": ">=22.12"
34
+ },
35
+ "license": "MIT"
36
+ }
package/proxy-app.mjs ADDED
@@ -0,0 +1,494 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from 'node:child_process'
4
+ import { mkdir, readFile, readdir, stat, writeFile } from 'node:fs/promises'
5
+ import { createHash, randomUUID } from 'node:crypto'
6
+ import { dirname, join, resolve, sep } from 'node:path'
7
+ import { zipSync } from 'fflate'
8
+ import { createLocalDevelopmentRelay } from './local-dev-relay.mjs'
9
+
10
+ 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'])
16
+ const APP_WORKSPACE_PATH = '/portal/user/app-workspace'
17
+
18
+ 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.
34
+
35
+ Publish environment:
36
+ PROXY_API_BASE_URL default: http://localhost:9003/api
37
+ PROXY_USER_ACCESS_TOKEN Current platform user session for app management/CI only; never passed to the app
38
+ PROXY_ACCESS_TOKEN Legacy alias for PROXY_USER_ACCESS_TOKEN
39
+
40
+ Local development:
41
+ 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.
46
+
47
+ Platform verification:
48
+ Build and pack the app, upload the ZIP, then publish the selected version.
49
+ `)
50
+ }
51
+
52
+ function option(args, name, fallback = '') {
53
+ const index = args.indexOf(name)
54
+ return index >= 0 && args[index + 1] ? args[index + 1] : fallback
55
+ }
56
+
57
+ function outputArgument(args) {
58
+ return args.slice(1).find((arg) => arg !== '--source') || undefined
59
+ }
60
+
61
+ function apiSettings() {
62
+ 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(/\/$/, '')
64
+ if (!token) throw new Error('PROXY_USER_ACCESS_TOKEN is required for app management commands; it is never passed to the app runtime')
65
+ return { token, baseURL }
66
+ }
67
+
68
+ function responseError(body, fallback) {
69
+ const message = typeof body?.error === 'string' ? body.error : body?.error?.message || body?.message
70
+ const code = body?.code || body?.error?.code
71
+ return `${message || fallback}${code ? ` (${code})` : ''}`
72
+ }
73
+
74
+ async function apiJSON(path, init = {}) {
75
+ const { token, baseURL } = apiSettings()
76
+ const response = await fetch(`${baseURL}${path}`, { ...init, headers: { Authorization: `Bearer ${token}`, ...(init.headers || {}) } })
77
+ const body = await response.json().catch(() => ({}))
78
+ if (!response.ok) {
79
+ const message = typeof body.error === 'string' ? body.error : body.error?.message || body.message
80
+ const code = body.code || body.error?.code
81
+ throw new Error(`${message || `request failed: HTTP ${response.status}`}${code ? ` (${code})` : ''}`)
82
+ }
83
+ return body.data || body
84
+ }
85
+
86
+ function safeRelative(value) {
87
+ const normalized = String(value || '').replaceAll('\\', '/')
88
+ if (!normalized || normalized.startsWith('/') || normalized.includes('\0')) throw new Error(`invalid relative path: ${value}`)
89
+ if (normalized.split('/').some((part) => part === '..')) throw new Error(`path traversal is not allowed: ${value}`)
90
+ return normalized.split('/').filter(Boolean).join('/')
91
+ }
92
+
93
+ async function exists(path) {
94
+ try {
95
+ await stat(path)
96
+ return true
97
+ } catch {
98
+ return false
99
+ }
100
+ }
101
+
102
+ async function readManifest(appDir) {
103
+ let manifest
104
+ try {
105
+ manifest = JSON.parse(await readFile(join(appDir, 'manifest.json'), 'utf8'))
106
+ } catch (error) {
107
+ throw new Error(`manifest.json is missing or invalid: ${error.message}`)
108
+ }
109
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) throw new Error('manifest must be a JSON object')
110
+ 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')
112
+ if (!/^\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(manifest.version || '')) throw new Error('manifest.version is invalid')
113
+ 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')
117
+ const permissions = new Set()
118
+ for (const permission of manifest.permissions) {
119
+ if (!ALLOWED_PERMISSIONS.has(permission)) throw new Error(`unsupported app permission: ${permission}`)
120
+ if (permissions.has(permission)) throw new Error(`duplicate app permission: ${permission}`)
121
+ permissions.add(permission)
122
+ }
123
+ if (manifest.apis !== undefined && !Array.isArray(manifest.apis)) throw new Error('manifest.apis must be an array')
124
+ const apis = new Set()
125
+ for (const api of manifest.apis || []) {
126
+ if (!ALLOWED_APIS.has(api)) throw new Error(`unsupported app API: ${api}`)
127
+ if (apis.has(api)) throw new Error(`duplicate app API: ${api}`)
128
+ apis.add(api)
129
+ }
130
+ if (manifest.pricing !== undefined && (!manifest.pricing || typeof manifest.pricing !== 'object' || Array.isArray(manifest.pricing))) {
131
+ throw new Error('manifest.pricing must be an object')
132
+ }
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) {
148
+ const root = resolve(appDir)
149
+ const { manifest, entry } = await readManifest(root)
150
+ const artifactRoot = (await exists(join(root, 'dist'))) ? join(root, 'dist') : root
151
+ if (!(await exists(join(artifactRoot, entry)))) throw new Error(`runtime entry is missing: ${entry}`)
152
+ if (artifactRoot !== root && /(?:src|href)=["']\//i.test(await readFile(join(artifactRoot, entry), 'utf8'))) {
153
+ throw new Error("built entry contains root-relative assets; configure the bundler to emit relative URLs (Vite: base: './')")
154
+ }
155
+ if (manifest.assets?.icon && !(await exists(join(artifactRoot, safeRelative(manifest.assets.icon))))) {
156
+ console.warn(`warning: manifest.assets.icon is missing: ${manifest.assets.icon}`)
157
+ }
158
+ return { root, artifactRoot, manifest, entry }
159
+ }
160
+
161
+ function run(command, args, cwd) {
162
+ 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}`)))
166
+ })
167
+ }
168
+
169
+ function runChild(command, args, cwd, env) {
170
+ return new Promise((resolvePromise, reject) => {
171
+ const child = spawn(command, args, { cwd, env, stdio: 'inherit', shell: process.platform === 'win32' })
172
+ const stop = () => child.kill('SIGTERM')
173
+ process.once('SIGINT', stop)
174
+ process.once('SIGTERM', stop)
175
+ child.on('error', reject)
176
+ child.on('exit', (code, signal) => {
177
+ process.removeListener('SIGINT', stop)
178
+ process.removeListener('SIGTERM', stop)
179
+ if (code === 0 || signal === 'SIGINT' || signal === 'SIGTERM') resolvePromise()
180
+ else reject(new Error(`${command} exited with ${code ?? signal}`))
181
+ })
182
+ })
183
+ }
184
+
185
+ async function devApp(appDir, args) {
186
+ const root = resolve(appDir)
187
+ const { manifest } = await readManifest(root)
188
+ const supportedOptions = new Set(['--port', '--legacy-bridge'])
189
+ for (const arg of args) {
190
+ if (arg.startsWith('--') && !supportedOptions.has(arg)) {
191
+ throw new Error(`unsupported dev option: ${arg}; local development always uses the real platform`)
192
+ }
193
+ }
194
+ const binding = await projectBinding(root)
195
+ if (binding.slug !== manifest.id) throw new Error('应用标识与本地绑定不一致,请先运行 proxy-app link 或重新创建应用')
196
+ const requestedPort = option(args, '--port', process.env.PROXY_APP_DEV_PORT || '5173')
197
+ 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}/`
199
+ let parsedEntryURL
200
+ try { parsedEntryURL = new URL(entryURL) } catch { throw new Error('PROXY_APP_DEV_URL must be a valid loopback http(s) URL') }
201
+ if (!['http:', 'https:'].includes(parsedEntryURL.protocol) || !['localhost', '127.0.0.1', '::1'].includes(parsedEntryURL.hostname) || parsedEntryURL.username || parsedEntryURL.password) {
202
+ throw new Error('PROXY_APP_DEV_URL must use localhost, 127.0.0.1, or ::1 without credentials')
203
+ }
204
+ 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,
218
+ })
219
+ const {
220
+ PROXY_USER_ACCESS_TOKEN: _userToken,
221
+ PROXY_ACCESS_TOKEN: _legacyToken,
222
+ PROXY_API_BASE_URL: _apiBaseURL,
223
+ PROXY_PORTAL_URL: _portalURL,
224
+ PROXY_PUBLIC_URL: _publicURL,
225
+ PROXY_APP_DEV_URL: _entryURL,
226
+ PROXY_APP_DEV_PORT: _devPort,
227
+ PROXY_PLATFORM_GATEWAY_URL: _gatewayURL,
228
+ PROXY_PLATFORM_RUNTIME_URL: _runtimeURL,
229
+ PROXY_PLATFORM_BRIDGE_URL: _bridgeURL,
230
+ PROXY_PLATFORM_AUTH_URL: _authURL,
231
+ PROXY_PLATFORM_RELAY_SECRET: _inheritedRelaySecret,
232
+ ...safeChildEnv
233
+ } = process.env
234
+ const childEnv = {
235
+ ...safeChildEnv,
236
+ 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
+ PROXY_PLATFORM_AUTH_URL: `${relay.relayURL}/auth`,
239
+ PROXY_PLATFORM_RELAY_SECRET: relaySecret,
240
+ }
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.')
245
+ try {
246
+ await runChild('npm', ['run', 'dev', '--', '--host', '127.0.0.1', '--port', requestedPort, '--strictPort'], root, childEnv)
247
+ } finally {
248
+ await relay.close()
249
+ }
250
+ }
251
+
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
+ }
257
+ const outputPath = resolve(output || join(info.root, `${info.manifest.id}-${info.manifest.version}.zip`))
258
+ const files = {}
259
+ const collect = async (directory, prefix = '') => {
260
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
261
+ if (['node_modules', '.git', 'dist'].includes(entry.name) || BUNDLE_IGNORED_FILES.has(entry.name) || entry.name.endsWith('.zip')) continue
262
+ if (entry.name === '.env' || entry.name.startsWith('.env.') || entry.name === '.npmrc') {
263
+ throw new Error(`sensitive file cannot be included in app bundle: ${entry.name}`)
264
+ }
265
+ if (entry.isSymbolicLink()) throw new Error(`symbolic links are not allowed in app bundles: ${entry.name}`)
266
+ const absolute = join(directory, entry.name)
267
+ const archivePath = prefix ? `${prefix}/${entry.name}` : entry.name
268
+ if (entry.isDirectory()) await collect(absolute, archivePath)
269
+ else files[archivePath] = new Uint8Array(await readFile(absolute))
270
+ }
271
+ }
272
+ if (info.artifactRoot === info.root) await collect(info.root)
273
+ else await collect(info.artifactRoot)
274
+ files['manifest.json'] = new Uint8Array(await readFile(join(info.root, 'manifest.json')))
275
+ const bundle = zipSync(files, { level: 9 })
276
+ if (bundle.length > MAX_BUNDLE_BYTES) throw new Error(`bundle is larger than ${MAX_BUNDLE_BYTES} bytes`)
277
+ await mkdir(dirname(outputPath), { recursive: true })
278
+ await writeFile(outputPath, bundle)
279
+ console.log(JSON.stringify({ output: outputPath, id: info.manifest.id, version: info.manifest.version, size: bundle.length, sha256: createHash('sha256').update(bundle).digest('hex') }, null, 2))
280
+ return { ...info, outputPath }
281
+ }
282
+
283
+ async function writeProject(root, app) {
284
+ 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')
286
+ 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`)
288
+ 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')
291
+ }
292
+
293
+ async function replaceIdentifier(root, previous, next) {
294
+ const sourceRoot = join(root, 'src')
295
+ if (!(await exists(sourceRoot))) return
296
+ const walk = async (directory) => {
297
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
298
+ const target = join(directory, entry.name)
299
+ if (entry.isDirectory()) await walk(target)
300
+ else if (/\.(?:js|jsx|ts|tsx)$/.test(entry.name)) {
301
+ const source = await readFile(target, 'utf8')
302
+ if (source.includes(previous)) await writeFile(target, source.replaceAll(previous, next))
303
+ }
304
+ }
305
+ }
306
+ await walk(sourceRoot)
307
+ }
308
+
309
+ async function initApp(appDir, args) {
310
+ const root = resolve(appDir)
311
+ if (await exists(root) && (await exists(join(root, 'manifest.json')) || await exists(join(root, 'package.json')))) {
312
+ throw new Error(`directory already contains an app: ${root}`)
313
+ }
314
+ const name = option(args, '--name', root.split(sep).pop() || 'Proxy App')
315
+ const app = { id: '', slug: `app.proxy.local.${randomUUID().replaceAll('-', '').slice(0, 16)}`, name, description: '' }
316
+ 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))
318
+ }
319
+
320
+ async function createApp(appDir, args) {
321
+ const root = resolve(appDir)
322
+ 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)
324
+ const localIdentifier = manifest.id
325
+ const name = option(args, '--name', manifest.name || root.split(sep).pop() || 'Proxy App')
326
+ const category = option(args, '--category', 'productivity')
327
+ if (!ALLOWED_CATEGORIES.has(category)) throw new Error(`unsupported app category: ${category}`)
328
+ 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')
331
+ }
332
+ manifest.id = app.slug
333
+ 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))
337
+ }
338
+
339
+ async function linkApp(appDir, appID) {
340
+ const root = resolve(appDir)
341
+ const result = await apiJSON(APP_WORKSPACE_PATH)
342
+ const app = (result.items || []).find((item) => item.id === appID)
343
+ if (!app) throw new Error(`application not found: ${appID}`)
344
+ 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)) {
347
+ throw new Error(`application has an invalid identifier: ${app.slug || '(missing)'}`)
348
+ }
349
+ const previous = manifest.id
350
+ manifest.id = app.slug
351
+ 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))
355
+ }
356
+
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')
363
+ }
364
+
365
+ async function projectAppID(appDir) {
366
+ return (await projectBinding(appDir)).app_id
367
+ }
368
+
369
+ 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
375
+ }
376
+
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
383
+ }
384
+
385
+ async function uploadApp(appDir, output, options = {}) {
386
+ const binding = await projectBinding(appDir)
387
+ 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
+ }
392
+ const bundle = await readFile(bundleInfo.outputPath)
393
+ 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 })
395
+ 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
398
+ }
399
+
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。')
405
+ }
406
+ let version = (remoteApp.versions || []).find((item) => item.version === info.manifest.version)
407
+ let uploaded = null
408
+ if (!version) {
409
+ uploaded = await uploadApp(appDir, output)
410
+ version = uploaded
411
+ }
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
415
+ }
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))
419
+ }
420
+
421
+ async function statusApp(appDir) {
422
+ const root = resolve(appDir)
423
+ 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
+ const app = await developerApp(root)
431
+ 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))
433
+ }
434
+
435
+ async function doctorApp(appDir) {
436
+ const root = resolve(appDir)
437
+ const checks = []
438
+ const major = Number(process.versions.node.split('.')[0])
439
+ checks.push({ name: 'node', ok: major >= 20, detail: process.version })
440
+ try {
441
+ const info = await validateApp(root)
442
+ checks.push({ name: 'manifest', ok: true, detail: `${info.manifest.id}@${info.manifest.version}` })
443
+ checks.push({ name: 'build', ok: info.artifactRoot !== info.root, detail: info.artifactRoot !== info.root ? 'dist found' : 'dist not found; run proxy-app build' })
444
+ } catch (error) {
445
+ checks.push({ name: 'manifest', ok: false, detail: error.message })
446
+ }
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
+ }
453
+ const valid = checks.every((item) => item.ok)
454
+ console.log(JSON.stringify({ ok: valid, ready_for_upload: valid, checks }, null, 2))
455
+ if (!valid) process.exitCode = 1
456
+ }
457
+
458
+ const [, , command, ...args] = process.argv
459
+ 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}`)
490
+ }
491
+ } catch (error) {
492
+ console.error(`proxy app: ${error instanceof Error ? error.message : String(error)}`)
493
+ process.exitCode = 1
494
+ }