@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/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 + '" ' + args,
46
- timeoutMs: timeoutMs || 15000,
123
+ command: 'python3 "' + SCRIPT + '" health --port ' + PORT,
124
+ timeoutMs: 8000,
47
125
  stdoutMaxBytes: 64 * 1024,
48
126
  })
49
- return await shell.run(spec)
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 null
136
+ return { ok: false, error: (e && e.message) || String(e) }
52
137
  }
53
138
  }
54
139
 
55
140
  const health = async () => {
56
- const r = await runCli('health --port ' + PORT, 8000)
57
- if (!r || r.exitCode !== 0) return null
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 p = JSON.parse((r.stdout && r.stdout.text) || '')
60
- return p && p.ok === true ? p : null
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 null
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) { serviceRunning = true; startError = null; return existing }
71
- if (!shell) { startError = 'shell service unavailable'; return null }
72
- if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; return null }
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
- const editorRootArg = EDITOR_ROOT ? ' --editor-root "' + EDITOR_ROOT + '"' : ''
76
- const spec = shell.resolve({
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 h = await health()
85
- if (h) { serviceRunning = true; startError = null; return h }
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 = 'service did not become healthy'
242
+ startError = '服务在 60 秒内没有就绪(端口 ' + PORT + ' 未通过健康检查)'
243
+ note('error', startError, transcript || '进程仍在运行但健康检查一直失败。命令:' + command)
89
244
  return null
90
245
  } catch (e) {
91
- startError = e && e.message ? String(e.message) : String(e)
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: 'file is required' }
137
- if (!/\.html?$/i.test(file)) return { ok: false, error: 'only .html files are supported' }
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
- if (!h) return { ok: false, error: startError || 'workbench service unavailable' }
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
- if (!/\.html?$/i.test(trimmed)) return { ok: true, isHtml: false, exists: false }
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(trimmed)
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', () => ({ ok: true, running: serviceRunning, owned: !!ownedProcess, port: PORT, assets: snapshot() }))
170
- harness.handle('status', async () => { const h = await health(); return { ok: !!h, running: !!h, owned: !!ownedProcess, port: PORT, info: h || null } })
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, { ok: true, running: serviceRunning, owned: !!ownedProcess, port: PORT, assets: snapshot() }),
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) => { const h = await health(); sendJson(res, 200, { ok: !!h, running: !!h, owned: !!ownedProcess, port: PORT, info: h || null }) },
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 { dirname, resolve } from 'node:path'
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 the portably-resolved script path; a deployment-provided config may
26
- // still override `script` / `editorRoot` / `port`.
27
- export const apply = (ctx, config) => plugin.apply(ctx, { script: scriptPath, ...(config || {}) })
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
+ })