@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 +16 -2
- package/dsh.plugin.json +1 -1
- package/lib/client.js +73 -2
- package/package.json +1 -1
- package/scripts/workbench.py +229 -33
- package/src/client.js +59 -2
- package/src/host.js +310 -58
- package/src/index.js +66 -6
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
|
-
###
|
|
31
|
+
### 静态装载(已发布 NPM)
|
|
32
32
|
|
|
33
|
-
`cordis.patch.yml` + `package.json` 的 `dsh.bundle`
|
|
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
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) || '
|
|
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
package/scripts/workbench.py
CHANGED
|
@@ -34,7 +34,7 @@ from typing import Any
|
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
SERVICE_NAME = "html-workbench"
|
|
37
|
-
SERVICE_VERSION = "2.
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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:
|
|
1127
|
+
temporary_name: Path | None = None
|
|
986
1128
|
try:
|
|
987
|
-
descriptor, temporary_name =
|
|
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 =
|
|
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__(
|
|
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
|
-
|
|
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
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
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
|
-
|
|
1553
|
-
|
|
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
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
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
|
-
|
|
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) || '
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
//
|
|
34
|
-
//
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
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: '
|
|
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
|
-
|
|
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 = '
|
|
206
|
-
+ ' --log-dir
|
|
207
|
-
+ ' --vendor-cache
|
|
208
|
-
+ (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
|
-
|
|
310
|
-
|
|
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
|
|
421
|
-
|
|
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',
|
|
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', () =>
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
19
|
-
//
|
|
20
|
-
|
|
21
|
-
|
|
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
|
})
|