@muyichengshayu/promptx 0.1.5 → 0.1.6

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/CHANGELOG.md ADDED
@@ -0,0 +1,37 @@
1
+ # Changelog
2
+
3
+ ## 0.1.6
4
+
5
+ - 新增多租户 Relay 子域名接入能力,一个 Relay 进程即可同时服务多个同事的远程访问。
6
+ - 新增 `promptx relay tenant add/list/remove` 与 `promptx relay start/stop/restart/status`,补齐 Relay 的租户管理和后台运维命令。
7
+ - 完善 Relay 转发稳定性与诊断信息,修复远程请求体转发问题,并增加更清晰的 host、tenant 与拒绝原因日志。
8
+ - README 精简为入口说明,详细 Relay 部署与使用流程迁移到 `docs/relay-quickstart.md`。
9
+ - 修复 Windows 开发环境下 `pnpm dev` / `pnpm dev:tailscale` 可能报 `spawn EINVAL` 的问题。
10
+
11
+ ## 0.1.5
12
+
13
+ - 本轮提示词支持按新数据结构展示图片块,新的图文输入在会话区可直接看到附图预览。
14
+ - 修复旧库升级时 `prompt_blocks_json` 字段缺失导致服务启动报错的问题,补齐增量迁移。
15
+ - 优化项目管理交互:运行中也可打开“管理项目”,管理弹窗优先选中当前项目,编辑表单能正确回填。
16
+ - 简化项目选择器与项目列表状态展示,减少无意义抖动和过重高亮;新建任务按钮不再因列表加载短暂禁用。
17
+
18
+ ## 0.1.4
19
+
20
+ - 修复 Windows 下通过 npm 安装后的正式版在启动服务、发送请求和执行辅助命令时频繁弹出黑色控制台窗口的问题。
21
+ - 为 PromptX、Codex、Git 与发布检查相关的 Windows 子进程统一补充隐藏窗口选项,减少系统级弹窗干扰。
22
+
23
+ ## 0.1.3
24
+
25
+ - 修复工作台输入区在输入法候选、实时刷新和快速发送场景下的尾字丢失问题。
26
+ - 修复设置面板首次打开时版本信息不加载、一直停留在“读取中...”的问题。
27
+
28
+ ## 0.1.2
29
+
30
+ - 设置面板增加版本信息展示,并在读取失败时显示明确状态。
31
+ - CLI 新增 `version`、`--version`、`-v` 版本查询,同时兼容 `-versioin`。
32
+ - README 补充说明:禅道扩展目前需要下载或克隆仓库源码后手动加载。
33
+
34
+ ## 0.1.1
35
+
36
+ - 修复工作台中“代码变更”入口、执行过程“查看”按钮和任务卡文件数徽标的闪烁问题。
37
+ - 优化任务列表刷新时的代码变更摘要复用逻辑,减少界面抖动。
package/README.md CHANGED
@@ -36,12 +36,18 @@ promptx doctor
36
36
  promptx start
37
37
  promptx status
38
38
  promptx stop
39
+ promptx relay start
39
40
  ```
40
41
 
41
42
  ```bash
42
43
  promptx doctor
43
44
  ```
44
45
 
46
+ 其中:
47
+
48
+ - `promptx start`:启动本机 PromptX 工作台
49
+ - `promptx relay start`:启动公网中转服务,适合部署到你自己的云服务器
50
+
45
51
  ## 使用方式
46
52
 
47
53
  1. 打开工作台,新建或选择一个任务
@@ -50,6 +56,21 @@ promptx doctor
50
56
  4. 点击发送,把当前内容交给 Codex
51
57
  5. 在中间继续查看执行过程,并按需多轮发送
52
58
 
59
+ ## 远程访问 Relay(预览)
60
+
61
+ 如果你希望在手机上远程访问自己电脑上的 PromptX,或想在云端部署多租户 Relay,请直接查看:
62
+
63
+ - `docs/relay-quickstart.md`
64
+
65
+ 文档里已经整理好这些内容:
66
+
67
+ - 本地 PromptX 接入 Relay
68
+ - 云端 Relay 启动与后台管理
69
+ - 多租户子域名接入
70
+ - `promptx relay tenant add/list/remove`
71
+ - `promptx relay start/stop/restart/status`
72
+ - Nginx、DNS、健康检查与常见排查
73
+
53
74
  ## 禅道扩展
54
75
 
55
76
  仓库内置了禅道 Chrome 扩展:`apps/zentao-extension`
@@ -57,6 +57,8 @@ import {
57
57
  searchWorkspaceEntries,
58
58
  } from './workspaceFiles.js'
59
59
  import { ensurePromptxStorageReady, serverRootDir } from './appPaths.js'
60
+ import { createRelayClient } from './relayClient.js'
61
+ import { getRelayConfigForClient, isRelayConfigManagedByEnv, writeStoredRelayConfig } from './relayConfig.js'
60
62
  import { createSseHub } from './sseHub.js'
61
63
 
62
64
  const app = Fastify({ logger: true })
@@ -80,6 +82,13 @@ function readPromptxVersion() {
80
82
  }
81
83
 
82
84
  const promptxVersion = readPromptxVersion()
85
+ const relayConfig = getRelayConfigForClient()
86
+ const relayClient = createRelayClient({
87
+ logger: app.log,
88
+ appVersion: promptxVersion,
89
+ localBaseUrl: process.env.PROMPTX_RELAY_LOCAL_BASE_URL || `http://127.0.0.1:${port}`,
90
+ ...relayConfig,
91
+ })
83
92
 
