@ducking-mind/proxy-app-cli 0.1.1 → 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.
@@ -0,0 +1,83 @@
1
+ import { lstat, readdir, readFile } from 'node:fs/promises'
2
+ import { extname, join, resolve } from 'node:path'
3
+ import { assertSafeArtifactFile } from './artifact-security.mjs'
4
+
5
+ const MAX_FUNCTION_SOURCE_BYTES = 4 * 1024 * 1024
6
+ const MAX_FUNCTION_SOURCE_FILES = 2000
7
+
8
+ function functionEntries(manifest) {
9
+ const functions = manifest?.functions
10
+ if (!functions || typeof functions !== 'object' || Array.isArray(functions)) return []
11
+ return ['request', 'task']
12
+ .map(kind => functions[kind]?.entry)
13
+ .filter(entry => typeof entry === 'string' && entry.length > 0)
14
+ }
15
+
16
+ // Read the same runtime module files that proxy-app pack uploads. Development
17
+ // sends these sources to Admin so Gateway can execute the versioned draft; the
18
+ // browser continues to call Gateway and never receives function source.
19
+ export async function readLocalFunctionSources(appDir, manifest) {
20
+ const requiredEntries = functionEntries(manifest)
21
+ if (requiredEntries.length === 0) return []
22
+
23
+ const root = resolve(appDir)
24
+ const runtimeRoot = join(root, 'runtime')
25
+ const files = []
26
+ let totalBytes = 0
27
+
28
+ try {
29
+ const runtimeMetadata = await lstat(runtimeRoot)
30
+ if (runtimeMetadata.isSymbolicLink() || !runtimeMetadata.isDirectory()) {
31
+ throw new Error('runtime function sources must be inside a real runtime/ directory')
32
+ }
33
+ } catch (error) {
34
+ if (error?.code !== 'ENOENT') throw error
35
+ }
36
+
37
+ const visit = async (directory, prefix) => {
38
+ let entries
39
+ try {
40
+ entries = await readdir(directory, { withFileTypes: true })
41
+ } catch (error) {
42
+ if (error?.code === 'ENOENT' && directory === runtimeRoot) return
43
+ throw error
44
+ }
45
+ entries.sort((left, right) => left.name.localeCompare(right.name))
46
+ for (const entry of entries) {
47
+ if (entry.name.includes('\\') || entry.name === '.' || entry.name === '..') {
48
+ throw new Error(`invalid runtime source path: ${entry.name}`)
49
+ }
50
+ const relativePath = `${prefix}/${entry.name}`
51
+ const absolutePath = join(directory, entry.name)
52
+ if (entry.isSymbolicLink()) throw new Error(`runtime function sources cannot be symbolic links: ${relativePath}`)
53
+ if (entry.isDirectory()) {
54
+ await visit(absolutePath, relativePath)
55
+ continue
56
+ }
57
+ if (!entry.isFile() || !['.js', '.mjs'].includes(extname(entry.name).toLowerCase())) continue
58
+ if (Buffer.byteLength(relativePath, 'utf8') > 255) throw new Error(`runtime function path is too long: ${relativePath}`)
59
+ if (files.length >= MAX_FUNCTION_SOURCE_FILES) throw new Error('runtime function source contains too many files')
60
+ const metadata = await lstat(absolutePath)
61
+ if (!metadata.isFile() || metadata.isSymbolicLink()) throw new Error(`runtime function sources cannot be symbolic links: ${relativePath}`)
62
+ if (metadata.size === 0) throw new Error(`runtime function source is empty: ${relativePath}`)
63
+ totalBytes += metadata.size
64
+ if (totalBytes > MAX_FUNCTION_SOURCE_BYTES) throw new Error('runtime function source exceeds 4 MiB')
65
+ const sourceBytes = await readFile(absolutePath)
66
+ let source
67
+ try {
68
+ source = new TextDecoder('utf-8', { fatal: true }).decode(sourceBytes)
69
+ } catch {
70
+ throw new Error(`runtime function source is not valid UTF-8: ${relativePath}`)
71
+ }
72
+ assertSafeArtifactFile(relativePath, source)
73
+ files.push({ path: relativePath, source })
74
+ }
75
+ }
76
+
77
+ await visit(runtimeRoot, 'runtime')
78
+ const available = new Set(files.map(file => file.path))
79
+ for (const entry of requiredEntries) {
80
+ if (!available.has(entry)) throw new Error(`manifest function entry is missing from runtime/: ${entry}`)
81
+ }
82
+ return files
83
+ }
@@ -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
- }