@vibe-x/dsh-html-workbench 0.2.0 → 0.3.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/assets/workbench.html +318 -3
- package/dsh.plugin.json +1 -1
- package/lib/client.js +442 -6
- package/package.json +1 -1
- package/scripts/workbench.py +865 -7
- package/src/client.js +422 -5
- package/src/host.js +327 -34
- package/src/index.js +13 -4
package/src/host.js
CHANGED
|
@@ -28,6 +28,23 @@ return {
|
|
|
28
28
|
const PORT = opts.port || 4317
|
|
29
29
|
const SCRIPT = opts.script || null
|
|
30
30
|
const EDITOR_ROOT = opts.editorRoot || null
|
|
31
|
+
// Static packages inject Node's platform-aware `tmpdir()` from src/index.js.
|
|
32
|
+
// Keep a dynamic-loader fallback too: TEMP/TMPDIR cover Windows/macOS/Linux;
|
|
33
|
+
// only the final fallback is POSIX because dynamic runners may not expose
|
|
34
|
+
// Node's `os` module. This folder contains only disposable cache/log data.
|
|
35
|
+
const tempRoot = typeof process !== 'undefined' && process.env
|
|
36
|
+
? (process.env.TEMP || process.env.TMP || process.env.TMPDIR)
|
|
37
|
+
: null
|
|
38
|
+
const RUNTIME_DIR = opts.runtimeDir || (tempRoot ? tempRoot.replace(/[\\/]$/, '') + '/html-workbench-dsh' : '/tmp/html-workbench-dsh')
|
|
39
|
+
|
|
40
|
+
// DSH runs plugin subprocesses under a workspace-write sandbox. `~/.cache`
|
|
41
|
+
// is deliberately outside that allow-list, so the service used to die before
|
|
42
|
+
// binding the port while trying to open its rotating log. Keep ALL runtime
|
|
43
|
+
// writes (service logs + downloaded GrapesJS vendor files) in /tmp instead.
|
|
44
|
+
// This is a cache by design: it is safe to delete and the service recreates
|
|
45
|
+
// it on the next start.
|
|
46
|
+
const LOG_DIR = RUNTIME_DIR + '/logs'
|
|
47
|
+
const VENDOR_CACHE = RUNTIME_DIR + '/vendor'
|
|
31
48
|
|
|
32
49
|
let assets = []
|
|
33
50
|
let seq = 0
|
|
@@ -38,28 +55,134 @@ return {
|
|
|
38
55
|
|
|
39
56
|
const shell = ctx.get('shell')
|
|
40
57
|
|
|
58
|
+
// ── Diagnostics journal ──────────────────────────────────────────────────
|
|
59
|
+
//
|
|
60
|
+
// A failed start used to surface as nothing but a red dot: the only signal
|
|
61
|
+
// was `running: false`, while the actual cause (a Python traceback, a busy
|
|
62
|
+
// port, a missing interpreter) stayed inside this Node process. Every
|
|
63
|
+
// subprocess outcome now lands in this ring buffer, which `status` returns
|
|
64
|
+
// and the panel renders, so the failure is readable where it is observed.
|
|
65
|
+
const JOURNAL_LIMIT = 40
|
|
66
|
+
let journal = []
|
|
67
|
+
|
|
68
|
+
const note = (level, message, detail) => {
|
|
69
|
+
journal.push({
|
|
70
|
+
at: Date.now(),
|
|
71
|
+
level: level,
|
|
72
|
+
message: String(message || ''),
|
|
73
|
+
detail: detail ? String(detail).slice(0, 4000) : null,
|
|
74
|
+
})
|
|
75
|
+
if (journal.length > JOURNAL_LIMIT) journal = journal.slice(-JOURNAL_LIMIT)
|
|
76
|
+
if (level === 'error') console.error('[html-workbench] ' + message, detail || '')
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// `CollectedOutput` carries more than text (truncation flags, spill paths);
|
|
80
|
+
// reduce it to the part a human reads, and say so when bytes were dropped.
|
|
81
|
+
const outputText = (collected) => {
|
|
82
|
+
if (!collected) return ''
|
|
83
|
+
const text = typeof collected === 'string' ? collected : (collected.text || '')
|
|
84
|
+
const trimmed = String(text).trim()
|
|
85
|
+
if (collected && collected.truncated) return trimmed + '\n…(输出被截断)'
|
|
86
|
+
return trimmed
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const describeRun = (result) => {
|
|
90
|
+
if (!result) return 'shell.run threw'
|
|
91
|
+
const parts = []
|
|
92
|
+
if (result.timedOut) parts.push('超时(' + result.timeoutMs + 'ms)')
|
|
93
|
+
if (result.aborted) parts.push('被取消')
|
|
94
|
+
if (result.signal) parts.push('信号 ' + result.signal)
|
|
95
|
+
parts.push('exit=' + String(result.exitCode))
|
|
96
|
+
const streams = [outputText(result.stderr), outputText(result.stdout)].filter(Boolean)
|
|
97
|
+
return parts.join(' ') + (streams.length ? '\n' + streams.join('\n') : '')
|
|
98
|
+
}
|
|
99
|
+
|
|
41
100
|
const runCli = async (args, timeoutMs) => {
|
|
101
|
+
if (!shell) { note('error', 'shell 服务不可用,无法执行 workbench.py'); return null }
|
|
102
|
+
if (!SCRIPT) { note('error', 'workbench.py 路径未配置'); return null }
|
|
103
|
+
const command = 'python3 "' + SCRIPT + '" ' + args
|
|
104
|
+
try {
|
|
105
|
+
const spec = shell.resolve({ command: command, timeoutMs: timeoutMs || 15000, stdoutMaxBytes: 64 * 1024 })
|
|
106
|
+
const result = await shell.run(spec)
|
|
107
|
+
// A non-zero exit is the interesting case and used to be discarded: the
|
|
108
|
+
// CLI prints a structured `{ok:false,error:{code,message}}` on stdout.
|
|
109
|
+
if (!result || result.exitCode !== 0) note('error', 'workbench.py ' + args.split(' ')[0] + ' 执行失败', describeRun(result))
|
|
110
|
+
return result
|
|
111
|
+
} catch (e) {
|
|
112
|
+
note('error', 'workbench.py ' + args.split(' ')[0] + ' 无法执行', (e && e.message) || String(e))
|
|
113
|
+
return null
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// `health` runs on a timer, so its failures are expected and must not flood
|
|
118
|
+
// the journal — poll quietly and let the caller decide what is worth noting.
|
|
119
|
+
const probeHealth = async () => {
|
|
42
120
|
if (!shell || !SCRIPT) return null
|
|
43
121
|
try {
|
|
44
122
|
const spec = shell.resolve({
|
|
45
|
-
command: 'python3 "' + SCRIPT + '" ' +
|
|
46
|
-
timeoutMs:
|
|
123
|
+
command: 'python3 "' + SCRIPT + '" health --port ' + PORT,
|
|
124
|
+
timeoutMs: 8000,
|
|
47
125
|
stdoutMaxBytes: 64 * 1024,
|
|
48
126
|
})
|
|
49
|
-
|
|
127
|
+
const result = await shell.run(spec)
|
|
128
|
+
if (!result || result.exitCode !== 0) return { ok: false, result: result }
|
|
129
|
+
try {
|
|
130
|
+
const payload = JSON.parse(outputText(result.stdout) || '')
|
|
131
|
+
return payload && payload.ok === true ? { ok: true, payload: payload } : { ok: false, result: result }
|
|
132
|
+
} catch (e) {
|
|
133
|
+
return { ok: false, result: result }
|
|
134
|
+
}
|
|
50
135
|
} catch (e) {
|
|
51
|
-
return
|
|
136
|
+
return { ok: false, error: (e && e.message) || String(e) }
|
|
52
137
|
}
|
|
53
138
|
}
|
|
54
139
|
|
|
55
140
|
const health = async () => {
|
|
56
|
-
const
|
|
57
|
-
|
|
141
|
+
const probe = await probeHealth()
|
|
142
|
+
return probe && probe.ok ? probe.payload : null
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// `workbench.py` reports failures as one structured JSON line on stderr. Turn
|
|
146
|
+
// the machine code into the sentence a person can act on; keep the raw line
|
|
147
|
+
// in the detail so nothing is lost when the code is one we do not know.
|
|
148
|
+
const CLI_HINTS = {
|
|
149
|
+
PORT_IN_USE: '端口已被占用。可能有别的进程(或旧版服务)占着它 —— 点「重启服务」会先退役占用者。',
|
|
150
|
+
ASSET_NOT_FOUND: '前端资源缺失,插件安装目录不完整(缺 assets/workbench.html)。',
|
|
151
|
+
PYTHON_TOO_OLD: '需要 Python 3.9 或更高版本。',
|
|
152
|
+
SERVER_OUTDATED: '端口上有旧版服务且无法自动停止,请手动结束该进程。',
|
|
153
|
+
FILE_NOT_FOUND: '找不到文件。',
|
|
154
|
+
VENDOR_DOWNLOAD_FAILED: '首次启动需要联网下载 GrapesJS,下载失败了。',
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const explainOutput = (text) => {
|
|
158
|
+
if (!text) return null
|
|
159
|
+
for (const line of String(text).split('\n')) {
|
|
160
|
+
const trimmed = line.trim()
|
|
161
|
+
if (!trimmed.startsWith('{')) continue
|
|
162
|
+
try {
|
|
163
|
+
const payload = JSON.parse(trimmed)
|
|
164
|
+
if (payload && payload.ok === false && payload.error) {
|
|
165
|
+
const hint = CLI_HINTS[payload.error]
|
|
166
|
+
return (hint || payload.message || payload.error) + '(' + payload.error + ')'
|
|
167
|
+
}
|
|
168
|
+
} catch (e) { /* not the JSON line; keep scanning */ }
|
|
169
|
+
}
|
|
170
|
+
return null
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Everything the started process wrote since the last read. DSH settles a
|
|
174
|
+
// spawn failure as `killed` with the error on stderr, so this is the only
|
|
175
|
+
// place a "python3: command not found" or a traceback can be observed.
|
|
176
|
+
const drain = (proc) => {
|
|
58
177
|
try {
|
|
59
|
-
const
|
|
60
|
-
|
|
178
|
+
const read = proc.readOutput()
|
|
179
|
+
if (!read) return ''
|
|
180
|
+
let text = String(read.delta || '').trim()
|
|
181
|
+
if (read.lossy) text += '\n…(部分输出丢失)'
|
|
182
|
+
if (read.stderrSpillPath) text += '\n完整 stderr: ' + read.stderrSpillPath
|
|
183
|
+
return text
|
|
61
184
|
} catch (e) {
|
|
62
|
-
return
|
|
185
|
+
return ''
|
|
63
186
|
}
|
|
64
187
|
}
|
|
65
188
|
|
|
@@ -67,34 +190,132 @@ return {
|
|
|
67
190
|
if (disposed) return null
|
|
68
191
|
if (serviceRunning) return health()
|
|
69
192
|
const existing = await health()
|
|
70
|
-
if (existing) {
|
|
71
|
-
|
|
72
|
-
|
|
193
|
+
if (existing) {
|
|
194
|
+
serviceRunning = true
|
|
195
|
+
startError = null
|
|
196
|
+
healthConfirmedAt = Date.now()
|
|
197
|
+
// Record the reuse too, so the journal always explains the CURRENT state
|
|
198
|
+
// rather than only the failures.
|
|
199
|
+
note('info', '复用已在运行的服务:版本 ' + (existing.version || '未知') + ',端口 ' + PORT)
|
|
200
|
+
return existing
|
|
201
|
+
}
|
|
202
|
+
if (!shell) { startError = 'shell 服务不可用(DSH 未提供 shell,插件无法拉起本地服务)'; note('error', startError); return null }
|
|
203
|
+
if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; note('error', startError); return null }
|
|
73
204
|
let proc = null
|
|
205
|
+
const command = 'python3 "' + SCRIPT + '" serve --port ' + PORT
|
|
206
|
+
+ ' --log-dir "' + LOG_DIR + '"'
|
|
207
|
+
+ ' --vendor-cache "' + VENDOR_CACHE + '"'
|
|
208
|
+
+ (EDITOR_ROOT ? ' --editor-root "' + EDITOR_ROOT + '"' : '')
|
|
74
209
|
try {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
command: 'python3 "' + SCRIPT + '" serve --port ' + PORT + editorRootArg,
|
|
78
|
-
stdoutMaxBytes: 64 * 1024,
|
|
79
|
-
})
|
|
80
|
-
proc = shell.start(spec)
|
|
210
|
+
note('info', '启动本地服务:端口 ' + PORT, command)
|
|
211
|
+
proc = shell.start(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
|
|
81
212
|
if (disposed) { try { proc.kill() } catch (e) {} return null }
|
|
82
213
|
ownedProcess = proc
|
|
214
|
+
let transcript = ''
|
|
83
215
|
for (let i = 0; i < 240; i += 1) {
|
|
84
|
-
const
|
|
85
|
-
if (
|
|
216
|
+
const chunk = drain(proc)
|
|
217
|
+
if (chunk) transcript += (transcript ? '\n' : '') + chunk
|
|
218
|
+
const probe = await probeHealth()
|
|
219
|
+
if (probe && probe.ok) {
|
|
220
|
+
serviceRunning = true
|
|
221
|
+
startError = null
|
|
222
|
+
healthConfirmedAt = Date.now()
|
|
223
|
+
note('info', '本地服务就绪:版本 ' + (probe.payload.version || '未知') + ',端口 ' + PORT)
|
|
224
|
+
return probe.payload
|
|
225
|
+
}
|
|
226
|
+
// Stop waiting the moment the process is gone. Polling health for the
|
|
227
|
+
// full 60s after `serve` already exited hides the reason and makes a
|
|
228
|
+
// fast, loud failure look like a hang.
|
|
229
|
+
if (proc.status !== 'running') {
|
|
230
|
+
const tail = drain(proc)
|
|
231
|
+
if (tail) transcript += (transcript ? '\n' : '') + tail
|
|
232
|
+
// Lead with the CLI's own diagnosis when it gave one — "端口已被占用"
|
|
233
|
+
// is actionable, "exit=1" is not.
|
|
234
|
+
const explained = explainOutput(transcript)
|
|
235
|
+
startError = explained || ('serve 进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
|
|
236
|
+
note('error', startError, transcript || '进程没有任何输出。命令:' + command)
|
|
237
|
+
ownedProcess = null
|
|
238
|
+
return null
|
|
239
|
+
}
|
|
86
240
|
await ctx.timeout(250)
|
|
87
241
|
}
|
|
88
|
-
startError = '
|
|
242
|
+
startError = '服务在 60 秒内没有就绪(端口 ' + PORT + ' 未通过健康检查)'
|
|
243
|
+
note('error', startError, transcript || '进程仍在运行但健康检查一直失败。命令:' + command)
|
|
89
244
|
return null
|
|
90
245
|
} catch (e) {
|
|
91
|
-
startError =
|
|
246
|
+
startError = 'serve 进程启动失败:' + ((e && e.message) || String(e))
|
|
247
|
+
note('error', startError, command)
|
|
92
248
|
if (proc) { try { proc.kill() } catch (e2) {} }
|
|
93
249
|
ownedProcess = null
|
|
94
250
|
return null
|
|
95
251
|
}
|
|
96
252
|
}
|
|
97
253
|
|
|
254
|
+
// A service that dies AFTER a successful start used to leave the plugin
|
|
255
|
+
// frozen: nothing ever re-checked the process, so the panel kept showing a
|
|
256
|
+
// stale dot while every open failed with no explanation. Reading
|
|
257
|
+
// `ownedProcess.status` costs nothing (no subprocess), so the panel's
|
|
258
|
+
// existing poll can supervise the process for free.
|
|
259
|
+
const superviseOwnedProcess = () => {
|
|
260
|
+
const proc = ownedProcess
|
|
261
|
+
if (!proc || proc.status === 'running') return
|
|
262
|
+
const tail = drain(proc)
|
|
263
|
+
const explained = explainOutput(tail)
|
|
264
|
+
ownedProcess = null
|
|
265
|
+
serviceRunning = false
|
|
266
|
+
startError = explained || ('服务进程已退出(exit=' + String(proc.exitCode) + (proc.signal ? ', 信号 ' + proc.signal : '') + ')')
|
|
267
|
+
note('error', '服务进程意外退出', tail || '进程没有留下输出。可用「重启服务」重新拉起。')
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// A REUSED service has no process handle, so its death is invisible to the
|
|
271
|
+
// check above. Revalidate with a real health call — throttled, because this
|
|
272
|
+
// spawns a subprocess and the panel polls every few seconds. The window is
|
|
273
|
+
// seeded when the service is confirmed up, so a freshly verified service is
|
|
274
|
+
// not immediately re-checked.
|
|
275
|
+
const REVALIDATE_MS = 10000
|
|
276
|
+
let healthConfirmedAt = 0
|
|
277
|
+
let revalidating = false
|
|
278
|
+
|
|
279
|
+
const revalidateService = () => {
|
|
280
|
+
if (revalidating || ownedProcess || !serviceRunning) return
|
|
281
|
+
if (Date.now() - healthConfirmedAt < REVALIDATE_MS) return
|
|
282
|
+
revalidating = true
|
|
283
|
+
void (async () => {
|
|
284
|
+
try {
|
|
285
|
+
if (await health()) { healthConfirmedAt = Date.now(); return }
|
|
286
|
+
serviceRunning = false
|
|
287
|
+
startError = '之前复用的服务已经不再响应(端口 ' + PORT + ')。'
|
|
288
|
+
note('error', '复用的服务失去响应', '健康检查失败。可用「重启服务」重新拉起。')
|
|
289
|
+
} finally {
|
|
290
|
+
revalidating = false
|
|
291
|
+
}
|
|
292
|
+
})()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// Everything the panel needs to explain a red dot without opening a terminal.
|
|
296
|
+
// Reaping first means a process that died since the last call is reported as
|
|
297
|
+
// dead here, instead of leaving a stale "running" dot behind.
|
|
298
|
+
const diagnostics = () => {
|
|
299
|
+
superviseOwnedProcess()
|
|
300
|
+
revalidateService()
|
|
301
|
+
return {
|
|
302
|
+
ok: true,
|
|
303
|
+
running: serviceRunning,
|
|
304
|
+
owned: !!ownedProcess,
|
|
305
|
+
port: PORT,
|
|
306
|
+
script: SCRIPT,
|
|
307
|
+
editorRoot: EDITOR_ROOT,
|
|
308
|
+
runtimeDir: RUNTIME_DIR,
|
|
309
|
+
logDir: LOG_DIR,
|
|
310
|
+
vendorCache: VENDOR_CACHE,
|
|
311
|
+
hasShell: !!shell,
|
|
312
|
+
processStatus: ownedProcess ? ownedProcess.status : null,
|
|
313
|
+
exitCode: ownedProcess ? ownedProcess.exitCode : null,
|
|
314
|
+
startError: startError,
|
|
315
|
+
journal: journal.slice().reverse(),
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
98
319
|
// 生命周期副作用:卸载时停掉本插件自己启动的服务进程。
|
|
99
320
|
ctx.effect(() => () => {
|
|
100
321
|
disposed = true
|
|
@@ -133,18 +354,59 @@ return {
|
|
|
133
354
|
const snapshot = () => assets.slice().sort((a, b) => b.seq - a.seq)
|
|
134
355
|
|
|
135
356
|
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 路径未配置' }
|
|
357
|
+
if (typeof file !== 'string' || !file) return { ok: false, error: '请填写文件路径' }
|
|
358
|
+
if (!/\.html?$/i.test(file)) return { ok: false, error: '只支持 .html / .htm 文件' }
|
|
359
|
+
if (!SCRIPT) return { ok: false, error: startError || 'workbench.py 路径未配置', diagnostics: diagnostics() }
|
|
139
360
|
const wasUp = await health()
|
|
140
361
|
if (!wasUp) await startService()
|
|
141
362
|
const h = await health()
|
|
142
|
-
|
|
363
|
+
// Carry the diagnostics WITH the failure: the panel can then show the real
|
|
364
|
+
// cause instead of a generic "unavailable" that sends the user hunting.
|
|
365
|
+
if (!h) return { ok: false, error: startError || '本地服务未就绪', diagnostics: diagnostics() }
|
|
143
366
|
return {
|
|
144
367
|
ok: true,
|
|
145
368
|
url: 'http://127.0.0.1:' + PORT + '/?file=' + encodeURIComponent(file),
|
|
146
369
|
reused: !!wasUp,
|
|
147
370
|
port: PORT,
|
|
371
|
+
version: h.version || null,
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const restartService = async () => {
|
|
376
|
+
const proc = ownedProcess
|
|
377
|
+
ownedProcess = null
|
|
378
|
+
serviceRunning = false
|
|
379
|
+
startError = null
|
|
380
|
+
if (proc) { try { proc.kill() } catch (e) {} }
|
|
381
|
+
note('info', '手动重启本地服务')
|
|
382
|
+
// Killing our own handle is not enough when the port is held by a process
|
|
383
|
+
// we do not own (a leftover from a previous DSH run, or an outdated
|
|
384
|
+
// version) — the spawn would then die with PORT_IN_USE. Clear the port
|
|
385
|
+
// first; `stop` is a no-op when nothing is listening.
|
|
386
|
+
const stopped = await runCli('stop --port ' + PORT, 20000)
|
|
387
|
+
if (stopped && stopped.exitCode !== 0) {
|
|
388
|
+
return { ok: false, error: '端口 ' + PORT + ' 上的旧进程无法停止,请手动结束后重试。', diagnostics: diagnostics() }
|
|
389
|
+
}
|
|
390
|
+
const h = await startService()
|
|
391
|
+
return h ? { ok: true, info: h, diagnostics: diagnostics() } : { ok: false, error: startError || '重启失败', diagnostics: diagnostics() }
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// Browser address bars yield file:///… URLs while the CLI expects a native
|
|
395
|
+
// path. Normalize only for the local existence probe; the Python service
|
|
396
|
+
// repeats this normalization authoritatively for every request.
|
|
397
|
+
const normalizeFileReference = (value) => {
|
|
398
|
+
const raw = String(value || '').trim()
|
|
399
|
+
if (!/^file:/i.test(raw)) return raw
|
|
400
|
+
try {
|
|
401
|
+
const url = new URL(raw)
|
|
402
|
+
if (url.protocol !== 'file:' || url.search || url.hash) return raw
|
|
403
|
+
let path = decodeURIComponent(url.pathname)
|
|
404
|
+
const isWindows = typeof process !== 'undefined' && process.platform === 'win32'
|
|
405
|
+
if (isWindows && /^[\\/][A-Za-z]:\//.test(path)) path = path.slice(1)
|
|
406
|
+
if (isWindows && url.hostname && url.hostname !== 'localhost') path = '//' + url.hostname + path
|
|
407
|
+
return path
|
|
408
|
+
} catch (e) {
|
|
409
|
+
return raw
|
|
148
410
|
}
|
|
149
411
|
}
|
|
150
412
|
|
|
@@ -152,24 +414,33 @@ return {
|
|
|
152
414
|
if (typeof file !== 'string') return { ok: false, error: 'file is required' }
|
|
153
415
|
const trimmed = file.trim()
|
|
154
416
|
if (!trimmed) return { ok: true, empty: true, isHtml: false, exists: false }
|
|
155
|
-
|
|
417
|
+
const normalized = normalizeFileReference(trimmed)
|
|
418
|
+
if (!/\.html?$/i.test(normalized)) return { ok: true, isHtml: false, exists: false }
|
|
156
419
|
if (!shell) return { ok: true, isHtml: true, exists: null }
|
|
157
|
-
const arg = JSON.stringify(
|
|
420
|
+
const arg = JSON.stringify(normalized)
|
|
158
421
|
const spec = shell.resolve({ command: 'python3 -c "import os,sys; sys.exit(0 if os.path.isfile(sys.argv[1]) else 1)" ' + arg, timeoutMs: 5000, stdoutMaxBytes: 1024 })
|
|
159
422
|
try {
|
|
160
423
|
const r = await shell.run(spec)
|
|
161
|
-
return { ok: true, isHtml: true, exists: !!(r && r.exitCode === 0) }
|
|
424
|
+
return { ok: true, isHtml: true, exists: !!(r && r.exitCode === 0), normalized: normalized }
|
|
162
425
|
} catch (e) {
|
|
163
|
-
return { ok: true, isHtml: true, exists: null }
|
|
426
|
+
return { ok: true, isHtml: true, exists: null, normalized: normalized }
|
|
164
427
|
}
|
|
165
428
|
}
|
|
166
429
|
|
|
430
|
+
const listPayload = () => {
|
|
431
|
+
const base = diagnostics()
|
|
432
|
+
base.assets = snapshot()
|
|
433
|
+
return base
|
|
434
|
+
}
|
|
435
|
+
|
|
167
436
|
// 动态传输:Package-private RPC(仅动态运行时存在 harness)。
|
|
168
437
|
if (typeof harness !== 'undefined') {
|
|
169
|
-
harness.handle('list', () => (
|
|
170
|
-
harness.handle('status', async () => { const h = await health();
|
|
438
|
+
harness.handle('list', () => listPayload())
|
|
439
|
+
harness.handle('status', async () => { const h = await health(); const out = diagnostics(); out.ok = !!h; out.running = !!h; out.info = h || null; return out })
|
|
171
440
|
harness.handle('open', async (args) => openFile(args && args.file))
|
|
172
441
|
harness.handle('resolve', async (args) => resolvePath(args && args.file))
|
|
442
|
+
harness.handle('diagnostics', () => diagnostics())
|
|
443
|
+
harness.handle('restart', async () => restartService())
|
|
173
444
|
}
|
|
174
445
|
|
|
175
446
|
// 静态传输:HTTP 路由,供静态 client bundle(fetch)与直接浏览器访问使用。
|
|
@@ -194,12 +465,19 @@ return {
|
|
|
194
465
|
ctx.effect(() => webServer.register({
|
|
195
466
|
kind: 'exact',
|
|
196
467
|
path: '/html-workbench/list',
|
|
197
|
-
handler: (req, res) => sendJson(res, 200,
|
|
468
|
+
handler: (req, res) => sendJson(res, 200, listPayload()),
|
|
198
469
|
}), 'html-workbench: list route')
|
|
199
470
|
ctx.effect(() => webServer.register({
|
|
200
471
|
kind: 'exact',
|
|
201
472
|
path: '/html-workbench/status',
|
|
202
|
-
handler: async (req, res) => {
|
|
473
|
+
handler: async (req, res) => {
|
|
474
|
+
const h = await health()
|
|
475
|
+
const out = diagnostics()
|
|
476
|
+
out.ok = !!h
|
|
477
|
+
out.running = !!h
|
|
478
|
+
out.info = h || null
|
|
479
|
+
sendJson(res, 200, out)
|
|
480
|
+
},
|
|
203
481
|
}), 'html-workbench: status route')
|
|
204
482
|
ctx.effect(() => webServer.register({
|
|
205
483
|
kind: 'exact',
|
|
@@ -218,6 +496,21 @@ return {
|
|
|
218
496
|
sendJson(res, out.ok ? 200 : 400, out)
|
|
219
497
|
},
|
|
220
498
|
}), 'html-workbench: resolve route')
|
|
499
|
+
// Readable straight from a browser tab — the fastest path from "red dot"
|
|
500
|
+
// to "here is the traceback" without any UI in the way.
|
|
501
|
+
ctx.effect(() => webServer.register({
|
|
502
|
+
kind: 'exact',
|
|
503
|
+
path: '/html-workbench/diagnostics',
|
|
504
|
+
handler: (req, res) => sendJson(res, 200, diagnostics()),
|
|
505
|
+
}), 'html-workbench: diagnostics route')
|
|
506
|
+
ctx.effect(() => webServer.register({
|
|
507
|
+
kind: 'exact',
|
|
508
|
+
path: '/html-workbench/restart',
|
|
509
|
+
handler: async (req, res) => {
|
|
510
|
+
const out = await restartService()
|
|
511
|
+
sendJson(res, out.ok ? 200 : 500, out)
|
|
512
|
+
},
|
|
513
|
+
}), 'html-workbench: restart route')
|
|
221
514
|
}
|
|
222
515
|
|
|
223
516
|
// 注册时主动拉起服务(异步,不阻塞 apply)。
|
package/src/index.js
CHANGED
|
@@ -9,11 +9,16 @@
|
|
|
9
9
|
* specific machine — and handed to the shared body as `config.script`.
|
|
10
10
|
*/
|
|
11
11
|
import { readFileSync } from 'node:fs'
|
|
12
|
-
import {
|
|
12
|
+
import { tmpdir } from 'node:os'
|
|
13
|
+
import { dirname, resolve, join } from 'node:path'
|
|
13
14
|
import { fileURLToPath } from 'node:url'
|
|
14
15
|
|
|
15
16
|
const here = dirname(fileURLToPath(import.meta.url))
|
|
16
17
|
const scriptPath = resolve(here, '..', 'scripts', 'workbench.py')
|
|
18
|
+
// Runtime state must be writable from DSH's workspace-write sandbox. `tmpdir()`
|
|
19
|
+
// is platform-aware: /tmp on macOS/Linux, %TEMP% on Windows. The content is only
|
|
20
|
+
// logs and a re-downloadable GrapesJS cache, so it is deliberately ephemeral.
|
|
21
|
+
const runtimeDir = join(tmpdir(), 'html-workbench-dsh')
|
|
17
22
|
|
|
18
23
|
// The body is a `return { ... }` statement; wrap it in a function and call it.
|
|
19
24
|
// eslint-disable-next-line no-new-func
|
|
@@ -22,6 +27,10 @@ const plugin = makePlugin()
|
|
|
22
27
|
|
|
23
28
|
export const name = 'html-workbench'
|
|
24
29
|
export const inject = plugin.inject ?? []
|
|
25
|
-
// Inject
|
|
26
|
-
//
|
|
27
|
-
export const apply = (ctx, config) => plugin.apply(ctx, {
|
|
30
|
+
// Inject paths resolved by Node rather than assuming POSIX separators. A
|
|
31
|
+
// deployment-provided config may still override every value.
|
|
32
|
+
export const apply = (ctx, config) => plugin.apply(ctx, {
|
|
33
|
+
script: scriptPath,
|
|
34
|
+
runtimeDir,
|
|
35
|
+
...(config || {}),
|
|
36
|
+
})
|