@vibe-x/dsh-html-workbench 0.3.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 CHANGED
@@ -28,15 +28,29 @@ dsh-plugin/
28
28
 
29
29
  `host.js` 默认用本机仓库内的 `skill/html-workbench/scripts/workbench.py` 拉起服务。
30
30
 
31
- ### 静态装载(后续发布 NPM 时再接入)
31
+ ### 静态装载(已发布 NPM)
32
32
 
33
- `cordis.patch.yml` + `package.json` 的 `dsh.bundle` 已就绪;Client 半的静态 bundle(`window.__ModuleLoader__.load`)尚未接入,动态 Client 已可用。
33
+ `cordis.patch.yml` + `package.json` 的 `dsh.bundle` 把插件作为一层 profile 装载;Client 半由 `npm run build` 打成 `lib/client.js`(`window.__ModuleLoader__.load`)。`prepublishOnly` 会自动构建,所以 `lib/`、`scripts/`、`assets/` 是构建产物、不入库。
34
34
 
35
35
  ## 服务生命周期(副作用管理)
36
36
 
37
37
  - **注册时**:`apply` 里异步 `startService()`,先 `health` 探测 `4317`;已在运行则复用(不认领),否则 `shell.start` 后台拉起 `workbench.py serve`。
38
38
  - **卸载时**:`ctx.effect` 的 disposer `kill()` 掉本插件自己启动的那个进程;复用的服务不误杀。
39
39
 
40
+ ## 运行时目录(Windows 沙箱)
41
+
42
+ 服务日志与 GrapesJS 缓存写在 `<工作区>/.html-workbench/`(常量 `RUNTIME_DIR_NAME`,Host 与 `workbench.py` 必须一致)。
43
+
44
+ 这些路径是**请求,不是保证**:服务是 DSH 的沙箱子进程,只能写会话工作区和沙箱交给它的私有临时目录,**系统 `%TEMP%` 根一律拒绝**——即使父进程能建出那个文件夹,子进程也写不进去。因此:
45
+
46
+ - `workbench.py` 在**自身进程内**用一次真实创建探测候选目录,被拒就立刻换下一个(工作区 → 私有临时目录),并把**实际使用**的目录写进 `/api/health` 的 `logDir` / `vendorCache`;
47
+ - Host 采纳上报值(`adoptResolvedPaths`),于是面板显示的是真实路径,而不是它请求过的路径;复用别的进程启动的服务时同样如此;
48
+ - 探测**不允许重试**:Windows 上 `tempfile.mkstemp` 遇到 `PermissionError` 会用只看 DACL 的 `os.access` 复检、再换随机名重试 10 000 次,会把"1 秒报错"变成"卡住数分钟并烧 CPU"。故改用 `make_temp_file()` 的单次 `O_EXCL` 打开。
49
+
50
+ 日志目录**全部候选都不可写**时,服务挂 `NullHandler` 照常启动:丢一份诊断日志可以接受,端口不 bind 不可以。
51
+
52
+ 工作区通常就是**用户自己的仓库**,所以建出目录的那一半会在运行时根写入内容为 `*` 的 `.gitignore`(`self_ignore()` / `probeWritableDir()`),让整棵树连同该文件一起对 git 隐形——本仓库的 `.gitignore` 只管得住自己的 checkout。判定依据是 `RUNTIME_DIR_NAME` 而非传入目录,否则 `--log-dir .` 会把用户的源码树整个变成被忽略的。
53
+
40
54
  ## RPC 接口(Client → Host)
41
55
 
42
56
  | method | 入参 | 返回 |
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "@vibe-x/dsh-html-workbench",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "main": "./src/index.js",
5
5
  "description": "在 DeepSeek Harness 右侧栏预览并可视化编辑 agent 生成的 HTML:复用/拉起本地 html-workbench 服务,在 iframe 中内嵌 GrapesJS 编辑器,插件卸载时停掉自己启动的服务。",
6
6
  "engines": {
package/lib/client.js CHANGED
@@ -49,6 +49,20 @@ window.__ModuleLoader__.load({
49
49
  } catch (error) {
50
50
  throw new Error('本地服务返回了无法解析的 JSON(HTTP ' + response.status + ')。');
51
51
  }
52
+ }, (error) => {
53
+ // fetch REJECTS (rather than resolving with a status) when the request
54
+ // never reached DSH's web server. Left alone, the browser's bare
55
+ // "Failed to fetch" surfaced in the panel — the single least actionable
56
+ // string in this plugin, and the one users reported. It also means the
57
+ // host half never ran, so none of its diagnostics exist yet: the only
58
+ // useful thing to report is WHICH request died and what to check.
59
+ const reason = (error && error.message) || String(error);
60
+ throw new Error(
61
+ '无法连接 HTML Workbench 后台(' + method + ')。请求 ' + url + ' 没有到达 DSH。'
62
+ + '通常说明插件的 Node 半没有加载成功,或 DSH 尚未重启以注册该路由。'
63
+ + '请完全重启 DSH;若仍失败,请在启动 DSH 的终端里查找 [html-workbench] 开头的报错。'
64
+ + '(底层错误:' + reason + ')'
65
+ );
52
66
  });
53
67
  },
54
68
  };
@@ -154,6 +168,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
154
168
  .hwb-diag-row + .hwb-diag-row { border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l2) 50%, transparent); }
155
169
  .hwb-diag-time { color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10.5px; padding-top: 1px; }
156
170
  .hwb-diag-msg { min-width: 0; }
171
+ .hwb-diag-count { margin-left: 6px; padding: 0 5px; border-radius: 999px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10px; }
157
172
  .hwb-diag-row[data-level="error"] .hwb-diag-msg { color: var(--dsw-alias-state-error-primary); }
158
173
  .hwb-diag-detail { display: block; margin-top: 3px; padding: 6px 8px; border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-secondary); font-family: var(--hwb-mono); font-size: 10.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; max-height: 180px; overflow-y: auto; }
159
174
  .hwb-diag-empty { padding: 16px 14px; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }
@@ -676,8 +691,12 @@ header:has([data-slot="conversation.session.header.utilities"]) {
676
691
  if (!res.isHtml) store.set({ resolveState: 'invalid' })
677
692
  else if (res.exists === true) store.set({ resolveState: 'exists' })
678
693
  else if (res.exists === false) store.set({ resolveState: 'missing' })
694
+ // `exists: null` means the CHECK could not run (no interpreter, no
695
+ // shell). Staying silent made the field look unresponsive, so promote
696
+ // the host's reason to the banner instead of dropping it.
697
+ else if (res.error) store.set({ resolveState: 'idle', error: res.error })
679
698
  else store.set({ resolveState: 'idle' })
680
- }).catch(() => store.set({ resolveState: 'idle' }))
699
+ }).catch((e) => store.set({ resolveState: 'idle', error: String(e && e.message ? e.message : e) }))
681
700
  }, 300)
