dsh-ris-ide 0.0.0-stage → 1.6.9

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 haweite
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,125 @@
1
- # Temporary Holding Version
1
+ # dsh-ris-ide
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **RIS-IDE local dev panel for the DSH (DeepSeek Harness) web GUI** — tail your service logs, start/stop workspace programs, debug Java with JDWP, browse and edit files, all without leaving the chat window. A standalone IDE window carries no session history, so it can stay open all day.
4
+
5
+ DSH(DeepSeek Harness)Web GUI 右侧栏里的 **RIS-IDE 本地开发面板**:不用离开聊天界面,就能看服务日志、启停本地程序、给 Java 断点调试、浏览和编辑工作区文件。
6
+
7
+ > 面向开发者/维护者的文档(架构、路由、命令、配置字段、里程碑、打包/发布/Jenkins、测试与踩坑)见 [DEVELOPMENT.md](https://gitee.com/riscloud/dsh-ris-ide/blob/master/DEVELOPMENT.md)。
8
+
9
+ ## 轻量级 IDE
10
+
11
+ 这不是又一个桌面 IDE,也不打算替代 IDEA / VS Code——它要解决的是「看一眼、改一下、跑一下、断一下」这类碎片化操作必须切窗口的问题:
12
+
13
+ - **按需加载,不常驻重资源**:编辑能力来自独立懒加载的 CodeMirror chunk(约 1.1MB,**第一次打开文件时才拉取**);不起 language server、不做全库索引、没有后台守护进程、不新开端口,只复用 DSH 已有的 `/dsh-ris-ide/*` 路由和**一条 SSE**。
14
+ - **面板本身很轻**:侧栏只留「RIS控制台」(控制台 + 实时日志,默认 240px);完整工作台放在**独立窗口**,该窗口不加载 DSH 界面、不带会话历史(插件客户端约 435KB + DSH 的 React + 一条 SSE),开一整天也不会随会话历史变重。
15
+ - **日常闭环够用**:git 口径文件树与文件名搜索(标题栏眼睛可临时关闭 .gitignore 过滤)、编辑/保存/多页签、Markdown/HTML 内联预览(编辑/预览一键切换,不依赖侧栏服务)、目录变更自动跟随、日志实时跟随与着色(点启动立刻切到该程序日志,编译静默期有提示)、程序启停、Java JDWP 断点调试,以及给 agent 的 `ris_dev_*`——都在同一处完成。
16
+ - **与重型 IDE 互补**:大重构、全局索引、深度语言服务仍交给 IDEA;这里负责顺手改一行、跑一下、看日志、和 agent 协作时共用同一个面板。
17
+
18
+ | 侧栏「RIS控制台」:控制台 + 实时日志|
19
+ |---|
20
+ | ![侧栏「RIS控制台」](https://gitee.com/riscloud/dsh-ris-ide/raw/master/docs/screenshots/ris-console.png) |
21
+
22
+ | 独立窗口(打开即最大化,无会话历史) 标题栏最大化 / 跟随 / 字号|
23
+ |---|
24
+ | ![独立窗口](https://gitee.com/riscloud/dsh-ris-ide/raw/master/docs/screenshots/ris-ide-window.png) |
25
+
26
+ ## 功能
27
+
28
+ - **日志实时跟随**:`.log` 不停在打开那一刻;按行着色(ANSI / ERROR-WARN-INFO)、自动换行、字号调节、暂停/到底;默认**只看本次启动**(重启后自动切到新一段),历史仍可加载。日志标题栏显示服务状态(`运行中 (pid N)` / `已停止`),可直接停止服务。
29
+ - **侧栏「RIS控制台」**:右侧栏的面板只保留**控制台 + 实时日志**上下两段——上面每行一个程序(状态点 / 内存 / ▶ ■ / 🐞),点程序名(或整行任意位置)下面就跟它的日志;分隔条可拖动,**默认 240px**、双击恢复、高度会记住;两段标题栏各有 **⤢ 最大化/还原**,日志标题栏还有 **A− / A+ 字号**(与 IDE 日志窗格共用一份偏好)与 **跟随/暂停**。文件树 / 编辑器 / 调试都在独立窗口里,面板自身很轻、能一直挂着。
30
+ - **独立窗口**:聊天页右上角(VS Code 按钮右侧)的 **⧉ RIS-IDE** 按钮可在自己的窗口里打开完整工作台(面板右上角也有同样入口),**打开即填满屏幕**(按工作区尺寸定位到 0,0,不再是固定 1500×960)——该窗口**不加载 DSH 界面、不带会话**(只用插件自己的约 435KB 客户端 + DSH 提供的 React + 一条 SSE),长期开着也不随会话历史变重;与侧栏面板是同一条 `/dsh-ris-ide/*` 路由、同一个 cookie,没有新端口。窗口名按工作空间固定,再点只聚焦复用同一个窗口;窗口名按工作空间(而非当前目录)固定,所以从程序目录进调试也不会开出第二个窗口;若确实检测到另一个窗口,会提示「关闭这个窗口 / 仍然在这里打开」,不会自动关。
31
+ - **本地实例**:扫描工作区里的 Spring Boot 入口与前端 `package.json` 脚本,一键**启动 / 停止 / 重启 / 看日志**(启停未返回前该行盖上 loading 遮罩,点不到第二次);首次打开自动生成 `.dsh-ris-ide.json` 保存启动参数(JVM 参数、env、日志名、停止宽限),之后按配置启动,配置漂移时提示更新。
32
+ - **JDWP 调试(仅 Java)**:点行号加断点、双击红点删除(空行与注释行自动忽略);启动/调试时日志自动跟随该程序(其它窗口也会跟着切),F8 连按不会报取栈失败;调试自动打开的文件在调试行离开后自动关闭(有未保存修改的保留),断点列表与调用栈用类名标识位置,断点信息全局同步(其它程序的历史点不会残留、取消后立即消失);断点 / 线程 / 调用栈 / 变量 / 单步 / 继续;对象与数组变量可点开懒加载查看(含继承字段、空值也列出;步进刷新后展开状态保留),基础类型不显示展开图标;依赖 jar 里的类会自动从 `-sources.jar` 提取源码、否则用 `javap` 反汇编,也可在调试面板 ⚙ 里**可选**填一个反编译工具 jar(如 CFR);缓存按依赖 jar 指纹失效(jar 重建即重取),并有 64MB/30 天上限自动回收;命中后自动切到「参数」页签,并打开源码、高亮当前行(行不在可见区时自动滚动居中;单步/继续后行号跟随),调试面板与 agent 共用同一个会话;前端程序只能启停和看日志。
33
+ - **文件树 + 编辑器**:git 口径的目录树与文件名搜索(结果显示工作空间相对路径、悬停看全路径;Ctrl+R 有单行选区时直接搜该文本;编辑器/日志里 Ctrl+F 会把选中的文本带入各自的查找框,不占用这个文件检索框)、内置 CodeMirror(Java 等语法高亮;切换文件时页签自动滚入可视区、编辑器失焦后不残留全选色)、保存、新建/复制/移动/删除、多页签、拖放移动、从系统粘贴/拖入文件导入;`src/main/java` 下可直接**新建包名**(`cn.com.x` 按点建多级目录),新建文件也只填一个名字(`cloud.test` 自动建包并补 `.java`);超过整读上限的大文本文件以**只读分页预览**打开(滚到接近底部才取下一页,内存超上限后释放前面的内容、可从头重载,不能编辑)。
34
+ - **文件树自动跟随**:工作区里新建/删除/重命名会经侧栏**同一条 SSE**自动更新,只重读变化的目录、行没变就不重绘;标题栏 **⟳** 可强制按 git 口径重新读取(结果会弹提示)。
35
+ - **预览与复制粘贴**:`.md/.markdown/.html/.htm` 在编辑区域上方有「预览」开关(点后编辑器变预览、按钮变「编辑」,预览当前内容含未保存修改);树右键「复制」是普通复制,粘贴遇到同名会先问**覆盖还是改名**;从系统文件管理器复制/拖入的文件可直接导入当前目录(`/fs/upload` 原始字节、`/fs/import` 路径导入)。
36
+ - **给 agent 用**:内置 `ris_dev_*` 工具(列程序 / 状态 / 启动 / 重启 / 停止 / 读日志 / 调试 / 安装 skill),agent 可以直接操作同一个面板。
37
+ - **跨平台**:Linux / macOS 用 bash 包装;Windows 自动选 Git Bash 或原生 PowerShell 包装(`taskkill /T` 杀整棵进程树)。
38
+
39
+ ## 安装
40
+
41
+ 需要 DSH `>= 0.2.0-rc.2`,且 Web GUI 带官方右侧栏服务(`sidebarRight` / `sidebarRightTabs` / `slots`)。
42
+
43
+ **本插件自身不依赖任何外部资源**:零运行时 npm 依赖、客户端产物(含 CodeMirror)已随包发布、不联网、不起独立端口,只使用 DSH 自带路由与 Node 内置模块。本机工具只在特定能力上需要:`git`(有则按 gitignore 过滤文件树/搜索;**没有 git 也能用**,退化为不过滤)、JDK `java`/`javap`(启动/调试 Java 程序、反汇编依赖类)、Maven(默认起 Spring 服务时会 `mvn compile`,本地仓库没缓存时会联网下载依赖)、Node/前端包管理器(启动前端 dev server)。
44
+
45
+ 发布到 npmjs 后:
46
+
47
+ ```bash
48
+ dsh plugin --profile web add dsh-ris-ide
49
+ ```
50
+
51
+ 在**内网环境**里,把 registry 指向公司内部的 npm 源(地址见内部运维文档,不在此仓库公开),装完同样是上面这条命令:
52
+
53
+ ```bash
54
+ # 例:临时 .npmrc 只写 registry,退出/失败自动删除
55
+ P=~/.dsh/profiles/web; ( printf 'registry=<internal-registry>\n' > "$P/.npmrc"; trap 'rm -f "$P/.npmrc"' EXIT; dsh plugin --profile web add dsh-ris-ide )
56
+ ```
57
+
58
+ 离线 / 内网机器也可以直接装 tgz:
59
+
60
+ ```bash
61
+ dsh plugin --profile web add /path/to/dsh-ris-ide-<version>.tgz
62
+ ```
63
+
64
+ 说明:
65
+
66
+ - `dsh plugin add` 会写 `~/.dsh`(沙箱会话里需要提权);它会把插件加入 profile 的 `dependencies` 与 `bundles`。
67
+ - 装完**重启 `dsh web`**(Loader 在启动时组合一次),浏览器再**硬刷新**(Ctrl+Shift+R)加载新的客户端 bundle。
68
+ - **升级**:先删掉 profile 里旧副本再装,否则 pnpm 可能因 `Lockfile is up to date` 跳过:
69
+ `rm -rf ~/.dsh/profiles/web/node_modules/dsh-ris-ide`,然后重跑上面的安装命令(换成新版本号)。
70
+
71
+ ## 使用
72
+
73
+ 1. 打开 DSH Web GUI,右侧栏进入「**RIS控制台**」标签(完整工作台可从聊天页右上角 **⧉ RIS-IDE** 按钮在独立窗口打开,见下)。
74
+ 2. 首次打开会在工作区根生成 `.dsh-ris-ide.json`(扫描生成;之后按它启动,可在面板里改配置后更新)。
75
+ 3. 程序行上的按钮:
76
+ - **启动 / 停止**:启动后日志自动可看;停止按 pidfile 校验后 TERM → 宽限 → KILL,连进程组一起收掉。
77
+ - **调试**:仅 Spring/Java 程序,会以 JDWP 模式重启并 attach。
78
+ - **进入**:打开独立工作台——上调试区(断点 / 线程 / 调用栈 / 变量 / 事件,多页签),下实时日志,右侧文件树与编辑器。
79
+ 4. 日志:直接在工作台或日志页查看;`Ctrl+F` 打开日志内查找。agent 说“看日志”时等效于 `ris_dev_logs`,会把日志弹到这个面板。
80
+ 5. 调试(Java):先看状态(`ris_dev_debug state`)→ 启动调试(`start`)→ 加断点(类名 + 行号)→ 线程/栈/变量/单步。自定义 `startCommand` 必须在 `java` 后带 **`${JDWP_ARGS}`** 占位符,否则调试启动会直接报错。
81
+
82
+ ## 配置
83
+
84
+ 配置写在**工作区根**的 `.dsh-ris-ide.json`(面板自动生成/校验/更新,也可以手改):
85
+
86
+ | 字段 | 作用 |
87
+ |---|---|
88
+ | `defaults.jvmArgs` | 所有程序的默认 JVM 参数(如 `["-Xmx2g"]`) |
89
+ | 程序条目 `jvmArgs` / `args` / `env` | 该程序的 JVM 参数 / 应用参数 / 环境变量(`jvmArgs` 覆盖 `defaults`) |
90
+ | `startCommand` | 覆盖自动拼装的启动命令(自定义启动方式;含调试占位符 `${JDWP_ARGS}`) |
91
+ | `logName` / `stopGraceMs` / `disabled` | 日志文件名 / 停止宽限(默认 5000 ms) / 是否跳过该程序 |
92
+
93
+ 字段的完整语义、扫描规则与冲突处理见 [DEVELOPMENT.md](https://gitee.com/riscloud/dsh-ris-ide/blob/master/DEVELOPMENT.md) §2.6。
94
+
95
+ ## 权限与安全
96
+
97
+ 能力边界(插件**只在以你身份运行的本地 DSH 里**工作):
98
+
99
+ - **启动/停止进程**:按工作区扫描结果执行 Maven / npm 启动命令,停止时按 pidfile 校验后杀整个进程组;不监听公网端口、不做远程控制。
100
+ - **文件读写**:宿主路由只服务 loopback 请求,路径经 `cwd` 解析并限制在工作区内;写入走 `/dsh-ris-ide/fs/write`。
101
+ - **环境变量与日志**:读取运行时 `PATH`/`HOME` 等以拼装启动命令,日志只读工作区内 `.log` 文件。
102
+ - **无遥测**:不向外发送任何请求(除浏览器从本机 DSH 宿主拉 `/dsh-ris-ide/*` 与 SSE)。
103
+ - 所有 HTTP 路由都拒绝非 loopback Host/Origin,避免被本机其它网页借用。
104
+
105
+ ## Windows
106
+
107
+ 已支持:启动包装自动选择 **Git Bash**(有则用同款 `run-with-log.sh`)或原生 **PowerShell**(`run-with-log.ps1`,零依赖),停止用 `taskkill /T` 杀整棵进程树。WSL bash 不支持,需要时用 `startCommand` 包 `wsl.exe`。细节见 [DEVELOPMENT.md](https://gitee.com/riscloud/dsh-ris-ide/blob/master/DEVELOPMENT.md) §1.5。
108
+
109
+ ## 卸载
110
+
111
+ ```bash
112
+ dsh plugin --profile web remove dsh-ris-ide # 同时移除 bundles 条目
113
+ ```
114
+
115
+ 然后重启 `dsh web`。
116
+
117
+ ## 源码与镜像
118
+
119
+ - Gitee(主仓库):<https://gitee.com/riscloud/dsh-ris-ide>
120
+ - GitHub(只读镜像,每 2 小时从 Gitee 自动同步):<https://github.com/ris-cloud/dsh-ris-ide>——请勿在 GitHub 侧提交,会被下一次同步覆盖。
121
+ - npm 包的 `repository` 字段指向 GitHub 镜像:npm 的 OIDC 可信发布会自动生成 provenance,要求 `repository` 与构建所在的 GitHub 仓库一致;源码仍以 Gitee 为准(`homepage`/`bugs` 与本文档均指向 Gitee)。
122
+
123
+ ## 开发
124
+
125
+ 打包与发布、路由与内部实现、测试、里程碑与踩坑都在 **[DEVELOPMENT.md](https://gitee.com/riscloud/dsh-ris-ide/blob/master/DEVELOPMENT.md)**;上架规则与提交步骤在**仓库外**的插件开发手册里——本仓库只放符合规范的配置与 `npm run check:listing` 自检。
@@ -0,0 +1,173 @@
1
+ #requires -Version 5.1
2
+ <#
3
+ .SYNOPSIS
4
+ dsh-ris-ide 的 Windows wrapper:把服务输出写进日志、写 pidfile、让面板能停能查。
5
+ (Linux/macOS 用同目录的 run-with-log.sh,本脚本是它的零依赖等价物。)
6
+
7
+ .DESCRIPTION
8
+ 由插件宿主以
9
+ powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File run-with-log.ps1 -SpecBase64 <b64>
10
+ 启动。`-SpecBase64` 是 UTF-8 JSON 的 base64,字段:
11
+ { logName, logDir, kind: 'spring'|'frontend'|'command', dir,
12
+ mainClass?, args?: string[], jvmArgs?: string[],
13
+ packageManager?, script?, command? }
14
+
15
+ - spring : 依次 `mvn -Pdev compile`、`mvn -Pdev dependency:build-classpath …`,
16
+ 然后 `java -DJM.LOG.PATH=… -Dcsp.sentinel.log.dir=… <jvmArgs> -cp "target\classes;<cp>" <mainClass> <args>`;
17
+ - frontend : `<pnpm|npm|yarn>.cmd run <script>`;
18
+ - command : 用户的 `startCommand`,交给 `cmd.exe /d /s /c` 执行。
19
+
20
+ pidfile 与 .sh 一致:`<log>.pid` 一行 `<servicePid> <wrapperPid> <epochMs> [<stopGraceMs>]`。
21
+ 停止由宿主 `taskkill /PID <wrapperPid> /T /F` 完成(Windows 没有 POSIX 信号/进程组,
22
+ /T 杀整棵进程树);本脚本在被正常结束时(服务自己退出)删除 pidfile 并写退出横幅。
23
+ #>
24
+ param(
25
+ [Parameter(Mandatory = $true)][string]$SpecBase64
26
+ )
27
+
28
+ $ErrorActionPreference = 'Stop'
29
+ $utf8 = New-Object System.Text.UTF8Encoding($false)
30
+
31
+ function Write-Log([string]$Path, [string]$Line) {
32
+ [System.IO.File]::AppendAllText($Path, $Line + "`r`n", $utf8)
33
+ }
34
+
35
+ function Resolve-CommandPath([string[]]$Names) {
36
+ foreach ($Name in $Names) {
37
+ $Command = Get-Command -Name $Name -CommandType Application, ExternalScript -ErrorAction SilentlyContinue
38
+ if ($null -ne $Command) { return $Command.Source }
39
+ }
40
+ return $null
41
+ }
42
+
43
+ function Quote-WinArg([string]$Value) {
44
+ if ($Value -eq '') { return '""' }
45
+ if ($Value -match '^[A-Za-z0-9_\./\\:=+,@%\-]+$') { return $Value }
46
+ # 双引号包裹;内部的 " 用 \" 转义(.NET/菜系解析器通用做法)。
47
+ return '"' + ($Value -replace '"', '\"') + '"'
48
+ }
49
+
50
+ $SpecJson = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($SpecBase64))
51
+ $Spec = $SpecJson | ConvertFrom-Json
52
+
53
+ $LogName = [string]$Spec.logName
54
+ $LogDir = [string]$Spec.logDir
55
+ $Kind = [string]$Spec.kind
56
+ if ([string]::IsNullOrWhiteSpace($Kind)) { $Kind = 'command' }
57
+ $WorkDir = [string]$Spec.dir
58
+
59
+ New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
60
+ $LogFile = Join-Path $LogDir ($LogName + '.log')
61
+ $PidFile = $LogFile + '.pid'
62
+ $CspDir = Join-Path $LogDir 'csp'
63
+ New-Item -ItemType Directory -Force -Path $CspDir | Out-Null
64
+
65
+ Write-Log $LogFile ('--- ' + $LogName + ' started ' + (Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz') + ' ---')
66
+ Write-Log $LogFile ('--- kind: ' + $Kind + ' dir: ' + $WorkDir + ' ---')
67
+
68
+ $Exe = $null
69
+ $ArgList = @()
70
+
71
+ try {
72
+ switch ($Kind) {
73
+ 'spring' {
74
+ $Mvn = Resolve-CommandPath @('mvn.cmd', 'mvn.bat', 'mvn')
75
+ if ($null -eq $Mvn) { throw 'mvn not found on PATH' }
76
+ Push-Location -LiteralPath $WorkDir
77
+ try {
78
+ Write-Log $LogFile '--- mvn -Pdev compile ---'
79
+ & $Mvn -Pdev -q compile 2>&1 | ForEach-Object { Write-Log $LogFile ([string]$_) }
80
+ if ($LASTEXITCODE -ne 0) { throw ('mvn compile failed (exit ' + $LASTEXITCODE + ')') }
81
+ Write-Log $LogFile '--- mvn dependency:build-classpath ---'
82
+ & $Mvn -Pdev -q dependency:build-classpath '-Dmdep.includeScope=runtime' '-Dmdep.outputFile=target/cp.txt' 2>&1 | ForEach-Object { Write-Log $LogFile ([string]$_) }
83
+ if ($LASTEXITCODE -ne 0) { throw ('mvn build-classpath failed (exit ' + $LASTEXITCODE + ')') }
84
+ } finally {
85
+ Pop-Location
86
+ }
87
+ $CpFile = Join-Path $WorkDir 'target\cp.txt'
88
+ if (-not (Test-Path -LiteralPath $CpFile)) { throw ('classpath file not found: ' + $CpFile) }
89
+ $ClassPath = 'target\classes;' + ((Get-Content -LiteralPath $CpFile -Raw).Trim())
90
+ $Exe = Resolve-CommandPath @('java.exe', 'java')
91
+ if ($null -eq $Exe) { throw 'java not found on PATH' }
92
+ $ArgList = @('-DJM.LOG.PATH=' + $LogDir, '-Dcsp.sentinel.log.dir=' + $CspDir)
93
+ foreach ($Item in @($Spec.jvmArgs)) { if (-not [string]::IsNullOrWhiteSpace([string]$Item)) { $ArgList += [string]$Item } }
94
+ $ArgList += @('-cp', $ClassPath)
95
+ $ArgList += [string]$Spec.mainClass
96
+ foreach ($Item in @($Spec.args)) { if (-not [string]::IsNullOrWhiteSpace([string]$Item)) { $ArgList += [string]$Item } }
97
+ }
98
+ 'frontend' {
99
+ $Pm = [string]$Spec.packageManager
100
+ if ([string]::IsNullOrWhiteSpace($Pm)) { $Pm = 'npm' }
101
+ $Exe = Resolve-CommandPath @(($Pm + '.cmd'), ($Pm + '.bat'), $Pm)
102
+ if ($null -eq $Exe) { throw ($Pm + ' not found on PATH') }
103
+ $Script = [string]$Spec.script
104
+ if ([string]::IsNullOrWhiteSpace($Script)) { $Script = 'dev' }
105
+ $ArgList = @('run', $Script)
106
+ }
107
+ default {
108
+ $Exe = Resolve-CommandPath @('cmd.exe')
109
+ if ($null -eq $Exe) { throw 'cmd.exe not found' }
110
+ $ArgList = @('/d', '/s', '/c', [string]$Spec.command)
111
+ }
112
+ }
113
+ } catch {
114
+ Write-Log $LogFile ('--- ' + $LogName + ' failed to start: ' + $_.Exception.Message + ' ---')
115
+ exit 1
116
+ }
117
+
118
+ Write-Log $LogFile ('--- exec: ' + $Exe + ' ' + (($ArgList | ForEach-Object { Quote-WinArg ([string]$_) }) -join ' ') + ' ---')
119
+
120
+ $StartInfo = New-Object System.Diagnostics.ProcessStartInfo
121
+ $StartInfo.FileName = $Exe
122
+ $StartInfo.WorkingDirectory = $WorkDir
123
+ $StartInfo.UseShellExecute = $false
124
+ $StartInfo.RedirectStandardOutput = $true
125
+ $StartInfo.RedirectStandardError = $true
126
+ $StartInfo.CreateNoWindow = $true
127
+ $StartInfo.Arguments = (($ArgList | ForEach-Object { Quote-WinArg ([string]$_) }) -join ' ')
128
+
129
+ $Process = New-Object System.Diagnostics.Process
130
+ $Process.StartInfo = $StartInfo
131
+ if (-not $Process.Start()) { throw ('failed to start ' + $Exe) }
132
+
133
+ # pidfile: <servicePid> <wrapperPid> <epochMs> [<stopGraceMs>]
134
+ $GraceMs = 0
135
+ if (-not [string]::IsNullOrWhiteSpace($env:DSH_STOP_GRACE_MS)) {
136
+ $Parsed = 0
137
+ if ([int]::TryParse($env:DSH_STOP_GRACE_MS, [ref]$Parsed)) { $GraceMs = $Parsed }
138
+ }
139
+ $EpochMs = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds()
140
+ Set-Content -LiteralPath $PidFile -Value ($Process.Id.ToString() + ' ' + $PID.ToString() + ' ' + $EpochMs.ToString() + ' ' + $GraceMs.ToString()) -Encoding Ascii
141
+
142
+ # 输出实时进日志(BeginOutputReadLine + 事件处理器,避免缓冲区互锁)。
143
+ $Stream = New-Object System.IO.FileStream($LogFile, [System.IO.FileMode]::Append, [System.IO.FileAccess]::Write, [System.IO.FileShare]::ReadWrite)
144
+ $Writer = New-Object System.IO.StreamWriter($Stream, $utf8)
145
+ $Writer.AutoFlush = $true
146
+ $State = [hashtable]::Synchronized(@{ Writer = $Writer })
147
+ $StdoutEvent = Register-ObjectEvent -InputObject $Process -EventName OutputDataReceived -MessageData $State -Action {
148
+ $Line = $Event.SourceEventArgs.Data
149
+ if ($null -ne $Line) { $Event.MessageData.Writer.WriteLine($Line) }
150
+ }
151
+ $StderrEvent = Register-ObjectEvent -InputObject $Process -EventName ErrorDataReceived -MessageData $State -Action {
152
+ $Line = $Event.SourceEventArgs.Data
153
+ if ($null -ne $Line) { $Event.MessageData.Writer.WriteLine($Line) }
154
+ }
155
+
156
+ $Status = 1
157
+ try {
158
+ $Process.BeginOutputReadLine()
159
+ $Process.BeginErrorReadLine()
160
+ $Process.WaitForExit()
161
+ $Status = $Process.ExitCode
162
+ } finally {
163
+ foreach ($Event in @($StdoutEvent, $StderrEvent)) {
164
+ if ($null -ne $Event) { Unregister-Event -SourceIdentifier $Event.Name -ErrorAction SilentlyContinue }
165
+ }
166
+ $Writer.Flush()
167
+ $Writer.Dispose()
168
+ $Stream.Dispose()
169
+ Remove-Item -LiteralPath $PidFile -Force -ErrorAction SilentlyContinue
170
+ }
171
+
172
+ Write-Log $LogFile ('--- ' + $LogName + ' exited ' + (Get-Date -Format 'yyyy-MM-ddTHH:mm:sszzz') + ' (status ' + $Status + ') ---')
173
+ exit $Status
@@ -0,0 +1,136 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # dsh-ris-ide — run a command with its output mirrored into a log file that
4
+ # the sidebar's `.log` viewer can follow live.
5
+ #
6
+ # run-with-log.sh <log-name> <command> [args...]
7
+ # run-with-log.sh ris-model java -cp "target/classes:$(cat target/cp.txt)" Main
8
+ #
9
+ # It prints one line BEFORE starting anything:
10
+ #
11
+ # LOG_FILE=/abs/path/to/ris-model.log
12
+ #
13
+ # and that path is what you hand to the sidebar opener (the model tool
14
+ # `sidebar_open`, which opens it in the calling conversation's sidebar):
15
+ # sidebar_open(target: '/abs/path/to/ris-model.log', title: 'ris-model 日志')
16
+ #
17
+ # Output is mirrored with `tee`, so the terminal session (and a background
18
+ # job's job_output) still see everything the service prints — the file is an
19
+ # addition, not a redirection. The file is created immediately (a `--- started
20
+ # ---` banner is written first), so the path already exists and is valid to open
21
+ # before the service prints its first line.
22
+ #
23
+ # Why a file at all: the sidebar's own file reader returns the file HEAD (first
24
+ # 512 KiB), so for a long-running log the newest lines would be missing; the
25
+ # sidebar viewer prefers this plugin's tail route (last N bytes) and falls back
26
+ # to the head reader only when the tail route is unavailable.
27
+ #
28
+ # Stop button: this script also writes `<log>.pid` — one line of
29
+ # `<servicePid> <wrapperPid> <epochMs> [<stopGraceMs>]`. The viewer's "停止服务" button POSTs
30
+ # `<page-base>/dsh-ris-ide/stop` with the log path; the host route reads that
31
+ # pidfile, verifies the PID really is this script, TERMs THIS wrapper, and this
32
+ # trap forwards the signal to the service child (TERM → 50×100 ms grace → KILL),
33
+ # removes the pidfile and exits. Nothing ever signals a PID the browser chose.
34
+ #
35
+ # Env:
36
+ # DSH_LOG_DIR directory for the log file (default: <cwd>/logs)
37
+ # DSH_LOG_STAMP 1 = per-run file name `<name>-YYYYmmdd-HHMMSS.log`
38
+ # (default: one stable `<name>.log`, appended across runs)
39
+ #
40
+ # Exit status is the wrapped command's own.
41
+
42
+ set -uo pipefail
43
+
44
+ if [[ $# -lt 2 ]]; then
45
+ printf 'usage: run-with-log.sh <log-name> <command> [args...]\n' >&2
46
+ exit 2
47
+ fi
48
+
49
+ log_name="$1"
50
+ shift
51
+ log_dir="${DSH_LOG_DIR:-$PWD/logs}"
52
+ mkdir -p "$log_dir" || exit 1
53
+
54
+ if [[ "${DSH_LOG_STAMP:-0}" == "1" ]]; then
55
+ log_file="$log_dir/${log_name}-$(date +%Y%m%d-%H%M%S).log"
56
+ else
57
+ log_file="$log_dir/${log_name}.log"
58
+ fi
59
+ # Absolute, because the sidebar opener and the tail route must agree on the
60
+ # same path no matter what the current directory is later.
61
+ log_file="$(cd "$(dirname "$log_file")" && printf '%s/%s' "$PWD" "$(basename "$log_file")")"
62
+
63
+ printf -- '--- %s started %s ---\n' "$log_name" "$(date -Is 2>/dev/null || date)" >>"$log_file" || exit 1
64
+ printf 'LOG_FILE=%s\n' "$log_file"
65
+ printf -- '--- command: %s ---\n' "$*" >>"$log_file"
66
+
67
+ # Start the service in the background so we know ITS pid, and mirror its output
68
+ # through tee. The pidfile next to the log is what the plugin's stop button
69
+ # reads — one line: "<servicePid> <wrapperPid> <epochMs>".
70
+ "$@" > >(tee -a "$log_file") 2>&1 &
71
+ child=$!
72
+ pid_file="$log_file.pid"
73
+ # 4th field (optional): the stop grace the host should honor, so a per-program
74
+ # `stopGraceMs` from the generated config survives until the stop happens.
75
+ printf '%s %s %s %s\n' "$child" "$$" "$(date +%s%3N 2>/dev/null || echo 0)" "${DSH_STOP_GRACE_MS:-}" >"$pid_file"
76
+
77
+ # The plugin signals THIS wrapper (never the service directly). A plain
78
+ # `kill $child` is not enough for dev servers: the child is usually a runner
79
+ # (`bash -lc ... && exec pnpm run dev`) and pnpm does NOT forward SIGTERM, so the
80
+ # real process (`sh -c vite` → node/vite) survives, keeps its port, and the next
81
+ # start finds the port busy. The plugin spawns this wrapper detached, so the
82
+ # wrapper leads its own process group and every descendant inherits it — signal
83
+ # the GROUP instead. When run by hand from a shell (not a group leader) we keep
84
+ # signalling only the child, so a manual run never touches unrelated processes.
85
+ own_pgid="$(ps -o pgid= -p $$ 2>/dev/null | tr -d ' ')"
86
+ group_leader=0
87
+ if [[ -n "$own_pgid" && "$own_pgid" == "$$" ]]; then group_leader=1; fi
88
+
89
+ signal_tree() {
90
+ local sig="$1" target="$child"
91
+ if [[ "$group_leader" == "1" ]]; then target="-$own_pgid"; fi
92
+ kill -s "$sig" -- "$target" 2>/dev/null
93
+ }
94
+
95
+ # Last resort after the grace loop: KILL whatever is left in the group, but
96
+ # never ourselves (a SIGKILL to the wrapper would skip the pidfile cleanup).
97
+ kill_remaining() {
98
+ if [[ "$group_leader" != "1" ]] || ! command -v pgrep >/dev/null 2>&1; then
99
+ kill -s KILL "$child" 2>/dev/null
100
+ return
101
+ fi
102
+ local pid
103
+ for pid in $(pgrep -g "$own_pgid" 2>/dev/null); do
104
+ [[ "$pid" == "$$" ]] && continue
105
+ kill -s KILL "$pid" 2>/dev/null
106
+ done
107
+ }
108
+
109
+ stopping=0
110
+ on_signal() {
111
+ if [[ "$stopping" == "1" ]]; then return; fi
112
+ stopping=1
113
+ # TERM reaches the wrapper too (it is in the group); the guard above keeps
114
+ # this idempotent.
115
+ signal_tree TERM
116
+ }
117
+ trap on_signal TERM INT
118
+
119
+ wait "$child"
120
+ status=$?
121
+
122
+ if [[ "$stopping" == "1" ]]; then
123
+ for _ in $(seq 1 50); do
124
+ kill -0 "$child" 2>/dev/null || break
125
+ sleep 0.1
126
+ done
127
+ kill_remaining
128
+ wait "$child" 2>/dev/null
129
+ status=143
130
+ fi
131
+
132
+ rm -f "$pid_file"
133
+ printf -- '--- %s exited %s (status %s) ---\n' "$log_name" "$(date -Is 2>/dev/null || date)" "$status" >>"$log_file"
134
+ # Let tee drain the pipe before the process exits, so the banner keeps its order.
135
+ sleep 0.15
136
+ exit "$status"
@@ -0,0 +1,25 @@
1
+ # dsh-ris-ide bundle patch
2
+ #
3
+ # This file is the `dsh.bundle.patch` layer of this local package: the DSH
4
+ # profile launcher merges it after every bundle layer, and the single `insert`
5
+ # below mounts the plugin row.
6
+ #
7
+ # Install + mount in one step (from anywhere):
8
+ #
9
+ # dsh plugin --profile web add ~/workspace/dsh-ris-ide
10
+ #
11
+ # The `dsh plugin` command forwards to pnpm inside the profile directory and
12
+ # reconciles `dsh.profile.bundles` against the installed packages, so seeing
13
+ # `dsh.bundle.patch` in package.json it appends `dsh-ris-ide` to the bundle
14
+ # stack as well. Remove with:
15
+ #
16
+ # dsh plugin --profile web remove dsh-ris-ide
17
+ #
18
+ # Contract note (same trap the ego-browser plugin documents): the Loader
19
+ # specifier (`name`) MUST equal the package.json `name` — the client-modules
20
+ # scan matches the nearest package manifest against the Loader specifier and
21
+ # keys the browser bundle by the package name (`window.__ModuleLoader__.load`
22
+ # id `dsh-ris-ide`). Only the row `id` may be a short alias.
23
+ - insert:
24
+ - id: ris-ide
25
+ name: 'dsh-ris-ide'