@muyichengshayu/promptx 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/README.md +80 -0
- package/apps/server/src/appPaths.js +102 -0
- package/apps/server/src/codex.js +585 -0
- package/apps/server/src/codexRunRuntime.js +212 -0
- package/apps/server/src/codexRuns.js +525 -0
- package/apps/server/src/codexSessions.js +149 -0
- package/apps/server/src/db.js +389 -0
- package/apps/server/src/gitDiff.js +1425 -0
- package/apps/server/src/index.js +909 -0
- package/apps/server/src/pdf.js +383 -0
- package/apps/server/src/repository.js +484 -0
- package/apps/server/src/sseHub.js +69 -0
- package/apps/server/src/upload.js +22 -0
- package/apps/server/src/workspaceFiles.js +662 -0
- package/apps/web/dist/assets/CodexSessionManagerDialog-c35LrKjV.js +6 -0
- package/apps/web/dist/assets/TaskDiffReviewDialog-BYcla0q4.js +12 -0
- package/apps/web/dist/assets/WorkbenchSettingsDialog-C0uQRStP.js +1 -0
- package/apps/web/dist/assets/WorkbenchView-Cp_qHNdX.js +216 -0
- package/apps/web/dist/assets/index-D9ui_gwj.js +25 -0
- package/apps/web/dist/assets/index-DDNrspNi.css +1 -0
- package/apps/web/dist/index.html +16 -0
- package/bin/promptx.js +60 -0
- package/package.json +58 -0
- package/packages/shared/src/index.js +121 -0
- package/scripts/doctor.mjs +251 -0
- package/scripts/service.mjs +308 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
export const EXPIRY_PRESETS = {
|
|
2
|
+
none: {
|
|
3
|
+
label: '不过期',
|
|
4
|
+
hours: null,
|
|
5
|
+
},
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export const EXPIRY_OPTIONS = Object.entries(EXPIRY_PRESETS).map(([value, preset]) => ({
|
|
9
|
+
value,
|
|
10
|
+
label: preset.label,
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
export const VISIBILITY_OPTIONS = [
|
|
14
|
+
{ value: 'private', label: '仅自己可见' },
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
export const BLOCK_TYPES = {
|
|
18
|
+
TEXT: 'text',
|
|
19
|
+
IMAGE: 'image',
|
|
20
|
+
IMPORTED_TEXT: 'imported_text',
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const BLOCK_TYPE_LABELS = {
|
|
24
|
+
[BLOCK_TYPES.TEXT]: '文本',
|
|
25
|
+
[BLOCK_TYPES.IMAGE]: '图片',
|
|
26
|
+
[BLOCK_TYPES.IMPORTED_TEXT]: '导入文件',
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function normalizeVisibility(value) {
|
|
30
|
+
return 'private'
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function normalizeExpiry(value) {
|
|
34
|
+
return 'none'
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function getVisibilityLabel(value) {
|
|
38
|
+
return VISIBILITY_OPTIONS.find((item) => item.value === value)?.label || '仅自己可见'
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function getBlockTypeLabel(value) {
|
|
42
|
+
return BLOCK_TYPE_LABELS[value] || '内容'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function resolveExpiresAt(expiry = 'none', now = new Date()) {
|
|
46
|
+
const preset = EXPIRY_PRESETS[normalizeExpiry(expiry)]
|
|
47
|
+
if (!preset || preset.hours === null) {
|
|
48
|
+
return null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return new Date(now.getTime() + preset.hours * 60 * 60 * 1000).toISOString()
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function getExpiryValue(expiresAt, now = new Date()) {
|
|
55
|
+
if (!expiresAt) {
|
|
56
|
+
return 'none'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const diffMs = new Date(expiresAt).getTime() - now.getTime()
|
|
60
|
+
if (diffMs <= 24 * 60 * 60 * 1000 + 60 * 1000) {
|
|
61
|
+
return 'none'
|
|
62
|
+
}
|
|
63
|
+
return 'none'
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function clampText(value = '', max = 20000) {
|
|
67
|
+
return String(value).slice(0, max)
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function slugifyTitle(title = '') {
|
|
71
|
+
const base = String(title)
|
|
72
|
+
.toLowerCase()
|
|
73
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
74
|
+
.replace(/^-+|-+$/g, '')
|
|
75
|
+
.slice(0, 36)
|
|
76
|
+
|
|
77
|
+
return base || 'task'
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function deriveTitleFromBlocks(blocks = [], max = 10) {
|
|
81
|
+
const firstText = blocks.find(
|
|
82
|
+
(block) =>
|
|
83
|
+
(block.type === BLOCK_TYPES.TEXT || block.type === BLOCK_TYPES.IMPORTED_TEXT) &&
|
|
84
|
+
block.content?.trim()
|
|
85
|
+
)
|
|
86
|
+
if (!firstText) {
|
|
87
|
+
return ''
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return firstText.content.replace(/\s+/g, ' ').trim().slice(0, max)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function buildRawTaskText(task) {
|
|
94
|
+
const parts = []
|
|
95
|
+
if (task.title) {
|
|
96
|
+
parts.push(`标题:${task.title}`, '')
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
for (const [index, block] of (task.blocks || []).entries()) {
|
|
100
|
+
if (block.type === BLOCK_TYPES.TEXT || block.type === BLOCK_TYPES.IMPORTED_TEXT) {
|
|
101
|
+
parts.push(block.content?.trim() || '', '')
|
|
102
|
+
continue
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
if (block.type === BLOCK_TYPES.IMAGE) {
|
|
106
|
+
parts.push(`图片 ${index + 1}:${block.content || ''}`)
|
|
107
|
+
parts.push('')
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return parts.join('\n').trim() + '\n'
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function summarizeTask(task) {
|
|
115
|
+
const textBlock = (task.blocks || []).find(
|
|
116
|
+
(block) =>
|
|
117
|
+
(block.type === BLOCK_TYPES.TEXT || block.type === BLOCK_TYPES.IMPORTED_TEXT) &&
|
|
118
|
+
block.content?.trim()
|
|
119
|
+
)
|
|
120
|
+
return (textBlock?.content || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
|
121
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import net from 'node:net'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import process from 'node:process'
|
|
5
|
+
import { execFileSync } from 'node:child_process'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
import { resolvePromptxPaths } from '../apps/server/src/appPaths.js'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_HOST = '127.0.0.1'
|
|
11
|
+
const DEFAULT_PORT = 3000
|
|
12
|
+
const SUPPORTED_NODE_RANGES = [
|
|
13
|
+
{ min: [20, 19, 0], maxExclusiveMajor: 21, label: '20.19+' },
|
|
14
|
+
{ min: [22, 13, 0], maxExclusiveMajor: 23, label: '22.13+' },
|
|
15
|
+
{ min: [24, 0, 0], maxExclusiveMajor: 25, label: '24.x' },
|
|
16
|
+
]
|
|
17
|
+
const RECOMMENDED_NODE_MAJOR = 22
|
|
18
|
+
const CODEX_BIN = process.env.CODEX_BIN || 'codex'
|
|
19
|
+
|
|
20
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
21
|
+
const webIndexPath = path.join(rootDir, 'apps', 'web', 'dist', 'index.html')
|
|
22
|
+
|
|
23
|
+
function compareVersions(left = [], right = []) {
|
|
24
|
+
const maxLength = Math.max(left.length, right.length)
|
|
25
|
+
for (let index = 0; index < maxLength; index += 1) {
|
|
26
|
+
const leftValue = Number(left[index] || 0)
|
|
27
|
+
const rightValue = Number(right[index] || 0)
|
|
28
|
+
if (leftValue > rightValue) {
|
|
29
|
+
return 1
|
|
30
|
+
}
|
|
31
|
+
if (leftValue < rightValue) {
|
|
32
|
+
return -1
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return 0
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parseNodeVersion(value = '') {
|
|
39
|
+
return String(value || '')
|
|
40
|
+
.replace(/^v/, '')
|
|
41
|
+
.split('.')
|
|
42
|
+
.map((part) => Number(part) || 0)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function resolveCodexBinary() {
|
|
46
|
+
if (process.platform !== 'win32') {
|
|
47
|
+
return CODEX_BIN
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (path.extname(CODEX_BIN)) {
|
|
51
|
+
return CODEX_BIN
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (fs.existsSync(`${CODEX_BIN}.cmd`)) {
|
|
55
|
+
return `${CODEX_BIN}.cmd`
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (fs.existsSync(`${CODEX_BIN}.bat`)) {
|
|
59
|
+
return `${CODEX_BIN}.bat`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (fs.existsSync(CODEX_BIN)) {
|
|
63
|
+
return CODEX_BIN
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const output = execFileSync('where.exe', [CODEX_BIN], {
|
|
68
|
+
encoding: 'utf8',
|
|
69
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
70
|
+
}).trim()
|
|
71
|
+
|
|
72
|
+
if (!output) {
|
|
73
|
+
return CODEX_BIN
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const candidates = output
|
|
77
|
+
.split(/\r?\n/g)
|
|
78
|
+
.map((line) => line.trim())
|
|
79
|
+
.filter(Boolean)
|
|
80
|
+
|
|
81
|
+
return candidates.find((item) => /\.(cmd|bat)$/i.test(item))
|
|
82
|
+
|| candidates.find((item) => /\.(exe|com)$/i.test(item))
|
|
83
|
+
|| candidates[0]
|
|
84
|
+
|| CODEX_BIN
|
|
85
|
+
} catch {
|
|
86
|
+
return CODEX_BIN
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function execCodex(args = []) {
|
|
91
|
+
const resolvedCodexBin = resolveCodexBinary()
|
|
92
|
+
if (process.platform === 'win32' && /\.(cmd|bat)$/i.test(resolvedCodexBin)) {
|
|
93
|
+
return execFileSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', resolvedCodexBin, ...args], {
|
|
94
|
+
encoding: 'utf8',
|
|
95
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return execFileSync(resolvedCodexBin, args, {
|
|
100
|
+
encoding: 'utf8',
|
|
101
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createCheck(name, status, detail) {
|
|
106
|
+
return { name, status, detail }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function printCheck(result) {
|
|
110
|
+
const symbol = result.status === 'pass' ? 'OK' : result.status === 'warn' ? 'WARN' : 'FAIL'
|
|
111
|
+
console.log(`[${symbol}] ${result.name}: ${result.detail}`)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function checkNodeVersion() {
|
|
115
|
+
const current = parseNodeVersion(process.version)
|
|
116
|
+
const matchedRange = SUPPORTED_NODE_RANGES.find((range) => (
|
|
117
|
+
current[0] < range.maxExclusiveMajor
|
|
118
|
+
&& compareVersions(current, range.min) >= 0
|
|
119
|
+
))
|
|
120
|
+
|
|
121
|
+
if (!matchedRange) {
|
|
122
|
+
return createCheck(
|
|
123
|
+
'Node.js',
|
|
124
|
+
'fail',
|
|
125
|
+
`当前 ${process.version},支持范围是 20.19+ / 22.13+ / 24.x,推荐 Node ${RECOMMENDED_NODE_MAJOR}`
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const recommendation = current[0] === RECOMMENDED_NODE_MAJOR ? ',当前就是推荐版本线' : `,推荐 Node ${RECOMMENDED_NODE_MAJOR}`
|
|
130
|
+
return createCheck('Node.js', 'pass', `当前 ${process.version}(支持 ${matchedRange.label}${recommendation})`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function checkStorageWritable() {
|
|
134
|
+
try {
|
|
135
|
+
const { promptxHomeDir, dataDir, uploadsDir, tmpDir } = resolvePromptxPaths()
|
|
136
|
+
;[promptxHomeDir, dataDir, uploadsDir, tmpDir].forEach((targetPath) => {
|
|
137
|
+
fs.mkdirSync(targetPath, { recursive: true })
|
|
138
|
+
})
|
|
139
|
+
const probePath = path.join(tmpDir, `.doctor-${process.pid}-${Date.now()}.tmp`)
|
|
140
|
+
fs.writeFileSync(probePath, 'ok')
|
|
141
|
+
fs.rmSync(probePath, { force: true })
|
|
142
|
+
return createCheck('数据目录', 'pass', `${promptxHomeDir} 可读写`)
|
|
143
|
+
} catch (error) {
|
|
144
|
+
return createCheck('数据目录', 'fail', error.message || '无法写入 ~/.promptx')
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function checkBuiltAssets() {
|
|
149
|
+
if (!fs.existsSync(webIndexPath)) {
|
|
150
|
+
return createCheck('前端产物', 'fail', '缺少 apps/web/dist/index.html,请先构建或确认发包内容完整')
|
|
151
|
+
}
|
|
152
|
+
return createCheck('前端产物', 'pass', '已包含前端构建产物')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function checkCodexVersion() {
|
|
156
|
+
try {
|
|
157
|
+
const output = execCodex(['--version']).trim()
|
|
158
|
+
const detail = output ? `${resolveCodexBinary()} -> ${output}` : `${resolveCodexBinary()} 可执行`
|
|
159
|
+
return createCheck('Codex CLI', 'pass', detail)
|
|
160
|
+
} catch (error) {
|
|
161
|
+
return createCheck(
|
|
162
|
+
'Codex CLI',
|
|
163
|
+
'fail',
|
|
164
|
+
`找不到可用 Codex。请先确认终端里可以运行 \`codex --version\`,或设置 \`CODEX_BIN\`。${error.message ? ` (${error.message})` : ''}`
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function checkCodexFullModeFlags() {
|
|
170
|
+
try {
|
|
171
|
+
execCodex(['exec', '--dangerously-bypass-approvals-and-sandbox', '--skip-git-repo-check', '--help'])
|
|
172
|
+
return createCheck('Codex 满血参数', 'pass', '支持 PromptX 默认的满血参数')
|
|
173
|
+
} catch (error) {
|
|
174
|
+
const detail = String(error?.stderr || error?.stdout || error?.message || '').trim()
|
|
175
|
+
return createCheck(
|
|
176
|
+
'Codex 满血参数',
|
|
177
|
+
'warn',
|
|
178
|
+
detail
|
|
179
|
+
? `当前 Codex 可能不支持 PromptX 默认启动参数:${detail.split(/\r?\n/)[0]}`
|
|
180
|
+
: '当前 Codex 可能不支持 PromptX 默认启动参数'
|
|
181
|
+
)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function isPortOccupied(host, port) {
|
|
186
|
+
return new Promise((resolve) => {
|
|
187
|
+
const server = net.createServer()
|
|
188
|
+
server.unref()
|
|
189
|
+
server.once('error', (error) => {
|
|
190
|
+
resolve(error?.code === 'EADDRINUSE')
|
|
191
|
+
})
|
|
192
|
+
server.once('listening', () => {
|
|
193
|
+
server.close(() => resolve(false))
|
|
194
|
+
})
|
|
195
|
+
server.listen(port, host)
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function checkServicePort() {
|
|
200
|
+
const host = String(process.env.HOST || DEFAULT_HOST).trim() || DEFAULT_HOST
|
|
201
|
+
const port = Math.max(1, Number(process.env.PORT || process.env.PROMPTX_SERVER_PORT) || DEFAULT_PORT)
|
|
202
|
+
const baseUrl = `http://${host}:${port}`
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
const response = await fetch(`${baseUrl}/health`)
|
|
206
|
+
if (response.ok) {
|
|
207
|
+
return createCheck('服务端口', 'pass', `${baseUrl} 已有 PromptX 服务在运行`)
|
|
208
|
+
}
|
|
209
|
+
} catch {
|
|
210
|
+
// Ignore and continue.
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
if (await isPortOccupied(host, port)) {
|
|
214
|
+
return createCheck('服务端口', 'warn', `${baseUrl} 端口已被其他进程占用`)
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return createCheck('服务端口', 'pass', `${baseUrl} 可用`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function main() {
|
|
221
|
+
const codexVersionCheck = checkCodexVersion()
|
|
222
|
+
const checks = [
|
|
223
|
+
checkNodeVersion(),
|
|
224
|
+
checkStorageWritable(),
|
|
225
|
+
checkBuiltAssets(),
|
|
226
|
+
codexVersionCheck,
|
|
227
|
+
codexVersionCheck.status === 'pass'
|
|
228
|
+
? checkCodexFullModeFlags()
|
|
229
|
+
: createCheck('Codex 满血参数', 'warn', '已跳过,因为 Codex CLI 当前不可用'),
|
|
230
|
+
await checkServicePort(),
|
|
231
|
+
]
|
|
232
|
+
|
|
233
|
+
console.log('PromptX Doctor')
|
|
234
|
+
console.log('')
|
|
235
|
+
checks.forEach(printCheck)
|
|
236
|
+
|
|
237
|
+
const failCount = checks.filter((item) => item.status === 'fail').length
|
|
238
|
+
const warnCount = checks.filter((item) => item.status === 'warn').length
|
|
239
|
+
|
|
240
|
+
console.log('')
|
|
241
|
+
console.log(`[promptx] 检查完成:${checks.length - failCount - warnCount} 通过,${warnCount} 警告,${failCount} 失败`)
|
|
242
|
+
|
|
243
|
+
if (failCount > 0) {
|
|
244
|
+
process.exitCode = 1
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
main().catch((error) => {
|
|
249
|
+
console.error(`[promptx] Doctor 执行失败:${error.message || error}`)
|
|
250
|
+
process.exitCode = 1
|
|
251
|
+
})
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import process from 'node:process'
|
|
4
|
+
import { spawn } from 'node:child_process'
|
|
5
|
+
import { setTimeout as delay } from 'node:timers/promises'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
import { resolvePromptxPaths } from '../apps/server/src/appPaths.js'
|
|
9
|
+
|
|
10
|
+
const DEFAULT_PORT = 3000
|
|
11
|
+
const DEFAULT_HOST = '127.0.0.1'
|
|
12
|
+
const STARTUP_TIMEOUT_MS = 15_000
|
|
13
|
+
const STOP_TIMEOUT_MS = 8_000
|
|
14
|
+
const POLL_INTERVAL_MS = 250
|
|
15
|
+
|
|
16
|
+
const rootDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
17
|
+
const webDistDir = path.join(rootDir, 'apps', 'web', 'dist')
|
|
18
|
+
const webIndexPath = path.join(webDistDir, 'index.html')
|
|
19
|
+
const serverEntryPath = path.join(rootDir, 'apps', 'server', 'src', 'index.js')
|
|
20
|
+
|
|
21
|
+
function ensureRuntimeDir() {
|
|
22
|
+
const { promptxHomeDir } = resolvePromptxPaths()
|
|
23
|
+
const runtimeDir = path.join(promptxHomeDir, 'run')
|
|
24
|
+
fs.mkdirSync(runtimeDir, { recursive: true })
|
|
25
|
+
return runtimeDir
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getRuntimePaths() {
|
|
29
|
+
const runtimeDir = ensureRuntimeDir()
|
|
30
|
+
return {
|
|
31
|
+
runtimeDir,
|
|
32
|
+
pidFile: path.join(runtimeDir, 'service.pid'),
|
|
33
|
+
stateFile: path.join(runtimeDir, 'service.json'),
|
|
34
|
+
logFile: path.join(runtimeDir, 'service.log'),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJsonFile(filePath) {
|
|
39
|
+
try {
|
|
40
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
41
|
+
} catch {
|
|
42
|
+
return null
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function readPid(filePath) {
|
|
47
|
+
try {
|
|
48
|
+
const value = fs.readFileSync(filePath, 'utf8').trim()
|
|
49
|
+
const pid = Number(value)
|
|
50
|
+
return Number.isInteger(pid) && pid > 0 ? pid : 0
|
|
51
|
+
} catch {
|
|
52
|
+
return 0
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isProcessAlive(pid) {
|
|
57
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
58
|
+
return false
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
try {
|
|
62
|
+
process.kill(pid, 0)
|
|
63
|
+
return true
|
|
64
|
+
} catch (error) {
|
|
65
|
+
return error?.code === 'EPERM'
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function removeRuntimeFiles() {
|
|
70
|
+
const { pidFile, stateFile } = getRuntimePaths()
|
|
71
|
+
fs.rmSync(pidFile, { force: true })
|
|
72
|
+
fs.rmSync(stateFile, { force: true })
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function getServiceState() {
|
|
76
|
+
const { pidFile, stateFile, logFile } = getRuntimePaths()
|
|
77
|
+
const pid = readPid(pidFile)
|
|
78
|
+
const state = readJsonFile(stateFile) || {}
|
|
79
|
+
|
|
80
|
+
if (!isProcessAlive(pid)) {
|
|
81
|
+
removeRuntimeFiles()
|
|
82
|
+
return {
|
|
83
|
+
running: false,
|
|
84
|
+
pid: 0,
|
|
85
|
+
state: null,
|
|
86
|
+
logFile,
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
running: true,
|
|
92
|
+
pid,
|
|
93
|
+
state,
|
|
94
|
+
logFile,
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function getBaseUrl(host, port) {
|
|
99
|
+
return `http://${host}:${port}`
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function waitForHealth(baseUrl, pid) {
|
|
103
|
+
const deadline = Date.now() + STARTUP_TIMEOUT_MS
|
|
104
|
+
const healthUrl = `${baseUrl}/health`
|
|
105
|
+
|
|
106
|
+
while (Date.now() < deadline) {
|
|
107
|
+
if (!isProcessAlive(pid)) {
|
|
108
|
+
throw new Error('服务进程启动后很快退出。')
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
try {
|
|
112
|
+
const response = await fetch(healthUrl)
|
|
113
|
+
if (response.ok) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
} catch {
|
|
117
|
+
// Ignore until timeout.
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
await delay(POLL_INTERVAL_MS)
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
throw new Error('等待服务启动超时。')
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
async function checkHealth(baseUrl) {
|
|
127
|
+
try {
|
|
128
|
+
const response = await fetch(`${baseUrl}/health`)
|
|
129
|
+
return response.ok
|
|
130
|
+
} catch {
|
|
131
|
+
return false
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function tailLog(filePath, maxLines = 30) {
|
|
136
|
+
try {
|
|
137
|
+
const lines = fs.readFileSync(filePath, 'utf8').trim().split(/\r?\n/)
|
|
138
|
+
return lines.slice(-maxLines).join('\n').trim()
|
|
139
|
+
} catch {
|
|
140
|
+
return ''
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function startService() {
|
|
145
|
+
if (!fs.existsSync(webIndexPath)) {
|
|
146
|
+
throw new Error('没有找到前端构建产物,请先运行 `pnpm build`。')
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const existing = getServiceState()
|
|
150
|
+
if (existing.running) {
|
|
151
|
+
const host = String(existing.state?.host || DEFAULT_HOST)
|
|
152
|
+
const port = Number(existing.state?.port || DEFAULT_PORT)
|
|
153
|
+
console.log(`[promptx] 已在运行:${getBaseUrl(host, port)}(PID ${existing.pid})`)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const { pidFile, stateFile, logFile } = getRuntimePaths()
|
|
158
|
+
const host = String(process.env.HOST || DEFAULT_HOST).trim() || DEFAULT_HOST
|
|
159
|
+
const port = Math.max(1, Number(process.env.PORT || process.env.PROMPTX_SERVER_PORT) || DEFAULT_PORT)
|
|
160
|
+
const baseUrl = getBaseUrl(host, port)
|
|
161
|
+
const startedAt = new Date().toISOString()
|
|
162
|
+
|
|
163
|
+
if (await checkHealth(baseUrl)) {
|
|
164
|
+
throw new Error(`检测到 ${baseUrl} 已有服务在运行,请先释放端口或改用其他端口。`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const logFd = fs.openSync(logFile, 'a')
|
|
168
|
+
|
|
169
|
+
const child = spawn(process.execPath, [serverEntryPath], {
|
|
170
|
+
cwd: rootDir,
|
|
171
|
+
detached: true,
|
|
172
|
+
stdio: ['ignore', logFd, logFd],
|
|
173
|
+
env: {
|
|
174
|
+
...process.env,
|
|
175
|
+
HOST: host,
|
|
176
|
+
PORT: String(port),
|
|
177
|
+
},
|
|
178
|
+
})
|
|
179
|
+
|
|
180
|
+
fs.closeSync(logFd)
|
|
181
|
+
child.unref()
|
|
182
|
+
|
|
183
|
+
fs.writeFileSync(pidFile, `${child.pid}\n`, 'utf8')
|
|
184
|
+
fs.writeFileSync(stateFile, JSON.stringify({
|
|
185
|
+
pid: child.pid,
|
|
186
|
+
host,
|
|
187
|
+
port,
|
|
188
|
+
startedAt,
|
|
189
|
+
logFile,
|
|
190
|
+
}, null, 2))
|
|
191
|
+
|
|
192
|
+
try {
|
|
193
|
+
await waitForHealth(baseUrl, child.pid)
|
|
194
|
+
await delay(300)
|
|
195
|
+
if (!isProcessAlive(child.pid)) {
|
|
196
|
+
throw new Error(`服务进程已退出,通常是端口 ${port} 被占用或启动参数冲突。`)
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
if (isProcessAlive(child.pid)) {
|
|
200
|
+
try {
|
|
201
|
+
process.kill(child.pid, 'SIGTERM')
|
|
202
|
+
} catch {
|
|
203
|
+
// Ignore shutdown failure.
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
removeRuntimeFiles()
|
|
208
|
+
const recentLog = tailLog(logFile)
|
|
209
|
+
throw new Error([
|
|
210
|
+
error.message || '服务启动失败。',
|
|
211
|
+
recentLog ? `最近日志:\n${recentLog}` : '',
|
|
212
|
+
].filter(Boolean).join('\n\n'))
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
console.log(`[promptx] 已后台启动:${baseUrl}`)
|
|
216
|
+
console.log(`[promptx] 日志文件:${logFile}`)
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async function stopService() {
|
|
220
|
+
const current = getServiceState()
|
|
221
|
+
if (!current.running) {
|
|
222
|
+
console.log('[promptx] 当前没有运行中的服务。')
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const pid = current.pid
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
process.kill(pid, 'SIGTERM')
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (error?.code !== 'ESRCH') {
|
|
232
|
+
throw error
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const deadline = Date.now() + STOP_TIMEOUT_MS
|
|
237
|
+
while (Date.now() < deadline) {
|
|
238
|
+
if (!isProcessAlive(pid)) {
|
|
239
|
+
removeRuntimeFiles()
|
|
240
|
+
console.log(`[promptx] 已停止服务(PID ${pid})。`)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
await delay(POLL_INTERVAL_MS)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
try {
|
|
247
|
+
process.kill(pid, 'SIGKILL')
|
|
248
|
+
} catch {
|
|
249
|
+
// Ignore when process already exited.
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
removeRuntimeFiles()
|
|
253
|
+
console.log(`[promptx] 已强制停止服务(PID ${pid})。`)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
async function restartService() {
|
|
257
|
+
await stopService()
|
|
258
|
+
await startService()
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function printStatus() {
|
|
262
|
+
const current = getServiceState()
|
|
263
|
+
if (!current.running) {
|
|
264
|
+
console.log('[promptx] 服务未运行。')
|
|
265
|
+
return
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const host = String(current.state?.host || DEFAULT_HOST)
|
|
269
|
+
const port = Number(current.state?.port || DEFAULT_PORT)
|
|
270
|
+
const startedAt = current.state?.startedAt || ''
|
|
271
|
+
console.log(`[promptx] 运行中:${getBaseUrl(host, port)}`)
|
|
272
|
+
console.log(`[promptx] PID:${current.pid}`)
|
|
273
|
+
if (startedAt) {
|
|
274
|
+
console.log(`[promptx] 启动时间:${startedAt}`)
|
|
275
|
+
}
|
|
276
|
+
console.log(`[promptx] 日志文件:${current.logFile}`)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function main() {
|
|
280
|
+
const command = String(process.argv[2] || 'status').trim()
|
|
281
|
+
|
|
282
|
+
if (command === 'start') {
|
|
283
|
+
await startService()
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (command === 'stop') {
|
|
288
|
+
await stopService()
|
|
289
|
+
return
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
if (command === 'status') {
|
|
293
|
+
printStatus()
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (command === 'restart') {
|
|
298
|
+
await restartService()
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
throw new Error(`不支持的命令:${command}`)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
main().catch((error) => {
|
|
306
|
+
console.error(`[promptx] ${error.message || error}`)
|
|
307
|
+
process.exitCode = 1
|
|
308
|
+
})
|