682
701
  }
683
702
 
@@ -730,6 +749,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
730
749
  const s = useStore()
731
750
  const d = s.diag
732
751
  const journal = (d && d.journal) || []
752
+ const [copied, setCopied] = React.useState(false)
733
753
 
734
754
  const fact = (key, value) => value == null || value === ''
735
755
  ? null
@@ -738,10 +758,53 @@ header:has([data-slot="conversation.session.header.utilities"]) {
738
758
  React.createElement('span', { className: 'hwb-diag-val', key: key + '-v' }, String(value)),
739
759
  ]
740
760
 
761
+ // A bug report is only useful if it carries the journal. Asking a user to
762
+ // screenshot a scrolling panel loses exactly the detail lines that matter,
763
+ // so hand them one paste-ready block instead — plain text, not JSON, so it
764
+ // survives being dropped into a chat window.
765
+ const copyReport = () => {
766
+ const lines = ['# HTML Workbench 诊断报告']
767
+ if (d) {
768
+ lines.push('插件版本: ' + (d.version || '未知(可能是动态热加载)'))
769
+ lines.push('平台: ' + (d.platform || '未知'))
770
+ lines.push('状态: ' + (d.running ? '运行中' : '未就绪'))
771
+ lines.push('端口: ' + d.port)
772
+ lines.push('进程: ' + (d.processStatus || '未由本插件启动')
773
+ + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')'))
774
+ lines.push('Python: ' + (d.pythonCommand || (d.pythonCandidates || []).join(' → ') || '未解析'))
775
+ lines.push('脚本: ' + (d.script || '未配置'))
776
+ lines.push('运行目录: ' + (d.runtimeDir || '未知'))
777
+ if (d.startError) lines.push('失败原因: ' + d.startError)
778
+ } else {
779
+ lines.push('(后台未返回诊断信息,通常说明请求根本没有到达 DSH)')
780
+ }
781
+ if (s.error) lines.push('界面报错: ' + s.error)
782
+ lines.push('', '## 日志(最新在前)')
783
+ if (journal.length) {
784
+ journal.forEach((entry) => {
785
+ lines.push('[' + clockTime(entry.at) + '] ' + entry.level + ': ' + entry.message)
786
+ if (entry.detail) lines.push(' ' + String(entry.detail).replace(/\n/g, '\n '))
787
+ })
788
+ } else {
789
+ lines.push('(无)')
790
+ }
791
+ const text = lines.join('\n')
792
+ const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1600) }
793
+ if (navigator.clipboard && navigator.clipboard.writeText) {
794
+ navigator.clipboard.writeText(text).then(done, () => store.set({ error: '复制失败,请手动访问 /html-workbench/diagnostics 获取原始信息。' }))
795
+ } else {
796
+ store.set({ error: '当前环境不支持剪贴板,请访问 /html-workbench/diagnostics 获取原始信息。' })
797
+ }
798
+ }
799
+
741
800
  return React.createElement('div', { className: 'hwb-diag' },
742
801
  React.createElement('div', { className: 'hwb-diag-head' },
743
802
  React.createElement('span', { className: 'hwb-diag-title' }, '服务诊断'),
744
803
  React.createElement('span', { className: 'hwb-spacer' }),
804
+ React.createElement('button', {
805
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
806
+ onClick: copyReport, title: '复制完整诊断信息,便于反馈问题',
807
+ }, copied ? '已复制' : '复制报告'),
745
808
  React.createElement('button', {
746
809
  type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
747
810
  disabled: s.restarting, onClick: restartService,
@@ -756,8 +819,11 @@ header:has([data-slot="conversation.session.header.utilities"]) {
756
819
  fact('状态', d.running ? '运行中' : (d.processStatus === 'running' ? '启动中' : '未就绪')),
757
820
  fact('端口', d.port),
758
821
  fact('进程', d.processStatus ? d.processStatus + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')') : '未由本插件启动'),
822
+ fact('Python', d.pythonCommand || ((d.pythonCandidates || []).join(' → ') || null)),
823
+ fact('运行目录', d.runtimeDir),
759
824
  fact('失败原因', d.startError),
760
825
  fact('脚本', d.script),
826
+ fact('版本', d.version ? d.version + (d.platform ? ' · ' + d.platform : '') : d.platform),
761
827
  d.hasShell === false ? fact('shell', '不可用') : null,
762
828
  )
763
829
  : null,
@@ -769,6 +835,11 @@ header:has([data-slot="conversation.session.header.utilities"]) {
769
835
  React.createElement('span', { className: 'hwb-diag-time' }, clockTime(entry.at)),
770
836
  React.createElement('span', { className: 'hwb-diag-msg' },
771
837
  entry.message,
838
+ // Folded duplicates would otherwise read as a single occurrence,
839
+ // hiding that the user retried and hit the same wall every time.
840
+ entry.repeated > 1
841
+ ? React.createElement('span', { className: 'hwb-diag-count' }, '×' + entry.repeated)
842
+ : null,
772
843
  entry.detail ? React.createElement('span', { className: 'hwb-diag-detail' }, entry.detail) : null,
773
844
  ),
774
845
  ))
@@ -776,7 +847,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
776
847
  ),
777
848
  React.createElement('div', { className: 'hwb-diag-hint' },
778
849
  '服务自身日志:',
779
- React.createElement('code', null, (d && d.logDir) || '/tmp/html-workbench-dsh/logs/'),
850
+ React.createElement('code', null, (d && d.logDir) || '<工作区>/.html-workbench/logs/'),
780
851
  ';也可直接访问 ',
781
852
  React.createElement('code', null, '/html-workbench/diagnostics'),
782
853
  ' 查看原始 JSON。',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vibe-x/dsh-html-workbench",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "DeepSeek Harness 插件:在右侧栏预览并可视化编辑 agent 生成的 HTML(复用/拉起本地 html-workbench 服务)。",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -34,7 +34,7 @@ from typing import Any
34
34
 
35
35
 
36
36
  SERVICE_NAME = "html-workbench"
37
- SERVICE_VERSION = "2.1.0"
37
+ SERVICE_VERSION = "2.2.0"
38
38
 
39
39
  DEFAULT_PORT = 4317
40
40
  # Capability names advertised by /api/health. `command_open` refuses to reuse a
@@ -126,6 +126,135 @@ def default_log_dir() -> Path:
126
126
  return base / "html-workbench" / "logs"
127
127
 
128
128
 
129
+ # Name of the runtime-state folder this process keeps beside its working
130
+ # directory. The DSH plugin resolves the same name host-side, so both halves
131
+ # agree on one location instead of scattering `.html-workbench` and
132
+ # `html-workbench-dsh` through the user's workspace.
133
+ RUNTIME_DIR_NAME = ".html-workbench"
134
+
135
+
136
+ def runtime_directory() -> Path:
137
+ """Return this process's runtime-state root: `<cwd>/<RUNTIME_DIR_NAME>`.
138
+
139
+ Under DSH the working directory is the session workspace, which is one of the
140
+ only two trees the subprocess sandbox grants write access to — the other is a
141
+ private temp directory it hands the child through TEMP/TMP. Everywhere else
142
+ the working directory is simply a sensible, self-cleaning location. Logs and
143
+ the GrapesJS cache are disposable, so either is acceptable; what is NOT
144
+ acceptable is a path the sandbox refuses (see `pick_writable_dir`).
145
+ """
146
+ return Path.cwd() / RUNTIME_DIR_NAME
147
+
148
+
149
+ def make_temp_file(directory: Path, prefix: str, suffix: str = "") -> tuple[int, Path]:
150
+ """Create a uniquely named temporary file in `directory`; return (fd, path).
151
+
152
+ Deliberately NOT `tempfile.mkstemp`. On Windows CPython's `_mkstemp_inner`
153
+ treats a `PermissionError` as "that name is already taken": it re-checks the
154
+ directory with `os.access(dir, os.W_OK)` and then retries with a NEW random
155
+ name, up to TMP_MAX (10 000) times. A real DACL always looks writable to
156
+ `os.access`, which cannot see the restricted token DSH's sandbox runs the
157
+ child under — so a directory the sandbox refuses makes `mkstemp` spin for
158
+ minutes (one denied `os.open` per iteration, ~4 ms each) instead of failing
159
+ in milliseconds. A single O_EXCL open turns that into an immediate error the
160
+ caller can catch and recover from.
161
+ """
162
+ for attempt in range(64):
163
+ candidate = Path(directory) / f"{prefix}{os.getpid()}-{attempt}-{os.urandom(6).hex()}{suffix}"
164
+ try:
165
+ descriptor = os.open(
166
+ candidate,
167
+ os.O_CREAT | os.O_EXCL | os.O_WRONLY | getattr(os, "O_BINARY", 0),
168
+ 0o600,
169
+ )
170
+ except FileExistsError:
171
+ continue
172
+ return descriptor, candidate
173
+ raise FileExistsError(f"无法在 {directory} 创建临时文件:连续的文件名冲突")
174
+
175
+
176
+ def self_ignore(directory: Path) -> None:
177
+ """Make a runtime directory invisible to git, best effort.
178
+
179
+ The runtime root usually lands inside the session workspace, which for most
180
+ users is a repository: without this, every start dirties `git status` with a
181
+ folder holding nothing but logs and a re-downloadable cache, and the noise
182
+ lands in the user's own project rather than ours. A `.gitignore` containing
183
+ `*` excludes the tree including that file, so nothing has to be added to the
184
+ repository's own ignore rules.
185
+
186
+ Only the runtime root is marked — never an arbitrary directory the caller
187
+ happened to pass, which may be the user's source tree (`--log-dir .`).
188
+ """
189
+ root = next((parent for parent in (directory, *directory.parents) if parent.name == RUNTIME_DIR_NAME), None)
190
+ if root is None:
191
+ return
192
+ marker = root / ".gitignore"
193
+ if marker.exists():
194
+ return
195
+ try:
196
+ marker.write_text("# Created by html-workbench: disposable logs and cache.\n*\n", encoding="utf-8")
197
+ except OSError:
198
+ pass
199
+
200
+
201
+ def pick_writable_dir(*candidates: Path) -> Path:
202
+ """Return the first candidate directory this process can really write into.
203
+
204
+ Existence proves nothing: the sandbox DACL looks writable on paper while the
205
+ restricted token is denied, and a folder the unconfined parent created may
206
+ reject every file creation here with EACCES / WinError 5. The only reliable
207
+ test is an actual create, which `make_temp_file` performs and fails fast.
208
+ Falls back to the first candidate so a caller with nothing writable at all
209
+ still gets a deterministic path (and a clear error at the real write).
210
+ """
211
+ fallback: Path | None = None
212
+ for candidate in candidates:
213
+ if candidate is None:
214
+ continue
215
+ if fallback is None:
216
+ fallback = candidate
217
+ try:
218
+ candidate.mkdir(parents=True, exist_ok=True)
219
+ except OSError:
220
+ continue
221
+ try:
222
+ descriptor, probe = make_temp_file(candidate, prefix=".wb-write-probe.")
223
+ except OSError:
224
+ continue
225
+ os.close(descriptor)
226
+ try:
227
+ probe.unlink()
228
+ except OSError:
229
+ pass
230
+ self_ignore(candidate)
231
+ return candidate
232
+ return fallback if fallback is not None else Path.cwd()
233
+
234
+
235
+ def log_candidates(log_dir: Path | str) -> tuple[Path, ...]:
236
+ """Log directories to try, best first: the caller's, then writable fallbacks."""
237
+ return (
238
+ Path(log_dir).expanduser(),
239
+ runtime_directory() / "logs",
240
+ Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME / "logs",
241
+ )
242
+
243
+
244
+ def vendor_candidates(vendor_cache: Path | str, log_dir: Path) -> tuple[Path, ...]:
245
+ """Vendor-cache directories to try, best first.
246
+
247
+ The cache sits beside whichever directory accepted the log: both are
248
+ re-creatable, and the log's parent already proved it is writable.
249
+ """
250
+ return (
251
+ Path(vendor_cache).expanduser(),
252
+ Path(log_dir).parent / "vendor",
253
+ runtime_directory() / "vendor",
254
+ Path(tempfile.gettempdir()) / RUNTIME_DIR_NAME / "vendor",
255
+ )
256
+
257
+
129
258
  _logger: logging.Logger | None = None
130
259
 
131
260
 
@@ -144,27 +273,40 @@ def get_logger() -> logging.Logger:
144
273
  return _logger
145
274
 
146
275
 
147
- def setup_logging(log_dir: Path | str, port: int | None = None) -> logging.Logger:
148
- """Attach the rotating file handler and return the module logger."""
276
+ def setup_logging(log_dir: Path | str, port: int | None = None) -> tuple[logging.Logger, Path]:
277
+ """Attach the rotating file handler; return the logger and the directory used.
278
+
279
+ The requested `--log-dir` can be unusable even when it already exists — the
280
+ sandbox denies writes to a folder whose DACL looks writable (see
281
+ `pick_writable_dir`). The directory is therefore probed and handed back to
282
+ the caller, so the host can report where the log really is instead of echoing
283
+ the path it merely asked for. A missing service log is never worth failing
284
+ the whole start: when EVERY candidate refuses writes, `pick_writable_dir`
285
+ returns a deterministic path that the handler cannot open, so the failure is
286
+ swallowed and the service runs without a file log rather than never binding
287
+ its port.
288
+ """
149
289
  global _logger
150
- directory = Path(log_dir).expanduser()
151
- directory.mkdir(parents=True, exist_ok=True)
290
+ directory = pick_writable_dir(*log_candidates(log_dir))
152
291
  filename = f"workbench-{port}.log" if port is not None else "workbench.log"
153
- handler = RotatingFileHandler(
154
- directory / filename,
155
- maxBytes=LOG_MAX_BYTES,
156
- backupCount=LOG_BACKUP_COUNT,
157
- encoding="utf-8",
158
- )
159
- handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
160
292
  logger = get_logger()
161
293
  for existing in list(logger.handlers):
162
294
  if isinstance(existing, RotatingFileHandler):
163
295
  existing.close()
164
296
  logger.removeHandler(existing)
165
- logger.addHandler(handler)
166
297
  logger.setLevel(logging.INFO)
167
- return logger
298
+ try:
299
+ handler = RotatingFileHandler(
300
+ directory / filename,
301
+ maxBytes=LOG_MAX_BYTES,
302
+ backupCount=LOG_BACKUP_COUNT,
303
+ encoding="utf-8",
304
+ )
305
+ except OSError:
306
+ return logger, directory
307
+ handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
308
+ logger.addHandler(handler)
309
+ return logger, directory
168
310
 
169
311
 
170
312
  def sha256_bytes(content: bytes) -> str:
@@ -218,7 +360,7 @@ def vendor_from_cdn(base_url: str) -> dict[str, bytes]:
218
360
  def write_vendor_cache(cache_dir: Path, files: dict[str, bytes]) -> None:
219
361
  cache_dir.mkdir(parents=True, exist_ok=True)
220
362
  for name, content in files.items():
221
- descriptor, temporary_name = tempfile.mkstemp(prefix=f".{name}.", dir=cache_dir)
363
+ descriptor, temporary_name = make_temp_file(cache_dir, prefix=f".{name}.")
222
364
  try:
223
365
  with os.fdopen(descriptor, "wb") as output:
224
366
  output.write(content)
@@ -982,9 +1124,9 @@ def promote_identities(target: Path, descriptors: list[dict[str, Any]], base_rev
982
1124
 
983
1125
 
984
1126
  def write_atomic(target: Path, content: str) -> None:
985
- temporary_name: str | None = None
1127
+ temporary_name: Path | None = None
986
1128
  try:
987
- descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.workbench-", suffix=".tmp", dir=target.parent)
1129
+ descriptor, temporary_name = make_temp_file(target.parent, prefix=f".{target.name}.workbench-", suffix=".tmp")
988
1130
  with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
989
1131
  output.write(content)
990
1132
  output.flush()
@@ -1231,7 +1373,7 @@ def save_document(target: Path, body: dict[str, Any]) -> dict[str, Any]:
1231
1373
  if latest["revision"] != base_revision:
1232
1374
  raise WorkbenchError("REVISION_CONFLICT", "保存期间文件发生外部修改,未覆盖最新版。", 409, document=latest)
1233
1375
 
1234
- descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.workbench-", suffix=".tmp", dir=target.parent)
1376
+ descriptor, temporary_name = make_temp_file(target.parent, prefix=f".{target.name}.workbench-", suffix=".tmp")
1235
1377
  try:
1236
1378
  with os.fdopen(descriptor, "w", encoding="utf-8", newline="") as output:
1237
1379
  output.write(source)
@@ -1249,11 +1391,24 @@ class WorkbenchServer(ThreadingHTTPServer):
1249
1391
  daemon_threads = True
1250
1392
  allow_reuse_address = True
1251
1393
 
1252
- def __init__(self, address: tuple[str, int], asset_file: Path, editor_root: Path, vendor_files: dict[str, Path]) -> None:
1394
+ def __init__(
1395
+ self,
1396
+ address: tuple[str, int],
1397
+ asset_file: Path,
1398
+ editor_root: Path,
1399
+ vendor_files: dict[str, Path],
1400
+ log_dir: Path | None = None,
1401
+ vendor_cache: Path | None = None,
1402
+ ) -> None:
1253
1403
  self.asset_file = asset_file.resolve(strict=True)
1254
1404
  self.editor_root = editor_root.resolve(strict=True)
1255
1405
  self.vendor_files = {name: path.resolve(strict=True) for name, path in vendor_files.items()}
1256
1406
  self.vendor_routes = {str(metadata["route"]): name for name, metadata in VENDOR_ASSETS.items()}
1407
+ # Advertised by /api/health so the host can report the directories actually
1408
+ # in use — including when it REUSES a service it did not start and has no
1409
+ # startup output to read them from.
1410
+ self.log_dir = Path(log_dir) if log_dir is not None else None
1411
+ self.vendor_cache = Path(vendor_cache) if vendor_cache is not None else None
1257
1412
  super().__init__(address, WorkbenchHandler)
1258
1413
 
1259
1414
 
@@ -1300,14 +1455,23 @@ class WorkbenchHandler(BaseHTTPRequestHandler):
1300
1455
  query = urllib.parse.parse_qs(parsed.query)
1301
1456
  try:
1302
1457
  if parsed.path == "/api/health":
1303
- self.send_json(200, {
1458
+ payload: dict[str, Any] = {
1304
1459
  "ok": True,
1305
1460
  "service": SERVICE_NAME,
1306
1461
  "version": SERVICE_VERSION,
1307
1462
  "port": self.server.server_port,
1308
1463
  "editorRoot": str(self.server.editor_root),
1309
1464
  "capabilities": list(SERVICE_CAPABILITIES),
1310
- })
1465
+ }
1466
+ # Report where this process really writes. The host shows these in
1467
+ # its diagnostics, so a REUSED service describes its own
1468
+ # directories instead of the ones the caller asked for — which may
1469
+ # be exactly what the sandbox refused.
1470
+ if self.server.log_dir is not None:
1471
+ payload["logDir"] = str(self.server.log_dir)
1472
+ if self.server.vendor_cache is not None:
1473
+ payload["vendorCache"] = str(self.server.vendor_cache)
1474
+ self.send_json(200, payload)
1311
1475
  return
1312
1476
  if parsed.path == "/api/document":
1313
1477
  self.send_json(200, read_document(self.query_file(query)))
@@ -1536,10 +1700,15 @@ def terminate_process(pid: int) -> None:
1536
1700
  pass
1537
1701
 
1538
1702
 
1539
- def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path]:
1540
- directory = Path(log_dir).expanduser()
1541
- directory.mkdir(parents=True, exist_ok=True)
1542
- log_file = directory / f"html-workbench-{port}.log"
1703
+ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file: Path | None, vendor_cache: Path, log_dir: Path) -> tuple[int, Path | None]:
1704
+ """Spawn a detached `serve` and return (pid, path of its captured output).
1705
+
1706
+ The path is None when no candidate directory accepted the file: a service
1707
+ whose console output cannot be captured must still start, so the output is
1708
+ discarded rather than the spawn abandoned (see `setup_logging`).
1709
+ """
1710
+ directory = pick_writable_dir(*log_candidates(log_dir))
1711
+ log_file: Path | None = directory / f"html-workbench-{port}.log"
1543
1712
  command = [sys.executable, str(script_file), "serve", "--port", str(port), "--editor-root", str(editor_root), "--vendor-cache", str(vendor_cache), "--log-dir", str(directory)]
1544
1713
  if asset_file is not None:
1545
1714
  command.extend(["--asset", str(asset_file)])
@@ -1549,19 +1718,45 @@ def start_detached(script_file: Path, port: int, editor_root: Path, asset_file:
1549
1718
  flags = subprocess.CREATE_NEW_PROCESS_GROUP | subprocess.DETACHED_PROCESS # type: ignore[attr-defined]
1550
1719
  kwargs["creationflags"] = flags
1551
1720
  kwargs.pop("start_new_session", None)
1552
- with log_file.open("ab") as log:
1553
- process = subprocess.Popen(command, stdout=log, stderr=subprocess.STDOUT, close_fds=True, **kwargs)
1721
+ try:
1722
+ log = log_file.open("ab")
1723
+ except OSError:
1724
+ log_file = None
1725
+ log = None
1726
+ try:
1727
+ process = subprocess.Popen(
1728
+ command,
1729
+ stdout=log if log is not None else subprocess.DEVNULL,
1730
+ stderr=subprocess.STDOUT,
1731
+ close_fds=True,
1732
+ **kwargs,
1733
+ )
1734
+ finally:
1735
+ if log is not None:
1736
+ log.close()
1554
1737
  return process.pid, log_file
1555
1738
 
1556
1739
 
1557
1740
  def command_serve(args: argparse.Namespace, script_file: Path) -> int:
1558
- logger = setup_logging(args.log_dir, args.port)
1741
+ logger, log_directory = setup_logging(args.log_dir, args.port)
1559
1742
  asset_file = Path(args.asset).expanduser().resolve(strict=True) if args.asset else default_asset_file(script_file)
1560
1743
  editor_root = Path(args.editor_root).expanduser().resolve(strict=True)
1561
- vendor_files = ensure_vendor_assets(Path(args.vendor_cache).expanduser())
1562
- server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files)
1563
- logger.info("service started: port=%s editorRoot=%s logDir=%s", args.port, editor_root, Path(args.log_dir).expanduser())
1564
- print(json.dumps({"ok": True, "url": f"http://127.0.0.1:{args.port}", "editorRoot": str(editor_root)}, ensure_ascii=False), flush=True)
1744
+ # Logs and cache are both disposable, so they follow the writable location the
1745
+ # sandbox really granted instead of the path the parent requested.
1746
+ vendor_cache = pick_writable_dir(*vendor_candidates(args.vendor_cache, log_directory))
1747
+ vendor_files = ensure_vendor_assets(vendor_cache)
1748
+ server = WorkbenchServer(("127.0.0.1", args.port), asset_file, editor_root, vendor_files, log_directory, vendor_cache)
1749
+ logger.info("service started: port=%s editorRoot=%s logDir=%s vendorCache=%s", args.port, editor_root, log_directory, vendor_cache)
1750
+ # The host surfaces these two paths in its diagnostics, so the panel shows the
1751
+ # directories actually in use rather than echoing the ones it asked for — the
1752
+ # requested ones may be exactly what the sandbox refused.
1753
+ print(json.dumps({
1754
+ "ok": True,
1755
+ "url": f"http://127.0.0.1:{args.port}",
1756
+ "editorRoot": str(editor_root),
1757
+ "logDir": str(log_directory),
1758
+ "vendorCache": str(vendor_cache),
1759
+ }, ensure_ascii=False), flush=True)
1565
1760
  try:
1566
1761
  server.serve_forever(poll_interval=0.25)
1567
1762
  except KeyboardInterrupt:
@@ -1600,7 +1795,8 @@ def command_open(args: argparse.Namespace, script_file: Path) -> int:
1600
1795
  break
1601
1796
  time.sleep(0.1)
1602
1797
  else:
1603
- raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败,请查看日志:{log_file}", 500)
1798
+ where = f",请查看日志:{log_file}" if log_file is not None else "(没有可写的日志目录,输出已丢弃)"
1799
+ raise WorkbenchError("SERVER_UNAVAILABLE", f"服务启动失败{where}", 500)
1604
1800
  query = urllib.parse.urlencode({"file": str(target)})
1605
1801
  print(json.dumps({
1606
1802
  "ok": True,
package/src/client.js CHANGED
@@ -98,6 +98,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
98
98
  .hwb-diag-row + .hwb-diag-row { border-top: 1px solid color-mix(in srgb, var(--dsw-alias-border-l2) 50%, transparent); }
99
99
  .hwb-diag-time { color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10.5px; padding-top: 1px; }
100
100
  .hwb-diag-msg { min-width: 0; }
101
+ .hwb-diag-count { margin-left: 6px; padding: 0 5px; border-radius: 999px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-tertiary); font-family: var(--hwb-mono); font-size: 10px; }
101
102
  .hwb-diag-row[data-level="error"] .hwb-diag-msg { color: var(--dsw-alias-state-error-primary); }
102
103
  .hwb-diag-detail { display: block; margin-top: 3px; padding: 6px 8px; border-radius: 6px; background: var(--dsw-alias-bg-base); color: var(--dsw-alias-label-secondary); font-family: var(--hwb-mono); font-size: 10.5px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; max-height: 180px; overflow-y: auto; }
103
104
  .hwb-diag-empty { padding: 16px 14px; color: var(--dsw-alias-label-tertiary); font-size: 11.5px; }
@@ -620,8 +621,12 @@ header:has([data-slot="conversation.session.header.utilities"]) {
620
621
  if (!res.isHtml) store.set({ resolveState: 'invalid' })
621
622
  else if (res.exists === true) store.set({ resolveState: 'exists' })
622
623
  else if (res.exists === false) store.set({ resolveState: 'missing' })
624
+ // `exists: null` means the CHECK could not run (no interpreter, no
625
+ // shell). Staying silent made the field look unresponsive, so promote
626
+ // the host's reason to the banner instead of dropping it.
627
+ else if (res.error) store.set({ resolveState: 'idle', error: res.error })
623
628
  else store.set({ resolveState: 'idle' })
624
- }).catch(() => store.set({ resolveState: 'idle' }))
629
+ }).catch((e) => store.set({ resolveState: 'idle', error: String(e && e.message ? e.message : e) }))
625
630
  }, 300)
