@vibe-x/dsh-html-workbench 0.2.0 → 0.3.1
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 +16 -2
- package/assets/workbench.html +318 -3
- package/dsh.plugin.json +1 -1
- package/lib/client.js +514 -7
- package/package.json +1 -1
- package/scripts/workbench.py +1091 -37
- package/src/client.js +480 -6
- package/src/host.js +586 -41
- package/src/index.js +74 -5
package/src/host.js
CHANGED
|
@@ -28,38 +28,340 @@ return {
|
|
|
28
28
|
const PORT = opts.port || 4317
|
|
29
29
|
const SCRIPT = opts.script || null
|
|
30
30
|
const EDITOR_ROOT = opts.editorRoot || null
|
|
31
|
+
const PLATFORM = opts.platform || (typeof process !== 'undefined' ? process.platform : null)
|
|
32
|
+
const IS_WINDOWS = PLATFORM === 'win32'
|
|
33
|
+
// Reported verbatim in the diagnostics. A dynamic (cordis_define) load has no
|
|
34
|
+
// config, so `version` stays null — which is itself the answer to "is this the
|
|
35
|
+
// packaged build or a hot-loaded one?", the first question every report raises.
|
|
36
|
+
const VERSION = opts.version || null
|
|
37
|
+
// Candidates must be a SINGLE executable token: every call site appends its
|
|
38
|
+
// own arguments, so a multi-token entry like `py -3` would place the script
|
|
39
|
+
// path after the launcher's own flags and break argument forwarding. The `py`
|
|
40
|
+
// launcher is still supported — see `expandWindowsLauncher`, which asks it for
|
|
41
|
+
// the interpreter's absolute path and uses that instead.
|
|
42
|
+
const PYTHON_CANDIDATES = opts.pythonCommand
|
|
43
|
+
? [opts.pythonCommand]
|
|
44
|
+
: (IS_WINDOWS ? ['python3', 'python'] : ['python3'])
|
|
45
|
+
// Static packages inject the runtime directory resolved (and probed) by
|
|
46
|
+
// src/index.js. Keep a dynamic-loader fallback too: TEMP/TMPDIR cover
|
|
47
|
+
// Windows/macOS/Linux; only the final fallback is POSIX because dynamic
|
|
48
|
+
// runners may not expose Node's `os` module. The folder name matches
|
|
49
|
+
// RUNTIME_DIR_NAME in workbench.py so both halves agree on one location.
|
|
50
|
+
const tempRoot = typeof process !== 'undefined' && process.env
|
|
51
|
+
? (process.env.TEMP || process.env.TMP || process.env.TMPDIR)
|
|
52
|
+
: null
|
|
53
|
+
// Strip a trailing separator from EITHER source: `%TEMP%` and an injected
|
|
54
|
+
// runtimeDir both legitimately end in one, and appending '/logs' to it would
|
|
55
|
+
// yield `C:\Temp\hwb\/logs` — a path Python tolerates but that reads as
|
|
56
|
+
// corruption in every log line and error message a user pastes back.
|
|
57
|
+
const trimSeparator = (value) => String(value).replace(/[\\/]+$/, '')
|
|
58
|
+
const RUNTIME_DIR = opts.runtimeDir
|
|
59
|
+
? trimSeparator(opts.runtimeDir)
|
|
60
|
+
: (tempRoot ? trimSeparator(tempRoot) + '/.html-workbench' : '/tmp/.html-workbench')
|
|
61
|
+
|
|
62
|
+
// These are REQUESTS, not guarantees. The service runs as a DSH sandboxed
|
|
63
|
+
// child that may write only the session workspace and a private temp folder
|
|
64
|
+
// the sandbox hands it — the ambient %TEMP% root seen by this parent is
|
|
65
|
+
// denied, so a folder the parent can create is not necessarily one the child
|
|
66
|
+
// can use. workbench.py therefore re-probes in-process, falls back on its
|
|
67
|
+
// own, and advertises the directories it settled on; `adoptResolvedPaths`
|
|
68
|
+
// below surfaces those, so the panel describes where the service really
|
|
69
|
+
// writes instead of echoing the path we merely asked for. Both are disposable
|
|
70
|
+
// cache/log data either way.
|
|
71
|
+
const LOG_DIR = RUNTIME_DIR + '/logs'
|
|
72
|
+
const VENDOR_CACHE = RUNTIME_DIR + '/vendor'
|
|
73
|
+
|
|
74
|
+
// Directories the running service reported; null until it says so.
|
|
75
|
+
let resolvedPaths = null
|
|
31
76
|
|
|
32
77
|
let assets = []
|
|
33
78
|
let seq = 0
|
|
34
79
|
let ownedProcess = null
|
|
35
80
|
let serviceRunning = false
|
|
36
81
|
let startError = null
|
|
82
|
+
let pythonCommand = opts.pythonCommand || (IS_WINDOWS ? null : 'python3')
|
|
83
|
+
// A failed probe must be remembered: `resolve` runs on every keystroke and
|
|
84
|
+
// `health` on a timer, so re-probing would spawn three 5s subprocesses per
|
|
85
|
+
// call and flood the journal with one repeated line. The TTL still lets a
|
|
86
|
+
// user who just installed Python recover without reloading the plugin.
|
|
87
|
+
const PROBE_RETRY_MS = 30000
|
|
88
|
+
let pythonProbeFailedAt = 0
|
|
89
|
+
let pythonProbe = null
|
|
37
90
|
let disposed = false
|
|
38
91
|
|
|
39
92
|
const shell = ctx.get('shell')
|
|
40
93
|
|
|
94
|
+
// ── Diagnostics journal ──────────────────────────────────────────────────
|
|
95
|
+
//
|
|
96
|
+
// A failed start used to surface as nothing but a red dot: the only signal
|
|
97
|
+
// was `running: false`, while the actual cause (a Python traceback, a busy
|
|
98
|
+
// port, a missing interpreter) stayed inside this Node process. Every
|
|
99
|
+
// subprocess outcome now lands in this ring buffer, which `status` returns
|
|
100
|
+
// and the panel renders, so the failure is readable where it is observed.
|
|
101
|
+
const JOURNAL_LIMIT = 40
|
|
102
|
+
// Errors are the whole point of the journal, yet they are also the rarest
|
|
103
|
+
// entries: a service that fails at start then gets polled by `status` every
|
|
104
|
+
// few seconds would push the original traceback out of a plain ring buffer
|
|
105
|
+
// long before anyone opens the panel. So evict by level — drop the oldest
|
|
106
|
+
// non-error first, and only sacrifice an error when nothing else is left.
|
|
107
|
+
const JOURNAL_ERROR_FLOOR = 12
|
|
108
|
+
let journal = []
|
|
109
|
+
|
|
110
|
+
const trimJournal = () => {
|
|
111
|
+
if (journal.length <= JOURNAL_LIMIT) return
|
|
112
|
+
const overflow = journal.length - JOURNAL_LIMIT
|
|
113
|
+
let dropped = 0
|
|
114
|
+
const errors = journal.filter((entry) => entry.level === 'error').length
|
|
115
|
+
const keep = []
|
|
116
|
+
for (let i = 0; i < journal.length; i += 1) {
|
|
117
|
+
const entry = journal[i]
|
|
118
|
+
const isError = entry.level === 'error'
|
|
119
|
+
// Never let noise starve the error history, but do not grow without
|
|
120
|
+
// bound either: past the floor, errors age out like anything else.
|
|
121
|
+
const expendable = !isError || errors - dropped > JOURNAL_ERROR_FLOOR
|
|
122
|
+
if (dropped < overflow && expendable) {
|
|
123
|
+
dropped += 1
|
|
124
|
+
continue
|
|
125
|
+
}
|
|
126
|
+
keep.push(entry)
|
|
127
|
+
}
|
|
128
|
+
// Errors alone can exceed the limit; fall back to a plain tail so the
|
|
129
|
+
// buffer still has a hard ceiling.
|
|
130
|
+
journal = keep.length > JOURNAL_LIMIT ? keep.slice(-JOURNAL_LIMIT) : keep
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const note = (level, message, detail) => {
|
|
134
|
+
const text = String(message || '')
|
|
135
|
+
const body = detail ? String(detail).slice(0, 4000) : null
|
|
136
|
+
// Retrying a broken start replays the SAME failure verbatim, interleaved
|
|
137
|
+
// with the "restarting" line — so consecutive-only folding would miss it.
|
|
138
|
+
// Match anywhere, then move the entry to the end so its position still
|
|
139
|
+
// agrees with its timestamp when the panel renders newest-first.
|
|
140
|
+
let index = -1
|
|
141
|
+
for (let i = journal.length - 1; i >= 0; i -= 1) {
|
|
142
|
+
const entry = journal[i]
|
|
143
|
+
if (entry.level === level && entry.message === text && entry.detail === body) { index = i; break }
|
|
144
|
+
}
|
|
145
|
+
if (index >= 0) {
|
|
146
|
+
const entry = journal.splice(index, 1)[0]
|
|
147
|
+
entry.at = Date.now()
|
|
148
|
+
entry.repeated = (entry.repeated || 1) + 1
|
|
149
|
+
journal.push(entry)
|
|
150
|
+
} else {
|
|
151
|
+
journal.push({ at: Date.now(), level: level, message: text, detail: body })
|
|
152
|
+
trimJournal()
|
|
153
|
+
}
|
|
154
|
+
if (level === 'error') console.error('[html-workbench] ' + text, body || '')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// `CollectedOutput` carries more than text (truncation flags, spill paths);
|
|
158
|
+
// reduce it to the part a human reads, and say so when bytes were dropped.
|
|
159
|
+
const outputText = (collected) => {
|
|
160
|
+
if (!collected) return ''
|
|
161
|
+
const text = typeof collected === 'string' ? collected : (collected.text || '')
|
|
162
|
+
const trimmed = String(text).trim()
|
|
163
|
+
if (collected && collected.truncated) return trimmed + '\n…(输出被截断)'
|
|
164
|
+
return trimmed
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// `shell.resolve({ command })` takes ONE command line, so every embedded path
|
|
168
|
+
// must be quoted for the platform's parser. Naive `'"' + p + '"'` breaks on
|
|
169
|
+
// Windows, where `C:\dir\` ends in a backslash that escapes the closing quote
|
|
170
|
+
// and swallows the rest of the line. Never use JSON.stringify here: it emits
|
|
171
|
+
// JSON escapes (`\\`), which cmd.exe passes through verbatim, so Python
|
|
172
|
+
// receives a path with doubled separators and reports every file as missing.
|
|
173
|
+
const quoteArg = (value) => {
|
|
174
|
+
const raw = String(value == null ? '' : value)
|
|
175
|
+
if (IS_WINDOWS) {
|
|
176
|
+
// Double only the run of backslashes that precedes the closing quote;
|
|
177
|
+
// interior ones are literal to cmd.exe. Inner quotes cannot appear in a
|
|
178
|
+
// Windows path, but strip them rather than emit an unbalanced line.
|
|
179
|
+
const cleaned = raw.replace(/"/g, '')
|
|
180
|
+
const tail = cleaned.match(/\\*$/)[0]
|
|
181
|
+
return '"' + cleaned + tail + '"'
|
|
182
|
+
}
|
|
183
|
+
return "'" + raw.replace(/'/g, "'\\''") + "'"
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const describeRun = (result) => {
|
|
187
|
+
if (!result) return 'shell.run threw'
|
|
188
|
+
const parts = []
|
|
189
|
+
if (result.timedOut) parts.push('超时(' + result.timeoutMs + 'ms)')
|
|
190
|
+
if (result.aborted) parts.push('被取消')
|
|
191
|
+
if (result.signal) parts.push('信号 ' + result.signal)
|
|
192
|
+
parts.push('exit=' + String(result.exitCode))
|
|
193
|
+
const streams = [outputText(result.stderr), outputText(result.stdout)].filter(Boolean)
|
|
194
|
+
return parts.join(' ') + (streams.length ? '\n' + streams.join('\n') : '')
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Windows ships a `py` launcher that resolves versions the PATH may not
|
|
198
|
+
// expose. It is NOT usable as a command prefix (`py -3 script.py` mangles
|
|
199
|
+
// forwarded arguments), so ask it for the interpreter's absolute path and use
|
|
200
|
+
// that as a normal single-token executable.
|
|
201
|
+
const expandWindowsLauncher = async () => {
|
|
202
|
+
if (!IS_WINDOWS || !shell) return null
|
|
203
|
+
const probe = 'py -3 -c ' + quoteArg('import sys; print(sys.executable)')
|
|
204
|
+
try {
|
|
205
|
+
const result = await shell.run(shell.resolve({ command: probe, timeoutMs: 5000, stdoutMaxBytes: 4096 }))
|
|
206
|
+
if (!result || result.exitCode !== 0) return { failure: 'py -3: ' + describeRun(result) }
|
|
207
|
+
const executable = outputText(result.stdout).split('\n').pop().trim()
|
|
208
|
+
if (!executable) return { failure: 'py -3: 没有返回解释器路径' }
|
|
209
|
+
return { command: quoteArg(executable) }
|
|
210
|
+
} catch (e) {
|
|
211
|
+
return { failure: 'py -3: ' + ((e && e.message) || String(e)) }
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const resolvePythonCommand = async () => {
|
|
216
|
+
if (pythonCommand) return pythonCommand
|
|
217
|
+
if (!shell) return null
|
|
218
|
+
// Collapse concurrent callers onto one probe: the panel's poll, an `open`
|
|
219
|
+
// and a keystroke-driven `resolve` can all arrive inside the same tick.
|
|
220
|
+
if (pythonProbe) return pythonProbe
|
|
221
|
+
if (pythonProbeFailedAt && Date.now() - pythonProbeFailedAt < PROBE_RETRY_MS) return null
|
|
222
|
+
pythonProbe = (async () => {
|
|
223
|
+
const failures = []
|
|
224
|
+
for (const candidate of PYTHON_CANDIDATES) {
|
|
225
|
+
try {
|
|
226
|
+
const spec = shell.resolve({ command: candidate + ' --version', timeoutMs: 5000, stdoutMaxBytes: 4096 })
|
|
227
|
+
const result = await shell.run(spec)
|
|
228
|
+
if (result && result.exitCode === 0) {
|
|
229
|
+
pythonCommand = candidate
|
|
230
|
+
pythonProbeFailedAt = 0
|
|
231
|
+
note('info', '使用 Python 解释器:' + candidate)
|
|
232
|
+
return pythonCommand
|
|
233
|
+
}
|
|
234
|
+
failures.push(candidate + ': ' + describeRun(result))
|
|
235
|
+
} catch (e) {
|
|
236
|
+
failures.push(candidate + ': ' + ((e && e.message) || String(e)))
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const launcher = await expandWindowsLauncher()
|
|
240
|
+
if (launcher && launcher.command) {
|
|
241
|
+
pythonCommand = launcher.command
|
|
242
|
+
pythonProbeFailedAt = 0
|
|
243
|
+
note('info', '使用 Python 解释器(经 py 启动器解析):' + launcher.command)
|
|
244
|
+
return pythonCommand
|
|
245
|
+
}
|
|
246
|
+
if (launcher && launcher.failure) failures.push(launcher.failure)
|
|
247
|
+
pythonProbeFailedAt = Date.now()
|
|
248
|
+
startError = '找不到可用的 Python 3 解释器(已尝试:'
|
|
249
|
+
+ PYTHON_CANDIDATES.join('、') + (IS_WINDOWS ? '、py -3' : '')
|
|
250
|
+
+ ')。请安装 Python 3.9+ 并确保它在 PATH 中,然后点「重启服务」。'
|
|
251
|
+
note('error', startError, failures.join('\n'))
|
|
252
|
+
return null
|
|
253
|
+
})()
|
|
254
|
+
try {
|
|
255
|
+
return await pythonProbe
|
|
256
|
+
} finally {
|
|
257
|
+
pythonProbe = null
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
41
261
|
const runCli = async (args, timeoutMs) => {
|
|
262
|
+
if (!shell) { note('error', 'shell 服务不可用,无法执行 workbench.py'); return null }
|
|
263
|
+
if (!SCRIPT) { note('error', 'workbench.py 路径未配置'); return null }
|
|
264
|
+
const python = await resolvePythonCommand()
|
|
265
|
+
if (!python) return null
|
|
266
|
+
const command = python + ' ' + quoteArg(SCRIPT) + ' ' + args
|
|
267
|
+
try {
|
|
268
|
+
const spec = shell.resolve({ command: command, timeoutMs: timeoutMs || 15000, stdoutMaxBytes: 64 * 1024 })
|
|
269
|
+
const result = await shell.run(spec)
|
|
270
|
+
// A non-zero exit is the interesting case and used to be discarded: the
|
|
271
|
+
// CLI prints a structured `{ok:false,error:{code,message}}` on stdout.
|
|
272
|
+
if (!result || result.exitCode !== 0) note('error', 'workbench.py ' + args.split(' ')[0] + ' 执行失败', describeRun(result))
|
|
273
|
+
return result
|
|
274
|
+
} catch (e) {
|
|
275
|
+
note('error', 'workbench.py ' + args.split(' ')[0] + ' 无法执行', (e && e.message) || String(e))
|
|
276
|
+
return null
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// `health` runs on a timer, so its failures are expected and must not flood
|
|
281
|
+
// the journal — poll quietly and let the caller decide what is worth noting.
|
|
282
|
+
const probeHealth = async () => {
|
|
42
283
|
if (!shell || !SCRIPT) return null
|
|
284
|
+
const python = await resolvePythonCommand()
|
|
285
|
+
if (!python) return null
|
|
43
286
|
try {
|
|
44
287
|
const spec = shell.resolve({
|
|
45
|
-
command: '
|
|
46
|
-
timeoutMs:
|
|
288
|
+
command: python + ' ' + quoteArg(SCRIPT) + ' health --port ' + PORT,
|
|
289
|
+
timeoutMs: 8000,
|
|
47
290
|
stdoutMaxBytes: 64 * 1024,
|
|
48
291
|
})
|
|
49
|
-
|
|
292
|
+
const result = await shell.run(spec)
|
|
293
|
+
if (!result || result.exitCode !== 0) return { ok: false, result: result }
|
|
294
|
+
try {
|
|
295
|
+
const payload = JSON.parse(outputText(result.stdout) || '')
|
|
296
|
+
return payload && payload.ok === true ? { ok: true, payload: payload } : { ok: false, result: result }
|
|
297
|
+
} catch (e) {
|
|
298
|
+
return { ok: false, result: result }
|
|
299
|
+
}
|
|
50
300
|
} catch (e) {
|
|
51
|
-
return
|
|
301
|
+
return { ok: false, error: (e && e.message) || String(e) }
|
|
52
302
|
}
|
|
53
303
|
}
|
|
54
304
|
|
|
305
|
+
// `/api/health` advertises the directories the service really writes to. The
|
|
306
|
+
// paths it was ASKED for may be exactly what the sandbox refused, so adopting
|
|
307
|
+
// the reported ones is what lets the panel stay truthful — for a service we
|
|
308
|
+
// started and for one we merely reused (a reused service leaves us no startup
|
|
309
|
+
// output to read them from).
|
|
310
|
+
const adoptResolvedPaths = (payload) => {
|
|
311
|
+
if (!payload) return
|
|
312
|
+
const logDir = typeof payload.logDir === 'string' ? payload.logDir : null
|
|
313
|
+
const vendorCache = typeof payload.vendorCache === 'string' ? payload.vendorCache : null
|
|
314
|
+
if (logDir || vendorCache) resolvedPaths = { logDir: logDir, vendorCache: vendorCache }
|
|
315
|
+
}
|
|
316
|
+
|
|
55
317
|
const health = async () => {
|
|
56
|
-
const
|
|
57
|
-
if (!
|
|
318
|
+
const probe = await probeHealth()
|
|
319
|
+
if (!probe || !probe.ok) return null
|
|
320
|
+
adoptResolvedPaths(probe.payload)
|
|
321
|
+
return probe.payload
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// `workbench.py` reports failures as one structured JSON line on stderr. Turn
|
|
325
|
+
// the machine code into the sentence a person can act on; keep the raw line
|
|
326
|
+
// in the detail so nothing is lost when the code is one we do not know.
|
|
327
|
+
const CLI_HINTS = {
|
|
328
|
+
PORT_IN_USE: '端口已被占用。可能有别的进程(或旧版服务)占着它 —— 点「重启服务」会先退役占用者。',
|
|
329
|
+
ASSET_NOT_FOUND: '前端资源缺失,插件安装目录不完整(缺 assets/workbench.html)。',
|
|
330
|
+
PYTHON_TOO_OLD: '需要 Python 3.9 或更高版本。',
|
|
331
|
+
SERVER_OUTDATED: '端口上有旧版服务且无法自动停止,请手动结束该进程。',
|
|
332
|
+
FILE_NOT_FOUND: '找不到文件。',
|
|
333
|
+
VENDOR_DOWNLOAD_FAILED: '首次启动需要联网下载 GrapesJS,下载失败了。',
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const explainOutput = (text) => {
|
|
337
|
+
if (!text) return null
|
|
338
|
+
for (const line of String(text).split('\n')) {
|
|
339
|
+
const trimmed = line.trim()
|
|
340
|
+
if (!trimmed.startsWith('{')) continue
|
|
341
|
+
try {
|
|
342
|
+
const payload = JSON.parse(trimmed)
|
|
343
|
+
if (payload && payload.ok === false && payload.error) {
|
|
344
|
+
const hint = CLI_HINTS[payload.error]
|
|
345
|
+
return (hint || payload.message || payload.error) + '(' + payload.error + ')'
|
|
346
|
+
}
|
|
347
|
+
} catch (e) { /* not the JSON line; keep scanning */ }
|
|
348
|
+
}
|
|
349
|
+
return null
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Everything the started process wrote since the last read. DSH settles a
|
|
353
|
+
// spawn failure as `killed` with the error on stderr, so this is the only
|
|
354
|
+
// place a "python3: command not found" or a traceback can be observed.
|
|
355
|
+
const drain = (proc) => {
|
|
58
356
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
357
|
+
const read = proc.readOutput()
|
|
358
|
+
if (!read) return ''
|
|
359
|
+
let text = String(read.delta || '').trim()
|
|
360
|
+
if (read.lossy) text += '\n…(部分输出丢失)'
|
|
361
|
+
if (read.stderrSpillPath) text += '\n完整 stderr: ' + read.stderrSpillPath
|
|
362
|
+
return text
|
|
61
363
|
} catch (e) {
|
|
62
|
-
return
|
|
364
|
+
return ''
|
|
63
365
|
}
|
|
64
366
|
}
|
|
65
367
|
|
|
@@ -67,34 +369,143 @@ return {
|
|
|
67
369
|
if (disposed) return null
|
|
68
370
|
if (serviceRunning) return health()
|
|
69
371
|
const existing = await health()
|
|
70
|
-
if (existing) {
|
|
71
|
-
|
|
72
|
-
|
|
372
|
+
if (existing) {
|
|
373
|
+
serviceRunning = true
|
|
374
|
+
startError = null
|
|
375
|
+
healthConfirmedAt = Date.now()
|
|
376
|
+
// Record the reuse too, so the journal always explains the CURRENT state
|
|
377
|
+
// rather than only the failures.
|
|
378
|
+
note('info', '复用已在运行的服务:版本 ' + (existing.version || '未知') + ',端口 ' + PORT)
|
|
379
|
+
return existing
|
|
380
|
+
}
|
|
381
|
+
if (!shell) { startError = 'shell 服务不可用(DSH 未提供 shell,插件无法拉起本地服务)'; note('error', startError); return null }
|
|
382
|
+
if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; note('error', startError); return null }
|
|
383
|
+
const python = await resolvePythonCommand()
|
|
384
|
+
if (!python) return null
|
|
73
385
|
let proc = null
|
|
386
|
+
const command = python + ' ' + quoteArg(SCRIPT) + ' serve --port ' + PORT
|
|
387
|
+
+ ' --log-dir ' + quoteArg(LOG_DIR)
|
|
388
|
+
+ ' --vendor-cache ' + quoteArg(VENDOR_CACHE)
|
|
389
|
+
+ (EDITOR_ROOT ? ' --editor-root ' + quoteArg(EDITOR_ROOT) : '')
|
|
74
390
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
command: 'python3 "' + SCRIPT + '" serve --port ' + PORT + editorRootArg,
|
|
78
|
-
stdoutMaxBytes: 64 * 1024,
|
|
79
|
-
})
|
|
80
|
-
proc = shell.start(spec)
|
|
391
|
+
note('info', '启动本地服务:端口 ' + PORT, command)
|
|
392
|
+
proc = shell.start(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
|
|
81
393
|
if (disposed) { try { proc.kill() } catch (e) {} return null }
|
|
82
394
|
ownedProcess = proc
|
|
395
|
+
let transcript = ''
|
|
83
396
|
for (let i = 0; i < 240; i += 1) {
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
397
|
+
const chunk = drain(proc)
|
|
398
|
+
if (chunk) transcript += (transcript ? '\n' : '') + chunk
|
|
399
|
+
const probe = await probeHealth()
|
|
400
|
+
if (probe && probe.ok) {
|
|
401
|
+
serviceRunning = true
|
|
402
|
+
startError = null
|
|
403
|
+
healthConfirmedAt = Date.now()
|
|
404
|
+
adoptResolvedPaths(probe.payload)
|
|
405
|
+
note('info', '本地服务就绪:版本 ' + (probe.payload.version || '未知') + ',端口 ' + PORT)
|
|
406
|
+
return probe.payload
|
|
407
|
+
}
|
|
408
|
+
// Stop waiting the moment the process is gone. Polling health for the
|
|
409
|
+
// full 60s after `serve` already exited hides the reason and makes a
|
|
410
|
+
// fast, loud failure look like a hang.
|
|
411
|
+
if (proc.status !== 'running') {
|
|
412
|
+
const tail = drain(proc)
|
|
413
|
+
if (tail) transcript += (transcript ? '\n' : '') + tail
|
|
414
|
+
// Lead with the CLI's own diagnosis when it gave one — "端口已被占用"
|
|
415
|
+
// is actionable, "exit=1" is not.
|
|
416
|
+
const explained = explainOutput(transcript)
|
|
417
|
+
startError = explained || ('serve 进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
|
|
418
|
+
note('error', startError, transcript || '进程没有任何输出。命令:' + command)
|
|
419
|
+
ownedProcess = null
|
|
420
|
+
return null
|
|
421
|
+
}
|
|
86
422
|
await ctx.timeout(250)
|
|
87
423
|
}
|
|
88
|
-
startError = '
|
|
424
|
+
startError = '服务在 60 秒内没有就绪(端口 ' + PORT + ' 未通过健康检查)'
|
|
425
|
+
note('error', startError, transcript || '进程仍在运行但健康检查一直失败。命令:' + command)
|
|
89
426
|
return null
|
|
90
427
|
} catch (e) {
|
|
91
|
-
startError =
|
|
428
|
+
startError = 'serve 进程启动失败:' + ((e && e.message) || String(e))
|
|
429
|
+
note('error', startError, command)
|
|
92
430
|
if (proc) { try { proc.kill() } catch (e2) {} }
|
|
93
431
|
ownedProcess = null
|
|
94
432
|
return null
|
|
95
433
|
}
|
|
96
434
|
}
|
|
97
435
|
|
|
436
|
+
// A service that dies AFTER a successful start used to leave the plugin
|
|
437
|
+
// frozen: nothing ever re-checked the process, so the panel kept showing a
|
|
438
|
+
// stale dot while every open failed with no explanation. Reading
|
|
439
|
+
// `ownedProcess.status` costs nothing (no subprocess), so the panel's
|
|
440
|
+
// existing poll can supervise the process for free.
|
|
441
|
+
const superviseOwnedProcess = () => {
|
|
442
|
+
const proc = ownedProcess
|
|
443
|
+
if (!proc || proc.status === 'running') return
|
|
444
|
+
const tail = drain(proc)
|
|
445
|
+
const explained = explainOutput(tail)
|
|
446
|
+
ownedProcess = null
|
|
447
|
+
serviceRunning = false
|
|
448
|
+
startError = explained || ('服务进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
|
|
449
|
+
note('error', '服务进程意外退出', tail || '进程没有留下输出。可用「重启服务」重新拉起。')
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// A REUSED service has no process handle, so its death is invisible to the
|
|
453
|
+
// check above. Revalidate with a real health call — throttled, because this
|
|
454
|
+
// spawns a subprocess and the panel polls every few seconds. The window is
|
|
455
|
+
// seeded when the service is confirmed up, so a freshly verified service is
|
|
456
|
+
// not immediately re-checked.
|
|
457
|
+
const REVALIDATE_MS = 10000
|
|
458
|
+
let healthConfirmedAt = 0
|
|
459
|
+
let revalidating = false
|
|
460
|
+
|
|
461
|
+
const revalidateService = () => {
|
|
462
|
+
if (revalidating || ownedProcess || !serviceRunning) return
|
|
463
|
+
if (Date.now() - healthConfirmedAt < REVALIDATE_MS) return
|
|
464
|
+
revalidating = true
|
|
465
|
+
void (async () => {
|
|
466
|
+
try {
|
|
467
|
+
if (await health()) { healthConfirmedAt = Date.now(); return }
|
|
468
|
+
serviceRunning = false
|
|
469
|
+
startError = '之前复用的服务已经不再响应(端口 ' + PORT + ')。'
|
|
470
|
+
note('error', '复用的服务失去响应', '健康检查失败。可用「重启服务」重新拉起。')
|
|
471
|
+
} finally {
|
|
472
|
+
revalidating = false
|
|
473
|
+
}
|
|
474
|
+
})()
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Everything the panel needs to explain a red dot without opening a terminal.
|
|
478
|
+
// Reaping first means a process that died since the last call is reported as
|
|
479
|
+
// dead here, instead of leaving a stale "running" dot behind.
|
|
480
|
+
const diagnostics = () => {
|
|
481
|
+
superviseOwnedProcess()
|
|
482
|
+
revalidateService()
|
|
483
|
+
return {
|
|
484
|
+
ok: true,
|
|
485
|
+
running: serviceRunning,
|
|
486
|
+
owned: !!ownedProcess,
|
|
487
|
+
version: VERSION,
|
|
488
|
+
platform: PLATFORM,
|
|
489
|
+
port: PORT,
|
|
490
|
+
script: SCRIPT,
|
|
491
|
+
editorRoot: EDITOR_ROOT,
|
|
492
|
+
runtimeDir: RUNTIME_DIR,
|
|
493
|
+
pythonCommand: pythonCommand,
|
|
494
|
+
pythonCandidates: PYTHON_CANDIDATES.slice(),
|
|
495
|
+
// Where the service really writes, once it has told us. The requested
|
|
496
|
+
// paths stand in only until then: they may be exactly what the sandbox
|
|
497
|
+
// refused, and showing them after the fact is what made a working service
|
|
498
|
+
// look broken.
|
|
499
|
+
logDir: (resolvedPaths && resolvedPaths.logDir) || LOG_DIR,
|
|
500
|
+
vendorCache: (resolvedPaths && resolvedPaths.vendorCache) || VENDOR_CACHE,
|
|
501
|
+
hasShell: !!shell,
|
|
502
|
+
processStatus: ownedProcess ? ownedProcess.status : null,
|
|
503
|
+
exitCode: ownedProcess ? ownedProcess.exitCode : null,
|
|
504
|
+
startError: startError,
|
|
505
|
+
journal: journal.slice().reverse(),
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
98
509
|
// 生命周期副作用:卸载时停掉本插件自己启动的服务进程。
|
|
99
510
|
ctx.effect(() => () => {
|
|
100
511
|
disposed = true
|
|
@@ -133,18 +544,59 @@ return {
|
|
|
133
544
|
const snapshot = () => assets.slice().sort((a, b) => b.seq - a.seq)
|
|
134
545
|
|
|
135
546
|
const openFile = async (file) => {
|
|
136
|
-
if (typeof file !== 'string' || !file) return { ok: false, error: '
|
|
137
|
-
if (!/\.html?$/i.test(file)) return { ok: false, error: '
|
|
138
|
-
if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置' }
|
|
547
|
+
if (typeof file !== 'string' || !file) return { ok: false, error: '请填写文件路径' }
|
|
548
|
+
if (!/\.html?$/i.test(file)) return { ok: false, error: '只支持 .html / .htm 文件' }
|
|
549
|
+
if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置', diagnostics: diagnostics() }
|
|
139
550
|
const wasUp = await health()
|
|
140
551
|
if (!wasUp) await startService()
|
|
141
552
|
const h = await health()
|
|
142
|
-
|
|
553
|
+
// Carry the diagnostics WITH the failure: the panel can then show the real
|
|
554
|
+
// cause instead of a generic "unavailable" that sends the user hunting.
|
|
555
|
+
if (!h) return { ok: false, error: startError || '本地服务未就绪', diagnostics: diagnostics() }
|
|
143
556
|
return {
|
|
144
557
|
ok: true,
|
|
145
558
|
url: 'http://127.0.0.1:' + PORT + '/?file=' + encodeURIComponent(file),
|
|
146
559
|
reused: !!wasUp,
|
|
147
560
|
port: PORT,
|
|
561
|
+
version: h.version || null,
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
const restartService = async () => {
|
|
566
|
+
const proc = ownedProcess
|
|
567
|
+
ownedProcess = null
|
|
568
|
+
serviceRunning = false
|
|
569
|
+
startError = null
|
|
570
|
+
if (proc) { try { proc.kill() } catch (e) {} }
|
|
571
|
+
note('info', '手动重启本地服务')
|
|
572
|
+
// Killing our own handle is not enough when the port is held by a process
|
|
573
|
+
// we do not own (a leftover from a previous DSH run, or an outdated
|
|
574
|
+
// version) — the spawn would then die with PORT_IN_USE. Clear the port
|
|
575
|
+
// first; `stop` is a no-op when nothing is listening.
|
|
576
|
+
const stopped = await runCli('stop --port ' + PORT, 20000)
|
|
577
|
+
if (stopped && stopped.exitCode !== 0) {
|
|
578
|
+
return { ok: false, error: '端口 ' + PORT + ' 上的旧进程无法停止,请手动结束后重试。', diagnostics: diagnostics() }
|
|
579
|
+
}
|
|
580
|
+
const h = await startService()
|
|
581
|
+
return h ? { ok: true, info: h, diagnostics: diagnostics() } : { ok: false, error: startError || '重启失败', diagnostics: diagnostics() }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Browser address bars yield file:///… URLs while the CLI expects a native
|
|
585
|
+
// path. Normalize only for the local existence probe; the Python service
|
|
586
|
+
// repeats this normalization authoritatively for every request.
|
|
587
|
+
const normalizeFileReference = (value) => {
|
|
588
|
+
const raw = String(value || '').trim()
|
|
589
|
+
if (!/^file:/i.test(raw)) return raw
|
|
590
|
+
try {
|
|
591
|
+
const url = new URL(raw)
|
|
592
|
+
if (url.protocol !== 'file:' || url.search || url.hash) return raw
|
|
593
|
+
let path = decodeURIComponent(url.pathname)
|
|
594
|
+
const isWindows = typeof process !== 'undefined' && process.platform === 'win32'
|
|
595
|
+
if (isWindows && /^[\\/][A-Za-z]:\//.test(path)) path = path.slice(1)
|
|
596
|
+
if (isWindows && url.hostname && url.hostname !== 'localhost') path = '//' + url.hostname + path
|
|
597
|
+
return path
|
|
598
|
+
} catch (e) {
|
|
599
|
+
return raw
|
|
148
600
|
}
|
|
149
601
|
}
|
|
150
602
|
|
|
@@ -152,24 +604,75 @@ return {
|
|
|
152
604
|
if (typeof file !== 'string') return { ok: false, error: 'file is required' }
|
|
153
605
|
const trimmed = file.trim()
|
|
154
606
|
if (!trimmed) return { ok: true, empty: true, isHtml: false, exists: false }
|
|
155
|
-
|
|
156
|
-
if (
|
|
157
|
-
|
|
158
|
-
const
|
|
607
|
+
const normalized = normalizeFileReference(trimmed)
|
|
608
|
+
if (!/\.html?$/i.test(normalized)) return { ok: true, isHtml: false, exists: false }
|
|
609
|
+
if (!shell) return { ok: true, isHtml: true, exists: null, normalized: normalized }
|
|
610
|
+
const python = await resolvePythonCommand()
|
|
611
|
+
// Say WHY the check could not run. Returning a bare `exists: null` renders
|
|
612
|
+
// as no indicator at all, which reads as "the field is ignoring me".
|
|
613
|
+
if (!python) {
|
|
614
|
+
return { ok: true, isHtml: true, exists: null, normalized: normalized, error: startError }
|
|
615
|
+
}
|
|
616
|
+
const probe = 'import os,sys; sys.exit(0 if os.path.isfile(sys.argv[1]) else 1)'
|
|
617
|
+
const spec = shell.resolve({
|
|
618
|
+
command: python + ' -c ' + quoteArg(probe) + ' ' + quoteArg(normalized),
|
|
619
|
+
timeoutMs: 5000,
|
|
620
|
+
stdoutMaxBytes: 1024,
|
|
621
|
+
})
|
|
159
622
|
try {
|
|
160
623
|
const r = await shell.run(spec)
|
|
161
|
-
return { ok: true, isHtml: true, exists: !!(r && r.exitCode === 0) }
|
|
624
|
+
return { ok: true, isHtml: true, exists: !!(r && r.exitCode === 0), normalized: normalized }
|
|
162
625
|
} catch (e) {
|
|
163
|
-
return { ok: true, isHtml: true, exists: null }
|
|
626
|
+
return { ok: true, isHtml: true, exists: null, normalized: normalized, error: (e && e.message) || String(e) }
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
const listPayload = () => {
|
|
631
|
+
const base = diagnostics()
|
|
632
|
+
base.assets = snapshot()
|
|
633
|
+
return base
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
const statusPayload = async () => {
|
|
637
|
+
const h = await health()
|
|
638
|
+
const out = diagnostics()
|
|
639
|
+
out.ok = !!h
|
|
640
|
+
out.running = !!h
|
|
641
|
+
out.info = h || null
|
|
642
|
+
return out
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// `diagnostics()` supervises the child process and revalidates the service,
|
|
646
|
+
// so it can itself throw (a dead handle, a shell that rejects). Since it is
|
|
647
|
+
// the payload every error path attaches, it must never be the thing that
|
|
648
|
+
// turns a readable failure back into an opaque one.
|
|
649
|
+
const safeDiagnostics = () => {
|
|
650
|
+
try {
|
|
651
|
+
return diagnostics()
|
|
652
|
+
} catch (error) {
|
|
653
|
+
return {
|
|
654
|
+
ok: false,
|
|
655
|
+
degraded: true,
|
|
656
|
+
error: '收集诊断信息时出错:' + ((error && error.message) || String(error)),
|
|
657
|
+
version: VERSION,
|
|
658
|
+
platform: PLATFORM,
|
|
659
|
+
port: PORT,
|
|
660
|
+
script: SCRIPT,
|
|
661
|
+
runtimeDir: RUNTIME_DIR,
|
|
662
|
+
startError: startError,
|
|
663
|
+
journal: journal.slice().reverse(),
|
|
664
|
+
}
|
|
164
665
|
}
|
|
165
666
|
}
|
|
166
667
|
|
|
167
668
|
// 动态传输:Package-private RPC(仅动态运行时存在 harness)。
|
|
168
669
|
if (typeof harness !== 'undefined') {
|
|
169
|
-
harness.handle('list', () => (
|
|
170
|
-
harness.handle('status',
|
|
670
|
+
harness.handle('list', () => listPayload())
|
|
671
|
+
harness.handle('status', () => statusPayload())
|
|
171
672
|
harness.handle('open', async (args) => openFile(args && args.file))
|
|
172
673
|
harness.handle('resolve', async (args) => resolvePath(args && args.file))
|
|
674
|
+
harness.handle('diagnostics', () => safeDiagnostics())
|
|
675
|
+
harness.handle('restart', async () => restartService())
|
|
173
676
|
}
|
|
174
677
|
|
|
175
678
|
// 静态传输:HTTP 路由,供静态 client bundle(fetch)与直接浏览器访问使用。
|
|
@@ -191,36 +694,78 @@ return {
|
|
|
191
694
|
})
|
|
192
695
|
return out
|
|
193
696
|
}
|
|
697
|
+
// A handler that throws leaves the request hanging until the browser gives
|
|
698
|
+
// up, and `fetch` then rejects with a bare "Failed to fetch" — the exact
|
|
699
|
+
// dead end this plugin's users hit. Always answer, and answer with the
|
|
700
|
+
// reason plus the journal, so the panel can render something actionable.
|
|
701
|
+
const guard = (name, handler) => async (req, res) => {
|
|
702
|
+
try {
|
|
703
|
+
await handler(req, res)
|
|
704
|
+
} catch (error) {
|
|
705
|
+
const reason = (error && error.stack) || (error && error.message) || String(error)
|
|
706
|
+
note('error', '路由 ' + name + ' 处理失败', reason)
|
|
707
|
+
try {
|
|
708
|
+
sendJson(res, 500, {
|
|
709
|
+
ok: false,
|
|
710
|
+
error: '插件内部错误(' + name + '):' + ((error && error.message) || String(error)),
|
|
711
|
+
detail: reason,
|
|
712
|
+
diagnostics: safeDiagnostics(),
|
|
713
|
+
})
|
|
714
|
+
} catch (_) { /* the socket is already gone; the journal has the reason */ }
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
// Registered FIRST and deliberately dependency-free: if anything below
|
|
718
|
+
// fails to register, this route still answers, which is the only way to
|
|
719
|
+
// read the failure without access to the terminal that started DSH.
|
|
720
|
+
ctx.effect(() => webServer.register({
|
|
721
|
+
kind: 'exact',
|
|
722
|
+
path: '/html-workbench/diagnostics',
|
|
723
|
+
handler: guard('diagnostics', (req, res) => sendJson(res, 200, safeDiagnostics())),
|
|
724
|
+
}), 'html-workbench: diagnostics route')
|
|
194
725
|
ctx.effect(() => webServer.register({
|
|
195
726
|
kind: 'exact',
|
|
196
727
|
path: '/html-workbench/list',
|
|
197
|
-
handler: (req, res) => sendJson(res, 200,
|
|
728
|
+
handler: guard('list', (req, res) => sendJson(res, 200, listPayload())),
|
|
198
729
|
}), 'html-workbench: list route')
|
|
199
730
|
ctx.effect(() => webServer.register({
|
|
200
731
|
kind: 'exact',
|
|
201
732
|
path: '/html-workbench/status',
|
|
202
|
-
handler: async (req, res) =>
|
|
733
|
+
handler: guard('status', async (req, res) => sendJson(res, 200, await statusPayload())),
|
|
203
734
|
}), 'html-workbench: status route')
|
|
204
735
|
ctx.effect(() => webServer.register({
|
|
205
736
|
kind: 'exact',
|
|
206
737
|
path: '/html-workbench/open',
|
|
207
|
-
handler: async (req, res) => {
|
|
738
|
+
handler: guard('open', async (req, res) => {
|
|
208
739
|
const file = parseQuery(req).file
|
|
209
740
|
const out = await openFile(file)
|
|
210
741
|
sendJson(res, out.ok ? 200 : 400, out)
|
|
211
|
-
},
|
|
742
|
+
}),
|
|
212
743
|
}), 'html-workbench: open route')
|
|
213
744
|
ctx.effect(() => webServer.register({
|
|
214
745
|
kind: 'exact',
|
|
215
746
|
path: '/html-workbench/resolve',
|
|
216
|
-
handler: async (req, res) => {
|
|
747
|
+
handler: guard('resolve', async (req, res) => {
|
|
217
748
|
const out = await resolvePath(parseQuery(req).file)
|
|
218
749
|
sendJson(res, out.ok ? 200 : 400, out)
|
|
219
|
-
},
|
|
750
|
+
}),
|
|
220
751
|
}), 'html-workbench: resolve route')
|
|
752
|
+
ctx.effect(() => webServer.register({
|
|
753
|
+
kind: 'exact',
|
|
754
|
+
path: '/html-workbench/restart',
|
|
755
|
+
handler: guard('restart', async (req, res) => {
|
|
756
|
+
const out = await restartService()
|
|
757
|
+
sendJson(res, out.ok ? 200 : 500, out)
|
|
758
|
+
}),
|
|
759
|
+
}), 'html-workbench: restart route')
|
|
221
760
|
}
|
|
222
761
|
|
|
223
762
|
// 注册时主动拉起服务(异步,不阻塞 apply)。
|
|
224
|
-
|
|
763
|
+
// A rejection here must not escape into Cordis: `apply` has already wired up
|
|
764
|
+
// the transports, and losing them would take the diagnostics route down with
|
|
765
|
+
// it — leaving the panel with nothing but "Failed to fetch" again.
|
|
766
|
+
Promise.resolve().then(startService).catch((error) => {
|
|
767
|
+
startError = '启动本地服务时发生未预期的错误:' + ((error && error.message) || String(error))
|
|
768
|
+
note('error', startError, (error && error.stack) || null)
|
|
769
|
+
})
|
|
225
770
|
},
|
|
226
771
|
}
|