84
93
  let lastExpiredPurgeAt = 0
85
94
  const sseHub = createSseHub()
@@ -338,6 +347,32 @@ app.get('/api/meta', async () => ({
338
347
  visibilityOptions: VISIBILITY_OPTIONS,
339
348
  }))
340
349
 
350
+ app.get('/api/relay/status', async () => ({
351
+ relay: relayClient.getStatus(),
352
+ }))
353
+
354
+ app.get('/api/relay/config', async () => ({
355
+ config: {
356
+ ...getRelayConfigForClient(),
357
+ },
358
+ managedByEnv: isRelayConfigManagedByEnv(),
359
+ relay: relayClient.getStatus(),
360
+ }))
361
+
362
+ app.put('/api/relay/config', async (request) => {
363
+ const savedConfig = writeStoredRelayConfig(request.body || {})
364
+ relayClient.updateConfig({
365
+ ...savedConfig,
366
+ localBaseUrl: process.env.PROMPTX_RELAY_LOCAL_BASE_URL || `http://127.0.0.1:${port}`,
367
+ })
368
+
369
+ return {
370
+ config: getRelayConfigForClient(),
371
+ managedByEnv: isRelayConfigManagedByEnv(),
372
+ relay: relayClient.getStatus(),
373
+ }
374
+ })
375
+
341
376
  app.get('/api/events/stream', async (request, reply) => {
342
377
  reply.hijack()
343
378
  const requestOrigin = request.headers.origin
@@ -915,10 +950,11 @@ app.setErrorHandler((error, request, reply) => {
915
950
  markInterruptedCodexRuns()
916
951
  purgeExpiredContent(true)
917
952
 
918
- app.listen({ port, host }).then(() => {
919
- app.log.info(`server running at http://${host}:${port}`)
920
- buildServerAccessUrls(host, port).forEach((message) => {
921
- app.log.info(message)
922
- })
923
- })
953
+ app.listen({ port, host }).then(() => {
954
+ app.log.info(`server running at http://${host}:${port}`)
955
+ buildServerAccessUrls(host, port).forEach((message) => {
956
+ app.log.info(message)
957
+ })
958
+ relayClient.start()
959
+ })
924
960
 
@@ -0,0 +1,431 @@
1
+ import process from 'node:process'
2
+ import WebSocket from 'ws'
3
+
4
+ import {
5
+ buildRelayWebSocketUrl,
6
+ createRelayRequestId,
7
+ decodeChunk,
8
+ encodeChunk,
9
+ sanitizeProxyHeaders,
10
+ } from './relayProtocol.js'
11
+
12
+ const DEFAULT_RECONNECT_DELAY_MS = 3_000
13
+ const REQUEST_CANCEL_REASON = 'relay_request_cancelled'
14
+
15
+ function createDisabledStatus() {
16
+ return {
17
+ enabled: false,
18
+ connected: false,
19
+ relayUrl: '',
20
+ websocketUrl: '',
21
+ deviceId: '',
22
+ lastConnectedAt: '',
23
+ lastDisconnectedAt: '',
24
+ lastCloseCode: 0,
25
+ lastCloseReason: '',
26
+ lastError: '',
27
+ }
28
+ }
29
+
30
+ function normalizeCloseReason(reason = '') {
31
+ const normalized = String(reason || '').trim()
32
+ if (!normalized) {
33
+ return ''
34
+ }
35
+
36
+ const reasonMap = {
37
+ invalid_tenant: '当前 Relay 域名未匹配到租户',
38
+ invalid_token: '设备令牌不匹配',
39
+ invalid_device: '设备 ID 不匹配',
40
+ missing_hello: '缺少设备认证报文',
41
+ missing_auth: '设备认证超时',
42
+ replaced_by_new_connection: '已被新的设备连接替换',
43
+ config_updated: '配置已更新,正在重连',
44
+ }
45
+
46
+ return reasonMap[normalized] || normalized
47
+ }
48
+
49
+ function readRelayClientConfig({
50
+ relayUrl = process.env.PROMPTX_RELAY_URL,
51
+ deviceId = process.env.PROMPTX_RELAY_DEVICE_ID,
52
+ deviceToken = process.env.PROMPTX_RELAY_DEVICE_TOKEN,
53
+ enabled = process.env.PROMPTX_RELAY_ENABLED,
54
+ localBaseUrl = process.env.PROMPTX_RELAY_LOCAL_BASE_URL,
55
+ } = {}) {
56
+ const normalizedRelayUrl = String(relayUrl || '').trim()
57
+ const normalizedDeviceId = String(deviceId || '').trim()
58
+ const normalizedDeviceToken = String(deviceToken || '').trim()
59
+ const hasExplicitEnabled = typeof enabled === 'boolean'
60
+ || (typeof enabled !== 'undefined' && String(enabled || '').trim() !== '')
61
+ const normalizedEnabled = hasExplicitEnabled ? String(enabled).trim().toLowerCase() : ''
62
+ const shouldEnable = hasExplicitEnabled
63
+ ? !['0', 'false', 'off', 'no'].includes(normalizedEnabled)
64
+ : Boolean(normalizedRelayUrl && normalizedDeviceId && normalizedDeviceToken)
65
+
66
+ return {
67
+ enabled: shouldEnable && Boolean(normalizedRelayUrl && normalizedDeviceId && normalizedDeviceToken),
68
+ relayUrl: normalizedRelayUrl,
69
+ websocketUrl: buildRelayWebSocketUrl(normalizedRelayUrl),
70
+ deviceId: normalizedDeviceId,
71
+ deviceToken: normalizedDeviceToken,
72
+ localBaseUrl: String(localBaseUrl || 'http://127.0.0.1:3000').trim() || 'http://127.0.0.1:3000',
73
+ }
74
+ }
75
+
76
+ function createRelayClient({
77
+ relayUrl,
78
+ deviceId,
79
+ deviceToken,
80
+ localBaseUrl,
81
+ logger = console,
82
+ appVersion = '0.0.0',
83
+ } = {}) {
84
+ let config = readRelayClientConfig({
85
+ relayUrl,
86
+ deviceId,
87
+ deviceToken,
88
+ localBaseUrl,
89
+ })
90
+
91
+ const status = {
92
+ ...createDisabledStatus(),
93
+ enabled: config.enabled,
94
+ relayUrl: config.relayUrl,
95
+ websocketUrl: config.websocketUrl,
96
+ deviceId: config.deviceId,
97
+ }
98
+
99
+ let socket = null
100
+ let stopped = false
101
+ let reconnectTimer = null
102
+ let requestMap = new Map()
103
+ let authenticated = false
104
+
105
+ function updateStatus(patch = {}) {
106
+ Object.assign(status, patch)
107
+ }
108
+
109
+ function syncStatusFromConfig() {
110
+ updateStatus({
111
+ enabled: config.enabled,
112
+ relayUrl: config.relayUrl,
113
+ websocketUrl: config.websocketUrl,
114
+ deviceId: config.deviceId,
115
+ })
116
+ }
117
+
118
+ syncStatusFromConfig()
119
+
120
+ function logInfo(message, extra) {
121
+ if (extra) {
122
+ logger.info?.(extra, message)
123
+ return
124
+ }
125
+ logger.info?.(message)
126
+ }
127
+
128
+ function logWarn(message, extra) {
129
+ if (extra) {
130
+ logger.warn?.(extra, message)
131
+ return
132
+ }
133
+ logger.warn?.(message)
134
+ }
135
+
136
+ function logError(message, extra) {
137
+ if (extra) {
138
+ logger.error?.(extra, message)
139
+ return
140
+ }
141
+ logger.error?.(message)
142
+ }
143
+
144
+ function sendFrame(payload = {}) {
145
+ if (!socket || socket.readyState !== WebSocket.OPEN) {
146
+ return false
147
+ }
148
+
149
+ socket.send(JSON.stringify(payload))
150
+ return true
151
+ }
152
+
153
+ async function forwardLocalResponse(requestId, response) {
154
+ sendFrame({
155
+ type: 'response.start',
156
+ requestId,
157
+ status: response.status,
158
+ statusText: response.statusText,
159
+ headers: sanitizeProxyHeaders(response.headers, ['content-encoding']),
160
+ })
161
+
162
+ if (response.body) {
163
+ for await (const chunk of response.body) {
164
+ sendFrame({
165
+ type: 'response.body',
166
+ requestId,
167
+ chunk: encodeChunk(chunk),
168
+ })
169
+ }
170
+ } else {
171
+ const bodyBuffer = Buffer.from(await response.arrayBuffer())
172
+ if (bodyBuffer.length) {
173
+ sendFrame({
174
+ type: 'response.body',
175
+ requestId,
176
+ chunk: encodeChunk(bodyBuffer),
177
+ })
178
+ }
179
+ }
180
+
181
+ sendFrame({
182
+ type: 'response.end',
183
+ requestId,
184
+ })
185
+ }
186
+
187
+ async function dispatchLocalRequest(record) {
188
+ const bodyBuffer = Buffer.concat(record.bodyChunks)
189
+ const controller = new AbortController()
190
+ record.controller = controller
191
+ requestMap.set(record.requestId, record)
192
+
193
+ try {
194
+ const targetUrl = new URL(record.path, config.localBaseUrl)
195
+ const response = await fetch(targetUrl, {
196
+ method: record.method,
197
+ headers: sanitizeProxyHeaders(record.headers, ['cookie']),
198
+ body: ['GET', 'HEAD'].includes(record.method) || !bodyBuffer.length ? undefined : bodyBuffer,
199
+ signal: controller.signal,
200
+ })
201
+
202
+ await forwardLocalResponse(record.requestId, response)
203
+ } catch (error) {
204
+ const errorCode = error?.name === 'AbortError' ? REQUEST_CANCEL_REASON : 'relay_local_request_failed'
205
+ sendFrame({
206
+ type: 'response.error',
207
+ requestId: record.requestId,
208
+ code: errorCode,
209
+ message: error?.message || '本地 PromptX 请求失败。',
210
+ })
211
+ } finally {
212
+ requestMap.delete(record.requestId)
213
+ }
214
+ }
215
+
216
+ function handleIncomingFrame(rawPayload = '') {
217
+ let payload
218
+ try {
219
+ payload = JSON.parse(String(rawPayload || ''))
220
+ } catch {
221
+ return
222
+ }
223
+
224
+ if (payload.type === 'request.start') {
225
+ requestMap.set(payload.requestId, {
226
+ requestId: String(payload.requestId || createRelayRequestId()),
227
+ method: String(payload.method || 'GET').toUpperCase(),
228
+ path: String(payload.path || '/'),
229
+ headers: payload.headers || {},
230
+ bodyChunks: [],
231
+ controller: null,
232
+ })
233
+ return
234
+ }
235
+
236
+ if (payload.type === 'request.body') {
237
+ const record = requestMap.get(payload.requestId)
238
+ if (!record) {
239
+ return
240
+ }
241
+ record.bodyChunks.push(decodeChunk(payload.chunk))
242
+ return
243
+ }
244
+
245
+ if (payload.type === 'request.end') {
246
+ const record = requestMap.get(payload.requestId)
247
+ if (!record) {
248
+ return
249
+ }
250
+ dispatchLocalRequest(record).catch((error) => {
251
+ sendFrame({
252
+ type: 'response.error',
253
+ requestId: record.requestId,
254
+ code: 'relay_dispatch_failed',
255
+ message: error?.message || 'Relay 转发失败。',
256
+ })
257
+ requestMap.delete(record.requestId)
258
+ })
259
+ return
260
+ }
261
+
262
+ if (payload.type === 'request.cancel') {
263
+ const record = requestMap.get(payload.requestId)
264
+ record?.controller?.abort()
265
+ }
266
+ }
267
+
268
+ function scheduleReconnect() {
269
+ if (stopped || reconnectTimer || !config.enabled) {
270
+ return
271
+ }
272
+
273
+ reconnectTimer = setTimeout(() => {
274
+ reconnectTimer = null
275
+ connect().catch(() => {})
276
+ }, DEFAULT_RECONNECT_DELAY_MS)
277
+ }
278
+
279
+ async function connect() {
280
+ if (!config.enabled) {
281
+ return
282
+ }
283
+ if (stopped || socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
284
+ return
285
+ }
286
+
287
+ socket = new WebSocket(config.websocketUrl)
288
+ authenticated = false
289
+
290
+ socket.on('open', () => {
291
+ sendFrame({
292
+ type: 'hello',
293
+ deviceId: config.deviceId,
294
+ deviceToken: config.deviceToken,
295
+ version: appVersion,
296
+ })
297
+ logInfo(`[relay] WebSocket 已建立,等待设备认证 ${config.relayUrl}`)
298
+ })
299
+
300
+ socket.on('message', (payload, isBinary) => {
301
+ if (isBinary) {
302
+ return
303
+ }
304
+ let message = null
305
+ try {
306
+ message = JSON.parse(payload.toString('utf8'))
307
+ } catch {
308
+ return
309
+ }
310
+
311
+ if (message?.type === 'hello.ack') {
312
+ authenticated = true
313
+ updateStatus({
314
+ connected: true,
315
+ lastConnectedAt: new Date().toISOString(),
316
+ lastCloseCode: 0,
317
+ lastCloseReason: '',
318
+ lastError: '',
319
+ })
320
+ logInfo(`[relay] 已连接 ${config.relayUrl}`)
321
+ return
322
+ }
323
+ handleIncomingFrame(JSON.stringify(message))
324
+ })
325
+
326
+ socket.on('close', (code, reason) => {
327
+ const wasAuthenticated = authenticated
328
+ const closeReason = normalizeCloseReason(reason?.toString('utf8'))
329
+ const nextError = closeReason && closeReason !== '配置已更新,正在重连'
330
+ ? `${wasAuthenticated ? 'Relay 已断开' : 'Relay 连接被拒绝'}:${closeReason}`
331
+ : (!wasAuthenticated && code && code !== 1000 ? `Relay 连接已关闭(code=${code})` : '')
332
+
333
+ updateStatus({
334
+ connected: false,
335
+ lastDisconnectedAt: new Date().toISOString(),
336
+ lastCloseCode: Number(code || 0),
337
+ lastCloseReason: closeReason,
338
+ ...(nextError ? { lastError: nextError } : {}),
339
+ })
340
+ socket = null
341
+ authenticated = false
342
+ logWarn('[relay] 连接已关闭', {
343
+ code: Number(code || 0),
344
+ reason: closeReason || 'none',
345
+ authenticated: wasAuthenticated,
346
+ })
347
+ scheduleReconnect()
348
+ })
349
+
350
+ socket.on('error', (error) => {
351
+ updateStatus({
352
+ lastError: error?.message || 'Relay 连接失败。',
353
+ })
354
+ logWarn('[relay] 连接异常', {
355
+ error: error?.message || String(error || ''),
356
+ })
357
+ })
358
+ }
359
+
360
+ return {
361
+ start() {
362
+ stopped = false
363
+ if (!config.enabled) {
364
+ syncStatusFromConfig()
365
+ return
366
+ }
367
+ connect().catch((error) => {
368
+ updateStatus({
369
+ lastError: error?.message || 'Relay 连接失败。',
370
+ })
371
+ logError('[relay] 初次连接失败', {
372
+ error: error?.message || String(error || ''),
373
+ })
374
+ scheduleReconnect()
375
+ })
376
+ },
377
+ stop() {
378
+ stopped = true
379
+ if (reconnectTimer) {
380
+ clearTimeout(reconnectTimer)
381
+ reconnectTimer = null
382
+ }
383
+ requestMap.forEach((record) => {
384
+ record.controller?.abort()
385
+ })
386
+ requestMap = new Map()
387
+ socket?.close()
388
+ socket = null
389
+ authenticated = false
390
+ updateStatus({
391
+ connected: false,
392
+ })
393
+ },
394
+ updateConfig(nextConfig = {}) {
395
+ const previousEnabled = config.enabled
396
+ config = readRelayClientConfig({
397
+ ...config,
398
+ ...nextConfig,
399
+ })
400
+ syncStatusFromConfig()
401
+ updateStatus({
402
+ lastError: '',
403
+ })
404
+
405
+ if (!config.enabled) {
406
+ this.stop()
407
+ return
408
+ }
409
+
410
+ if (!previousEnabled) {
411
+ this.start()
412
+ return
413
+ }
414
+
415
+ if (socket) {
416
+ socket.close(1012, 'config_updated')
417
+ } else {
418
+ this.start()
419
+ }
420
+ },
421
+ getStatus() {
422
+ return { ...status }
423
+ },
424
+ }
425
+ }
426
+
427
+ export {
428
+ createDisabledStatus,
429
+ createRelayClient,
430
+ readRelayClientConfig,
431
+ }
@@ -0,0 +1,84 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+
4
+ import { ensurePromptxStorageReady } from './appPaths.js'
5
+
6
+ const RELAY_CONFIG_FILE = 'relay-config.json'
7
+
8
+ function getRelayConfigPath() {
9
+ const { dataDir } = ensurePromptxStorageReady()
10
+ return path.join(dataDir, RELAY_CONFIG_FILE)
11
+ }
12
+
13
+ function normalizeRelayConfig(input = {}) {
14
+ const relayUrl = String(input?.relayUrl || '').trim()
15
+ const deviceId = String(input?.deviceId || '').trim()
16
+ const deviceToken = String(input?.deviceToken || '').trim()
17
+ const enabled = typeof input?.enabled === 'boolean'
18
+ ? input.enabled
19
+ : !['0', 'false', 'off', 'no'].includes(String(input?.enabled || '').trim().toLowerCase())
20
+
21
+ return {
22
+ relayUrl,
23
+ deviceId,
24
+ deviceToken,
25
+ enabled: Boolean(enabled && relayUrl && deviceId && deviceToken),
26
+ }
27
+ }
28
+
29
+ function readStoredRelayConfig() {
30
+ const filePath = getRelayConfigPath()
31
+
32
+ try {
33
+ const payload = JSON.parse(fs.readFileSync(filePath, 'utf8'))
34
+ return normalizeRelayConfig(payload)
35
+ } catch {
36
+ return normalizeRelayConfig()
37
+ }
38
+ }
39
+
40
+ function writeStoredRelayConfig(input = {}) {
41
+ const filePath = getRelayConfigPath()
42
+ const normalized = normalizeRelayConfig(input)
43
+ fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
44
+ return normalized
45
+ }
46
+
47
+ function getRelayConfigForClient() {
48
+ const stored = readStoredRelayConfig()
49
+ const relayUrl = String(process.env.PROMPTX_RELAY_URL || stored.relayUrl || '').trim()
50
+ const deviceId = String(process.env.PROMPTX_RELAY_DEVICE_ID || stored.deviceId || '').trim()
51
+ const deviceToken = String(process.env.PROMPTX_RELAY_DEVICE_TOKEN || stored.deviceToken || '').trim()
52
+ const envEnabled = String(process.env.PROMPTX_RELAY_ENABLED || '').trim()
53
+ const managedByEnv = isRelayConfigManagedByEnv()
54
+ const enabled = envEnabled
55
+ ? !['0', 'false', 'off', 'no'].includes(envEnabled.toLowerCase())
56
+ : managedByEnv
57
+ ? Boolean(relayUrl && deviceId && deviceToken)
58
+ : Boolean(stored.enabled)
59
+
60
+ return normalizeRelayConfig({
61
+ relayUrl,
62
+ deviceId,
63
+ deviceToken,
64
+ enabled,
65
+ })
66
+ }
67
+
68
+ function isRelayConfigManagedByEnv() {
69
+ return Boolean(
70
+ String(process.env.PROMPTX_RELAY_URL || '').trim()
71
+ || String(process.env.PROMPTX_RELAY_DEVICE_ID || '').trim()
72
+ || String(process.env.PROMPTX_RELAY_DEVICE_TOKEN || '').trim()
73
+ || String(process.env.PROMPTX_RELAY_ENABLED || '').trim()
74
+ )
75
+ }
76
+
77
+ export {
78
+ getRelayConfigForClient,
79
+ getRelayConfigPath,
80
+ isRelayConfigManagedByEnv,
81
+ normalizeRelayConfig,
82
+ readStoredRelayConfig,
83
+ writeStoredRelayConfig,
84
+ }