626
631
  }
627
632
 
@@ -674,6 +679,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
674
679
  const s = useStore()
675
680
  const d = s.diag
676
681
  const journal = (d && d.journal) || []
682
+ const [copied, setCopied] = React.useState(false)
677
683
 
678
684
  const fact = (key, value) => value == null || value === ''
679
685
  ? null
@@ -682,10 +688,53 @@ header:has([data-slot="conversation.session.header.utilities"]) {
682
688
  React.createElement('span', { className: 'hwb-diag-val', key: key + '-v' }, String(value)),
683
689
  ]
684
690
 
691
+ // A bug report is only useful if it carries the journal. Asking a user to
692
+ // screenshot a scrolling panel loses exactly the detail lines that matter,
693
+ // so hand them one paste-ready block instead — plain text, not JSON, so it
694
+ // survives being dropped into a chat window.
695
+ const copyReport = () => {
696
+ const lines = ['# HTML Workbench 诊断报告']
697
+ if (d) {
698
+ lines.push('插件版本: ' + (d.version || '未知(可能是动态热加载)'))
699
+ lines.push('平台: ' + (d.platform || '未知'))
700
+ lines.push('状态: ' + (d.running ? '运行中' : '未就绪'))
701
+ lines.push('端口: ' + d.port)
702
+ lines.push('进程: ' + (d.processStatus || '未由本插件启动')
703
+ + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')'))
704
+ lines.push('Python: ' + (d.pythonCommand || (d.pythonCandidates || []).join(' → ') || '未解析'))
705
+ lines.push('脚本: ' + (d.script || '未配置'))
706
+ lines.push('运行目录: ' + (d.runtimeDir || '未知'))
707
+ if (d.startError) lines.push('失败原因: ' + d.startError)
708
+ } else {
709
+ lines.push('(后台未返回诊断信息,通常说明请求根本没有到达 DSH)')
710
+ }
711
+ if (s.error) lines.push('界面报错: ' + s.error)
712
+ lines.push('', '## 日志(最新在前)')
713
+ if (journal.length) {
714
+ journal.forEach((entry) => {
715
+ lines.push('[' + clockTime(entry.at) + '] ' + entry.level + ': ' + entry.message)
716
+ if (entry.detail) lines.push(' ' + String(entry.detail).replace(/\n/g, '\n '))
717
+ })
718
+ } else {
719
+ lines.push('(无)')
720
+ }
721
+ const text = lines.join('\n')
722
+ const done = () => { setCopied(true); setTimeout(() => setCopied(false), 1600) }
723
+ if (navigator.clipboard && navigator.clipboard.writeText) {
724
+ navigator.clipboard.writeText(text).then(done, () => store.set({ error: '复制失败,请手动访问 /html-workbench/diagnostics 获取原始信息。' }))
725
+ } else {
726
+ store.set({ error: '当前环境不支持剪贴板,请访问 /html-workbench/diagnostics 获取原始信息。' })
727
+ }
728
+ }
729
+
685
730
  return React.createElement('div', { className: 'hwb-diag' },
686
731
  React.createElement('div', { className: 'hwb-diag-head' },
687
732
  React.createElement('span', { className: 'hwb-diag-title' }, '服务诊断'),
688
733
  React.createElement('span', { className: 'hwb-spacer' }),
734
+ React.createElement('button', {
735
+ type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
736
+ onClick: copyReport, title: '复制完整诊断信息,便于反馈问题',
737
+ }, copied ? '已复制' : '复制报告'),
689
738
  React.createElement('button', {
690
739
  type: 'button', className: 'hwb-btn hwb-btn-quiet hwb-btn-sm',
691
740
  disabled: s.restarting, onClick: restartService,
@@ -700,8 +749,11 @@ header:has([data-slot="conversation.session.header.utilities"]) {
700
749
  fact('状态', d.running ? '运行中' : (d.processStatus === 'running' ? '启动中' : '未就绪')),
701
750
  fact('端口', d.port),
702
751
  fact('进程', d.processStatus ? d.processStatus + (d.exitCode == null ? '' : '(exit=' + d.exitCode + ')') : '未由本插件启动'),
752
+ fact('Python', d.pythonCommand || ((d.pythonCandidates || []).join(' → ') || null)),
753
+ fact('运行目录', d.runtimeDir),
703
754
  fact('失败原因', d.startError),
704
755
  fact('脚本', d.script),
756
+ fact('版本', d.version ? d.version + (d.platform ? ' · ' + d.platform : '') : d.platform),
705
757
  d.hasShell === false ? fact('shell', '不可用') : null,
706
758
  )
707
759
  : null,
@@ -713,6 +765,11 @@ header:has([data-slot="conversation.session.header.utilities"]) {
713
765
  React.createElement('span', { className: 'hwb-diag-time' }, clockTime(entry.at)),
714
766
  React.createElement('span', { className: 'hwb-diag-msg' },
715
767
  entry.message,
768
+ // Folded duplicates would otherwise read as a single occurrence,
769
+ // hiding that the user retried and hit the same wall every time.
770
+ entry.repeated > 1
771
+ ? React.createElement('span', { className: 'hwb-diag-count' }, '×' + entry.repeated)
772
+ : null,
716
773
  entry.detail ? React.createElement('span', { className: 'hwb-diag-detail' }, entry.detail) : null,
717
774
  ),
718
775
  ))
@@ -720,7 +777,7 @@ header:has([data-slot="conversation.session.header.utilities"]) {
720
777
  ),
721
778
  React.createElement('div', { className: 'hwb-diag-hint' },
722
779
  '服务自身日志:',
723
- React.createElement('code', null, (d && d.logDir) || '/tmp/html-workbench-dsh/logs/'),
780
+ React.createElement('code', null, (d && d.logDir) || '<工作区>/.html-workbench/logs/'),
724
781
  ';也可直接访问 ',
725
782
  React.createElement('code', null, '/html-workbench/diagnostics'),
726
783
  ' 查看原始 JSON。',
package/src/host.js CHANGED
@@ -28,29 +28,65 @@ 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.
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.
35
50
  const tempRoot = typeof process !== 'undefined' && process.env
36
51
  ? (process.env.TEMP || process.env.TMP || process.env.TMPDIR)
37
52
  : 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.
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.
46
71
  const LOG_DIR = RUNTIME_DIR + '/logs'
47
72
  const VENDOR_CACHE = RUNTIME_DIR + '/vendor'
48
73
 
74
+ // Directories the running service reported; null until it says so.
75
+ let resolvedPaths = null
76
+
49
77
  let assets = []
50
78
  let seq = 0
51
79
  let ownedProcess = null
52
80
  let serviceRunning = false
53
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
54
90
  let disposed = false
55
91
 
56
92
  const shell = ctx.get('shell')
@@ -63,17 +99,59 @@ return {
63
99
  // subprocess outcome now lands in this ring buffer, which `status` returns
64
100
  // and the panel renders, so the failure is readable where it is observed.
65
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
66
108
  let journal = []
67
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
+
68
133
  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 || '')
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 || '')
77
155
  }
78
156
 
79
157
  // `CollectedOutput` carries more than text (truncation flags, spill paths);
@@ -86,6 +164,25 @@ return {
86
164
  return trimmed
87
165
  }
88
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
+
89
186
  const describeRun = (result) => {
90
187
  if (!result) return 'shell.run threw'
91
188
  const parts = []
@@ -97,10 +194,76 @@ return {
97
194
  return parts.join(' ') + (streams.length ? '\n' + streams.join('\n') : '')
98
195
  }
99
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
+
100
261
  const runCli = async (args, timeoutMs) => {
101
262
  if (!shell) { note('error', 'shell 服务不可用,无法执行 workbench.py'); return null }
102
263
  if (!SCRIPT) { note('error', 'workbench.py 路径未配置'); return null }
103
- const command = 'python3 "' + SCRIPT + '" ' + args
264
+ const python = await resolvePythonCommand()
265
+ if (!python) return null
266
+ const command = python + ' ' + quoteArg(SCRIPT) + ' ' + args
104
267
  try {
105
268
  const spec = shell.resolve({ command: command, timeoutMs: timeoutMs || 15000, stdoutMaxBytes: 64 * 1024 })
106
269
  const result = await shell.run(spec)
@@ -118,9 +281,11 @@ return {
118
281
  // the journal — poll quietly and let the caller decide what is worth noting.
119
282
  const probeHealth = async () => {
120
283
  if (!shell || !SCRIPT) return null
284
+ const python = await resolvePythonCommand()
285
+ if (!python) return null
121
286
  try {
122
287
  const spec = shell.resolve({
123
- command: 'python3 "' + SCRIPT + '" health --port ' + PORT,
288
+ command: python + ' ' + quoteArg(SCRIPT) + ' health --port ' + PORT,
124
289
  timeoutMs: 8000,
125
290
  stdoutMaxBytes: 64 * 1024,
126
291
  })
@@ -137,9 +302,23 @@ return {
137
302
  }
138
303
  }
139
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
+
140
317
  const health = async () => {
141
318
  const probe = await probeHealth()
142
- return probe && probe.ok ? probe.payload : null
319
+ if (!probe || !probe.ok) return null
320
+ adoptResolvedPaths(probe.payload)
321
+ return probe.payload
143
322
  }
144
323
 
145
324
  // `workbench.py` reports failures as one structured JSON line on stderr. Turn
@@ -201,11 +380,13 @@ return {
201
380
  }
202
381
  if (!shell) { startError = 'shell 服务不可用(DSH 未提供 shell,插件无法拉起本地服务)'; note('error', startError); return null }
203
382
  if (!SCRIPT) { startError = 'workbench.py 路径未配置(请用静态装载并随包分发 scripts/workbench.py)'; note('error', startError); return null }
383
+ const python = await resolvePythonCommand()
384
+ if (!python) return null
204
385
  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 + '"' : '')
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) : '')
209
390
  try {
210
391
  note('info', '启动本地服务:端口 ' + PORT, command)
211
392
  proc = shell.start(shell.resolve({ command: command, stdoutMaxBytes: 64 * 1024 }))
@@ -220,6 +401,7 @@ return {
220
401
  serviceRunning = true
221
402
  startError = null
222
403
  healthConfirmedAt = Date.now()
404
+ adoptResolvedPaths(probe.payload)
223
405
  note('info', '本地服务就绪:版本 ' + (probe.payload.version || '未知') + ',端口 ' + PORT)
224
406
  return probe.payload
225
407
  }
@@ -302,12 +484,20 @@ return {
302
484
  ok: true,
303
485
  running: serviceRunning,
304
486
  owned: !!ownedProcess,
487
+ version: VERSION,
488
+ platform: PLATFORM,
305
489
  port: PORT,
306
490
  script: SCRIPT,
307
491
  editorRoot: EDITOR_ROOT,
308
492
  runtimeDir: RUNTIME_DIR,
309
- logDir: LOG_DIR,
310
- vendorCache: VENDOR_CACHE,
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,
311
501
  hasShell: !!shell,
312
502
  processStatus: ownedProcess ? ownedProcess.status : null,
313
503
  exitCode: ownedProcess ? ownedProcess.exitCode : null,
@@ -416,14 +606,24 @@ return {
416
606
  if (!trimmed) return { ok: true, empty: true, isHtml: false, exists: false }
417
607
  const normalized = normalizeFileReference(trimmed)
418
608
  if (!/\.html?$/i.test(normalized)) return { ok: true, isHtml: false, exists: false }
419
- if (!shell) return { ok: true, isHtml: true, exists: null }
420
- const arg = JSON.stringify(normalized)
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 })
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
+ })
422
622
  try {
423
623
  const r = await shell.run(spec)
424
624
  return { ok: true, isHtml: true, exists: !!(r && r.exitCode === 0), normalized: normalized }
425
625
  } catch (e) {
426
- return { ok: true, isHtml: true, exists: null, normalized: normalized }
626
+ return { ok: true, isHtml: true, exists: null, normalized: normalized, error: (e && e.message) || String(e) }
427
627
  }
428
628
  }
429
629
 
@@ -433,13 +633,45 @@ return {
433
633
  return base
434
634
  }
435
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
+ }
665
+ }
666
+ }
667
+
436
668
  // 动态传输:Package-private RPC(仅动态运行时存在 harness)。
437
669
  if (typeof harness !== 'undefined') {
438
670
  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 })
671
+ harness.handle('status', () => statusPayload())
440
672
  harness.handle('open', async (args) => openFile(args && args.file))
441
673
  harness.handle('resolve', async (args) => resolvePath(args && args.file))
442
- harness.handle('diagnostics', () => diagnostics())
674
+ harness.handle('diagnostics', () => safeDiagnostics())
443
675
  harness.handle('restart', async () => restartService())
444
676
  }
445
677
 
@@ -462,58 +694,78 @@ return {
462
694
  })
463
695
  return out
464
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')
465
725
  ctx.effect(() => webServer.register({
466
726
  kind: 'exact',
467
727
  path: '/html-workbench/list',
468
- handler: (req, res) => sendJson(res, 200, listPayload()),
728
+ handler: guard('list', (req, res) => sendJson(res, 200, listPayload())),
469
729
  }), 'html-workbench: list route')
470
730
  ctx.effect(() => webServer.register({
471
731
  kind: 'exact',
472
732
  path: '/html-workbench/status',
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
- },
733
+ handler: guard('status', async (req, res) => sendJson(res, 200, await statusPayload())),
481
734
  }), 'html-workbench: status route')
482
735
  ctx.effect(() => webServer.register({
483
736
  kind: 'exact',
484
737
  path: '/html-workbench/open',
485
- handler: async (req, res) => {
738
+ handler: guard('open', async (req, res) => {
486
739
  const file = parseQuery(req).file
487
740
  const out = await openFile(file)
488
741
  sendJson(res, out.ok ? 200 : 400, out)
489
- },
742
+ }),
490
743
  }), 'html-workbench: open route')
491
744
  ctx.effect(() => webServer.register({
492
745
  kind: 'exact',
493
746
  path: '/html-workbench/resolve',
494
- handler: async (req, res) => {
747
+ handler: guard('resolve', async (req, res) => {
495
748
  const out = await resolvePath(parseQuery(req).file)
496
749
  sendJson(res, out.ok ? 200 : 400, out)
497
- },
750
+ }),
498
751
  }), '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
752
  ctx.effect(() => webServer.register({
507
753
  kind: 'exact',
508
754
  path: '/html-workbench/restart',
509
- handler: async (req, res) => {
755
+ handler: guard('restart', async (req, res) => {
510
756
  const out = await restartService()
511
757
  sendJson(res, out.ok ? 200 : 500, out)
512
- },
758
+ }),
513
759
  }), 'html-workbench: restart route')
514
760
  }
515
761
 
516
762
  // 注册时主动拉起服务(异步,不阻塞 apply)。
517
- void startService()
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
+ })
518
770
  },
519
771
  }
package/src/index.js CHANGED
@@ -8,17 +8,76 @@
8
8
  * resolved relative to THIS module (`import.meta.url`) — never hard-coded to a
9
9
  * specific machine — and handed to the shared body as `config.script`.
10
10
  */
11
- import { readFileSync } from 'node:fs'
11
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
12
12
  import { tmpdir } from 'node:os'
13
- import { dirname, resolve, join } from 'node:path'
13
+ import { dirname, join, resolve } from 'node:path'
14
14
  import { fileURLToPath } from 'node:url'
15
15
 
16
16
  const here = dirname(fileURLToPath(import.meta.url))
17
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')
18
+ // The panel shows this next to the diagnostics so a pasted report says WHICH
19
+ // build produced it — without it, every bug report needs a follow-up question.
20
+ const version = JSON.parse(readFileSync(resolve(here, '..', 'package.json'), 'utf8')).version
21
+
22
+ // Must match RUNTIME_DIR_NAME in scripts/workbench.py, so the host's first guess
23
+ // and the service's own in-process fallback land in the SAME folder instead of
24
+ // scattering two differently named directories through the user's workspace.
25
+ const RUNTIME_DIR_NAME = '.html-workbench'
26
+
27
+ /**
28
+ * Whether this process can really create files in `dir` (mkdir + write + delete).
29
+ *
30
+ * A writable-looking DACL proves nothing: DSH runs the Python service as a
31
+ * sandboxed child whose restricted token may write only the session workspace and
32
+ * a private temp directory it hands the child through TEMP/TMP — never the
33
+ * ambient %TEMP% root the parent sees. Probing by an actual create is the only
34
+ * test that reflects what a child can do, and the probe must fail fast (see
35
+ * `make_temp_file` in workbench.py for why `tempfile.mkstemp` is unusable here).
36
+ *
37
+ * A successful probe leaves the directory behind, so it is marked ignored right
38
+ * away: the runtime root normally sits in the user's own repository, and probing
39
+ * must not be what dirties their `git status` (see `self_ignore` in workbench.py,
40
+ * which does the same for the directory the service settles on).
41
+ */
42
+ const probeWritableDir = (dir) => {
43
+ try {
44
+ mkdirSync(dir, { recursive: true })
45
+ const probe = join(dir, '.wb-write-probe-' + process.pid)
46
+ writeFileSync(probe, '')
47
+ rmSync(probe, { force: true })
48
+ if (!existsSync(join(dir, '.gitignore'))) {
49
+ writeFileSync(join(dir, '.gitignore'), '# Created by html-workbench: disposable logs and cache.\n*\n')
50
+ }
51
+ return true
52
+ } catch (e) {
53
+ return false
54
+ }
55
+ }
56
+
57
+ /**
58
+ * Choose the runtime directory to ASK the service for.
59
+ *
60
+ * The workspace comes first: it is the one tree the sandbox reliably grants and it
61
+ * keeps the re-downloadable GrapesJS cache across restarts. The historical temp
62
+ * location stays as a fallback for deployments with no writable cwd.
63
+ *
64
+ * This is a request, not a guarantee — the probe runs with the parent's rights,
65
+ * which are wider than the child's. The service re-probes in-process, falls back
66
+ * on its own, and reports the directories it really used, so a wrong guess here
67
+ * costs a different folder, never a failed start.
68
+ */
69
+ const resolveRuntimeDir = () => {
70
+ const candidates = [
71
+ process.cwd() ? join(process.cwd(), RUNTIME_DIR_NAME) : null,
72
+ join(tmpdir(), RUNTIME_DIR_NAME),
73
+ ]
74
+ for (const candidate of candidates) {
75
+ if (candidate && probeWritableDir(candidate)) return candidate
76
+ }
77
+ return candidates[0] || join(tmpdir(), RUNTIME_DIR_NAME)
78
+ }
79
+
80
+ const runtimeDir = resolveRuntimeDir()
22
81
 
23
82
  // The body is a `return { ... }` statement; wrap it in a function and call it.
24
83
  // eslint-disable-next-line no-new-func
@@ -32,5 +91,6 @@ export const inject = plugin.inject ?? []
32
91
  export const apply = (ctx, config) => plugin.apply(ctx, {
33
92
  script: scriptPath,
34
93
  runtimeDir,
94
+ version,
35
95
  ...(config || {}),
36
96
  })