feat(vscode): 插件工程调试扩展(plg.json 诊断 / SDK 版本 / 构建运行 / 内核日志跟随)

插件的真实形态是「独立子进程 + 内核侧握手」,所以插件问题几乎都在 IDE 之外发生:
编不出来(多半是没声明用哪版 SDK,工具链拿了存储里的 current)、编出来起不来
(产物与内核协议绑定)、起来了行为不对(真因只在内核日志里)。这个扩展把这三件事
拉进 IDE。

- `tools/vscode-hmapdev`:TypeScript 扩展(零运行时依赖,仅 devDeps: typescript + @types/vscode)
  - **plg.json 诊断**:必需字段;`sdk` 必须是完整版本号(区间写法 `1.2` 报错并说明
    「patch 位恒为 .0」,与工具链 ResolveSDKForProject 同一套规矩);声明的 SDK 未安装时
    直接给 `hmapdev sdk install vX.Y.Z`;
  - **状态栏**:`插件 · SDK <声明> · hmapdev <版本>`,工具链缺失/工程有错时变色,tooltip 列已装 SDK;
  - **命令**:build / build --target all / clean / debug(解释执行)/ 工具链版本 / SDK 列表-安装-切换 /
    跟随内核日志 / 停止跟随 / 刷新;
  - **任务**:同一批动作注册为 `hmapdev` 任务 + Go 问题匹配器(编译错误进 Problems);
  - **内核日志跟随**:读 `<dataDir>/log` 最新 `homed_*.log`,按插件名过滤持续输出;
  - schema 校验 + plg.json 骨架片段。
- 诚实边界(写进 README):**不是源码级调试器**——没有断点/单步,没有 DAP 会话;
  做的是构建、运行、看内核日志、清单校验。

验证:`tsc` 零错误;`node --test` 13/13(版本规则、诊断分级、SDK 列表解析、状态栏文本、
以及**反向核对抓到的真缺陷**:`hmapdev 未找到` 这类输出曾被解析成版本号 → 已要求版本
token 以数字开头,否则「工具链不在」会被显示成「工具链 <垃圾词>」并让 SDK 诊断失真);
与真实工具链输出的集成核对 PASS(`hmapdev version` / `sdk list` 的真输出解析正确)。

README 同时补两节:plg.json 的 `sdk` 字段语义(含「为什么必须有」与「为什么拒区间写法」)、
本扩展的用法与边界。
This commit is contained in:
JianFeeeee
2026-09-12 16:07:34 +08:00
parent 9206353858
commit fcb7490f63
13 changed files with 1138 additions and 0 deletions

3
tools/vscode-hmapdev/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules/
out/
*.vsix

View File

@ -0,0 +1,5 @@
src/
tsconfig.json
node_modules/
out/test/
.gitignore

View File

@ -0,0 +1,69 @@
# hmapdev — HomeAgent 插件开发 VSCode 扩展
调试与构建 HomeAgent 插件工程的 IDE 支持:**plg.json 校验、SDK 版本解析、构建/运行、内核日志跟随**。
## 为什么需要它
插件的真实形态是「**独立子进程 + 内核侧握手**」,所以插件的三类问题几乎都在 IDE 之外发生:
1. **编不出来** —— 最常见的原因不是代码,而是项目没声明要用哪版 SDK工具链拿了存储里的
`current`(可能是陈旧的 `v0.8.0`),于是报一堆看不懂的 `undefined: sdk.XXX`
2. **编出来但起不来** —— 产物与内核**协议绑定**(协议版本 + 共享内存魔数),用错工具链编出来的
插件会在握手时被拒;
3. **起来了但行为不对** —— 真因往往只在内核日志里(建链失败、崩溃重启、工具报错)。
本扩展把这三件事拉进 IDE**先把「用哪版 SDK」摆到明面上**,再让构建/运行/看日志变成一条动作链。
## 功能
| 功能 | 说明 |
|---|---|
| **plg.json 诊断** | 必需字段;`sdk` 必须是**完整版本号**(区间写法 `1.2` 会报错并说明「patch 位恒为 .0」);声明的 SDK 若未安装在本地存储,直接给出 `hmapdev sdk install vX.Y.Z` |
| **状态栏** | `插件 · SDK <声明> · hmapdev <版本>`;工具链缺失或工程有错时变红/黄tooltip 列出已装 SDK |
| **构建 / 清理 / 运行** | `hmapdev build``build --target all``clean``debug`(解释执行,快速迭代)——在集成终端里跑,可 Ctrl-C |
| **任务Tasks** | 同一批动作注册为 `hmapdev` 任务,可绑快捷键、串依赖;带 **Go 问题匹配器**,编译错误进 Problems 面板 |
| **跟随内核日志** | 读 `<dataDir>/log` 下最新的 `homed_*.log`,按插件名过滤后持续输出(真正的联调回路) |
| **SDK 管理** | 查看工具链版本、列出/安装/切换 SDK 版本(走 QuickPick不用记命令 |
| **JSON 支持** | `plg.json` 的 schema 校验 + 骨架片段 |
## 安装
```bash
cd tools/vscode-hmapdev
npm install
npm run compile
```
然后二选一:
- **开发模式**:在 VSCode 里打开本目录,按 `F5`Extension Development Host把插件工程目录作为工作区打开
- **安装到本机**`npx @vscode/vsce package` 生成 `.vsix`,再 `code --install-extension hmapdev-vscode-0.1.0.vsix`
前提:`hmapdev``PATH` 上(或设置 `hmapdev.path`)。
## 配置
| 设置 | 默认 | 说明 |
|---|---|---|
| `hmapdev.path` | `hmapdev` | 工具链可执行文件路径 |
| `hmapdev.kernelDataDir` | 空 | 内核数据目录(`homed -data` 的那个);填了才能跟随内核日志 |
| `hmapdev.diagnoseSdk` | `true` | 是否校验声明的 SDK 是否已安装(需要能执行 hmapdev |
## 用法(典型开发回路)
1. 打开插件工程(含 `plg.json`)→ 状态栏出现 `插件 · SDK <版本> · hmapdev <版本>`
2.`sdk` 报错(未声明 / 区间写法 / 未安装)→ 按提示执行 `hmapdev: 安装 SDK 版本…`,再 `hmapdev: 刷新状态`
3. `hmapdev: 构建插件`(或 `构建(全部目标平台)`)→ 编译错误直接进 Problems
4. 快速验证行为:`hmapdev: 运行插件(解释执行)`
5. 与内核联调:设置 `hmapdev.kernelDataDir``hmapdev: 跟随内核日志`,只看本插件的行;
6. 改代码 → 重复 3/5。装进内核时记得**与内核同批替换**(协议绑定的产物不支持滚动升级)。
## 诚实的边界
- **这不是源码级调试器**:没有断点/单步。插件的 Go 代码要么编译成产物在内核里跑、要么用
`hmapdev debug`yaegi 解释执行)跑,两条路都不提供 DAP 调试会话。本扩展做的是
「构建 + 运行 + 看内核日志 + 清单校验」,这也是插件问题实际能被定位的方式。
- **Windows 目标**:不支持(协议 2 的统一共享内存区未移植到 Windows内核侧改走 WSL2
扩展只给提示,不假装能构建。
- **`sdk` 字段的语义**:它声明的是**本插件针对的 SDK 版本**= 接口线),不是内核版本。
SDK 版本跟随内核中版本、patch 位恒为 `.0`

59
tools/vscode-hmapdev/package-lock.json generated Normal file
View File

@ -0,0 +1,59 @@
{
"name": "hmapdev-vscode",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "hmapdev-vscode",
"version": "0.1.0",
"license": "AGPL-3.0-only",
"devDependencies": {
"@types/node": "^22.0.0",
"@types/vscode": "^1.85.0",
"typescript": "^5.6.0"
},
"engines": {
"vscode": "^1.85.0"
}
},
"node_modules/@types/node": {
"version": "22.20.2",
"resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.2.tgz",
"integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/@types/vscode": {
"version": "1.137.0",
"resolved": "https://registry.npmmirror.com/@types/vscode/-/vscode-1.137.0.tgz",
"integrity": "sha512-0dc/BBWxkyUsJzXIZ7PkKSalThmS4xiBT+8YEDiWdCefRKHGVV5ZNkM5NB5ULYamallYJujIfncNoXWFlyzL8A==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

View File

@ -0,0 +1,174 @@
{
"name": "hmapdev-vscode",
"displayName": "HomeAgent Plugin Dev (hmapdev)",
"description": "调试与构建 HomeAgent 插件工程plg.json 校验、SDK 版本解析、hmapdev 构建/运行、内核日志跟随。",
"version": "0.1.0",
"publisher": "JianFeeeee",
"license": "AGPL-3.0-only",
"private": true,
"engines": {
"vscode": "^1.85.0"
},
"categories": [
"Programming Languages",
"Debuggers",
"Other"
],
"main": "./out/extension.js",
"activationEvents": [
"workspaceContains:plg.json",
"workspaceContains:**/plg.json"
],
"contributes": {
"commands": [
{
"command": "hmapdev.build",
"title": "hmapdev: 构建插件"
},
{
"command": "hmapdev.buildAll",
"title": "hmapdev: 构建插件(全部目标平台)"
},
{
"command": "hmapdev.clean",
"title": "hmapdev: 清理产物"
},
{
"command": "hmapdev.run",
"title": "hmapdev: 运行插件(解释执行,快速迭代)"
},
{
"command": "hmapdev.showVersion",
"title": "hmapdev: 查看工具链版本"
},
{
"command": "hmapdev.listSdk",
"title": "hmapdev: 列出 SDK 版本"
},
{
"command": "hmapdev.installSdk",
"title": "hmapdev: 安装 SDK 版本…"
},
{
"command": "hmapdev.useSdk",
"title": "hmapdev: 切换当前 SDK 版本…"
},
{
"command": "hmapdev.tailKernelLog",
"title": "hmapdev: 跟随内核日志(按插件过滤)"
},
{
"command": "hmapdev.stopTailKernelLog",
"title": "hmapdev: 停止跟随内核日志"
},
{
"command": "hmapdev.openPlgJson",
"title": "hmapdev: 打开 plg.json"
},
{
"command": "hmapdev.refresh",
"title": "hmapdev: 刷新状态(重新探测工具链与 SDK"
}
],
"configuration": {
"title": "HomeAgent Plugin Dev",
"properties": {
"hmapdev.path": {
"type": "string",
"default": "hmapdev",
"description": "hmapdev 可执行文件路径(默认从 PATH 找)。"
},
"hmapdev.kernelDataDir": {
"type": "string",
"default": "",
"description": "内核数据目录homed -data 的那个目录)。填了才能跟随内核日志调试;留空则「跟随内核日志」会先询问。"
},
"hmapdev.diagnoseSdk": {
"type": "boolean",
"default": true,
"description": "校验 plg.json 里声明的 SDK 版本是否已安装在本地 SDK 存储(需要能执行 hmapdev。"
}
}
},
"taskDefinitions": [
{
"type": "hmapdev",
"required": [
"action"
],
"properties": {
"action": {
"type": "string",
"enum": [
"build",
"buildAll",
"clean",
"run"
],
"description": "要执行的 hmapdev 动作。"
},
"cwd": {
"type": "string",
"description": "插件工程目录(默认取 plg.json 所在目录)。"
}
}
}
],
"problemMatchers": [
{
"name": "hmapdev-go",
"owner": "go",
"source": "hmapdev",
"fileLocation": [
"relative",
"${workspaceFolder}"
],
"pattern": [
{
"regexp": "^(.+\\.go):(\\d+):(\\d+):\\s+(.+)$",
"file": 1,
"line": 2,
"column": 3,
"message": 4
},
{
"regexp": "^(.+\\.go):(\\d+):\\s+(.+)$",
"file": 1,
"line": 2,
"message": 3
}
]
}
],
"languages": [
{
"id": "json",
"filenames": [
"plg.json"
]
}
],
"jsonValidation": [
{
"fileMatch": "plg.json",
"url": "./schema/plg.schema.json"
}
],
"snippets": [
{
"language": "json",
"path": "./snippets/plg.json.code-snippets"
}
]
},
"scripts": {
"compile": "tsc -p ./",
"watch": "tsc -watch -p ./",
"test": "tsc -p ./ && node --test out/test/*.test.js"
},
"devDependencies": {
"@types/node": "^22.0.0",
"@types/vscode": "^1.85.0",
"typescript": "^5.6.0"
}
}

View File

@ -0,0 +1,41 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "HomeAgent 插件清单plg.json",
"type": "object",
"required": ["name", "version", "entry"],
"properties": {
"name": {
"type": "string",
"description": "插件名(与目录名一致最省事)"
},
"name_zh": { "type": "string", "description": "中文显示名" },
"name_en": { "type": "string", "description": "英文显示名" },
"version": { "type": "string", "description": "插件自身版本号(如 0.1.0),与内核/SDK 版本无关" },
"description": { "type": "string" },
"author": { "type": "string" },
"entry": {
"type": "string",
"description": "入口产物文件名(子进程模式通常是 plugin.binLua 是 main.lua"
},
"sdk": {
"type": "string",
"pattern": "^v?\\d+\\.\\d+\\.\\d+$",
"description": "本插件针对的 SDK 版本,必须是完整版本号(如 1.2.0。SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本;工具链按此在本地 SDK 存储里选择版本。"
},
"tags": { "type": "array", "items": { "type": "string" } },
"targets": {
"type": "string",
"description": "目标平台,逗号分隔,如 linux/amd64,darwin/arm64windows 目标暂不支持插件产物)"
},
"outdir": { "type": "string", "description": "产物目录(默认 dist" },
"bundle": { "type": "boolean", "description": "是否打包成 .hmap默认 true" },
"sdk_path": {
"type": "string",
"description": "直接指定 SDK 源码目录(本机改 SDK 联调时用);设置后优先于 sdk 字段"
},
"go_version": { "type": "string" },
"replaces": { "type": "object", "additionalProperties": { "type": "string" } },
"source_dirs": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": true
}

View File

@ -0,0 +1,26 @@
{
"plg.json 骨架": {
"prefix": "plg",
"body": [
"{",
" \"name\": \"${1:MyPlugin}\",",
" \"name_zh\": \"${2:中文名}\",",
" \"name_en\": \"${1:MyPlugin}\",",
" \"version\": \"0.1.0\",",
" \"description\": \"${3:插件说明}\",",
" \"author\": \"${4:HomeAgent}\",",
" \"entry\": \"plugin.bin\",",
" \"sdk\": \"${5:1.2.0}\",",
" \"tags\": [\"${1:MyPlugin}\"],",
" \"targets\": \"linux/amd64,darwin/arm64\"",
"}",
"$0"
],
"description": "plg.json 必需字段骨架sdk 必须是完整版本号)"
},
"sdk 字段": {
"prefix": "sdk",
"body": ["\"sdk\": \"${1:1.2.0}\","],
"description": "声明本插件针对的 SDK 版本完整版本号patch 位恒为 .0"
}
}

View File

@ -0,0 +1,174 @@
/**
* 纯逻辑层:不 import vscode便于用 node --test 直接单测。
*
* 这里的规矩必须与工具链一致tools/hmapdev/sdk_resolve.go
* - SDK 版本跟随内核中版本、**patch 位恒为 .0** → 一条内核线只有一个 SDK 版本;
* - 因此 plg.json 的 `sdk` 必须是**完整版本号**x.y.z区间写法"1.2")要报错,
* 否则项目会以为「同一条线里还能挑不同 SDK」。
*/
/** plg.json 的字段(未知字段保留,不做拒绝)。 */
export interface PlgConfig {
name?: string;
name_zh?: string;
name_en?: string;
version?: string;
description?: string;
author?: string;
entry?: string;
sdk?: string;
tags?: string[];
targets?: string;
sdk_path?: string;
outdir?: string;
bundle?: boolean;
replaces?: Record<string, string>;
source_dirs?: string[];
}
/** 诊断级别(与 vscode.DiagnosticSeverity 数值对齐,避免耦合)。 */
export enum Severity {
Error = 0,
Warning = 1,
Information = 2,
Hint = 3,
}
export interface PlgDiagnostic {
severity: Severity;
message: string;
/** plg.json 里的字段名(用于在 JSON 文档里定位)。 */
field?: string;
}
/** 完整版本号x.y.z允许 v 前缀)。 */
export function isFullVersion(v: string): boolean {
return /^v?\d+\.\d+\.\d+$/.test((v ?? "").trim());
}
/** 中版本x.y。 */
export function isMinorVersion(v: string): boolean {
return /^v?\d+\.\d+$/.test((v ?? "").trim());
}
export function normalizeVersion(v: string): string {
return (v ?? "").trim().replace(/^v/, "");
}
/**
* 校验 plg.json。
*
* `installedSdks` 为本地 SDK 存储里已安装的版本(不带 v 前缀);传 undefined 表示
* 没探测(例如工具链不可用),此时只校验格式、不报「未安装」。
*/
export function validatePlg(cfg: PlgConfig, installedSdks?: string[]): PlgDiagnostic[] {
const out: PlgDiagnostic[] = [];
const req = (field: keyof PlgConfig, hint: string) => {
const v = cfg[field];
if (v === undefined || v === null || String(v).trim() === "") {
out.push({ severity: Severity.Error, message: `${field} 不能为空(${hint}`, field: field as string });
}
};
req("name", "插件名,与目录名一致最省事");
req("entry", "入口产物,子进程模式通常是 plugin.bin");
req("version", "插件自身版本号,如 0.1.0");
// SDK 声明:这是「工具链自动选 SDK 版本」的依据,缺了就只能退回 current
if (cfg.sdk === undefined || cfg.sdk === null || String(cfg.sdk).trim() === "") {
out.push({
severity: Severity.Warning,
message: "缺少 sdk 字段:工具链无法据此选择 SDK 版本,会退回存储里的 current换机器/换人后容易编出与预期不符的产物)",
field: "sdk",
});
} else if (isMinorVersion(cfg.sdk)) {
out.push({
severity: Severity.Error,
message:
`sdk 必须是完整版本号(如 "1.2.0"${cfg.sdk} 这种区间写法会让人以为同一条内核线里还能挑不同 SDK。` +
`SDK 版本跟随内核中版本、patch 位恒为 .0,一条内核线只有一个 SDK 版本。`,
field: "sdk",
});
} else if (!isFullVersion(cfg.sdk)) {
out.push({ severity: Severity.Error, message: `sdk 不是合法版本号(写法:"1.2.0"`, field: "sdk" });
} else if (installedSdks && !installedSdks.includes(normalizeVersion(cfg.sdk))) {
const have = installedSdks.length ? installedSdks.join(", ") : "(存储里还没有任何 SDK";
out.push({
severity: Severity.Error,
message: `声明的 SDK ${normalizeVersion(cfg.sdk)} 未安装。已安装:${have}。安装hmapdev sdk install v${normalizeVersion(cfg.sdk)}`,
field: "sdk",
});
}
// 目标平台windows 目前不支持(协议 2 的统一共享内存区未移植)
const targets = (cfg.targets ?? "").toLowerCase();
if (targets.includes("windows")) {
out.push({
severity: Severity.Information,
message: "windows 目标暂不支持插件产物:协议 2 的统一共享内存区未移植 Windows内核改走 WSL2。构建会在该目标上明确报错。",
field: "targets",
});
}
if (!cfg.targets) {
out.push({ severity: Severity.Information, message: "未声明 targets构建时按默认目标处理", field: "targets" });
}
return out;
}
/**
* 解析 `hmapdev sdk list` 的输出,返回已安装版本(去 v 前缀、升序)。
*
* 输出形如:
* Installed SDK versions:
* * v1.2.0
* v0.8.0
* 每行可能带 `*` 标记(当前版本)或前导空格。
*/
export function parseSdkList(text: string): string[] {
const out: string[] = [];
for (const raw of (text ?? "").split(/\r?\n/)) {
const line = raw.trim().replace(/^\*\s*/, "").trim();
const m = /^v?(\d+\.\d+\.\d+)$/.exec(line);
if (m) {
out.push(m[1]);
}
}
return out.sort(compareVersions);
}
/**
* 解析 `hmapdev version` 的自述(首行形如 `hmapdev 1.2.0`)。
*
* 只认**以数字开头**的版本 token否则 `hmapdev 未找到` / `hmapdev error`
* 这类输出会被当成版本号,把「工具链不在」误报成「工具链 1.x」
* (状态栏与「是否已装 SDK」的判断都基于它假版本会让诊断全面失真
*/
export function parseToolchainVersion(text: string): string {
for (const raw of (text ?? "").split(/\r?\n/)) {
const m = /^hmapdev\s+v?(\d+(?:\.\d+)*(?:[-+.][0-9A-Za-z.-]+)?)\s*$/.exec(raw.trim());
if (m) {
return m[1];
}
}
return "";
}
/** 数值比较 x.y.z字典序会把 1.2.9 排在 1.2.10 之后)。 */
export function compareVersions(a: string, b: string): number {
const pa = normalizeVersion(a).split(".").map((n) => parseInt(n, 10) || 0);
const pb = normalizeVersion(b).split(".").map((n) => parseInt(n, 10) || 0);
for (let i = 0; i < 3; i++) {
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
if (d !== 0) {
return d;
}
}
return 0;
}
/** 状态栏文本:插件 + 声明 SDK + 工具链版本(缺项用 "?")。 */
export function statusBarText(cfg: PlgConfig | undefined, toolchainVersion: string): string {
const name = cfg?.name?.trim() || "(未识别插件)";
const sdk = cfg?.sdk ? normalizeVersion(cfg.sdk) : "未声明";
const tc = toolchainVersion ? `hmapdev ${toolchainVersion}` : "hmapdev 未找到";
return `${name} · SDK ${sdk} · ${tc}`;
}

View File

@ -0,0 +1,272 @@
import * as cp from "child_process";
import * as path from "path";
import * as vscode from "vscode";
import { PlgConfig, Severity, statusBarText, validatePlg } from "./core";
import { Toolchain } from "./toolchain";
let out: vscode.OutputChannel;
let tc: Toolchain;
let status: vscode.StatusBarItem;
let diagnostics: vscode.DiagnosticCollection;
let tailChild: cp.ChildProcess | undefined;
/** 找到工作区里的 plg.json多个时取第一个并提示。 */
async function findPlg(): Promise<vscode.Uri | undefined> {
const found = await vscode.workspace.findFiles("**/plg.json", "**/{node_modules,out,dist,build}/**", 5);
if (found.length === 0) {
return undefined;
}
return found[0];
}
async function readPlg(uri: vscode.Uri): Promise<PlgConfig | undefined> {
try {
const txt = Buffer.from(await vscode.workspace.fs.readFile(uri)).toString("utf8");
return JSON.parse(txt) as PlgConfig;
} catch (e) {
out.appendLine(`error: 解析 ${uri.fsPath} 失败:${e instanceof Error ? e.message : String(e)}`);
return undefined;
}
}
function severityToVscode(s: Severity): vscode.DiagnosticSeverity {
switch (s) {
case Severity.Error:
return vscode.DiagnosticSeverity.Error;
case Severity.Warning:
return vscode.DiagnosticSeverity.Warning;
case Severity.Information:
return vscode.DiagnosticSeverity.Information;
default:
return vscode.DiagnosticSeverity.Hint;
}
}
/** 在 JSON 文档里定位字段(找不到就标整个文件,至少让人看见)。 */
function rangeForField(doc: vscode.TextDocument, field?: string): vscode.Range {
if (field) {
const idx = doc.getText().indexOf(`"${field}"`);
if (idx >= 0) {
const start = doc.positionAt(idx);
const end = doc.positionAt(idx + field.length + 2);
return new vscode.Range(start, end);
}
}
return new vscode.Range(new vscode.Position(0, 0), new vscode.Position(0, 0));
}
async function refresh(): Promise<void> {
const uri = await findPlg();
diagnostics.clear();
if (!uri) {
status.text = statusBarText(undefined, await tc.version());
status.tooltip = "工作区里没有找到 plg.json本扩展只在插件工程里工作";
return;
}
const cfg = await readPlg(uri);
if (!cfg) {
return;
}
const tcVersion = await tc.version();
const diagSetting = vscode.workspace.getConfiguration("hmapdev").get<boolean>("diagnoseSdk", true);
const installed = diagSetting ? await tc.sdkList() : undefined;
const doc = await vscode.workspace.openTextDocument(uri);
const items = validatePlg(cfg, installed).map((d) => {
const vd = new vscode.Diagnostic(rangeForField(doc, d.field), d.message, severityToVscode(d.severity));
vd.source = "hmapdev";
return vd;
});
diagnostics.set(uri, items);
const errors = items.filter((d) => d.severity === vscode.DiagnosticSeverity.Error).length;
status.text = `$(tools) ${statusBarText(cfg, tcVersion)}`;
status.backgroundColor = tc.isMissing()
? new vscode.ThemeColor("statusBarItem.errorBackground")
: errors > 0
? new vscode.ThemeColor("statusBarItem.warningBackground")
: undefined;
const installedText = installed ? installed.join(", ") || "(无)" : "(未探测)";
status.tooltip = [
`插件:${cfg.name ?? "?"}`,
`声明 SDK${cfg.sdk ?? "未声明"}`,
`已安装 SDK${installedText}`,
`工具链:${tcVersion ? `hmapdev ${tcVersion}` : "未找到(检查 hmapdev.path / PATH"}`,
`plg.json${uri.fsPath}`,
].join("\n");
status.command = "hmapdev.openPlgJson";
status.show();
}
async function pluginDir(): Promise<string | undefined> {
const uri = await findPlg();
return uri ? path.dirname(uri.fsPath) : undefined;
}
async function withDir(fn: (dir: string) => unknown | Promise<unknown>): Promise<void> {
const dir = await pluginDir();
if (!dir) {
void vscode.window.showWarningMessage("当前工作区没有 plg.json无法定位插件工程。");
return;
}
await fn(dir);
}
export function activate(context: vscode.ExtensionContext): void {
out = vscode.window.createOutputChannel("hmapdev");
tc = new Toolchain(out);
diagnostics = vscode.languages.createDiagnosticCollection("hmapdev");
status = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Left, 100);
context.subscriptions.push(out, diagnostics, status);
const reg = (id: string, fn: () => unknown) =>
context.subscriptions.push(vscode.commands.registerCommand(id, async () => {
try {
await fn();
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
out.appendLine(`error: ${msg}`);
void vscode.window.showErrorMessage(`hmapdev: ${msg}`);
}
}));
reg("hmapdev.build", () => withDir((d) => tc.runInTerminal(["build"], d, "hmapdev build")));
reg("hmapdev.buildAll", () => withDir((d) => tc.runInTerminal(["build", "--target", "all"], d, "hmapdev build all")));
reg("hmapdev.clean", () => withDir((d) => tc.runInTerminal(["clean"], d, "hmapdev clean")));
reg("hmapdev.run", () => withDir((d) => tc.runInTerminal(["debug", d], d, "hmapdev debug")));
reg("hmapdev.showVersion", async () => {
out.show(true);
const v = await tc.version(true);
if (!v) {
void vscode.window.showErrorMessage("找不到 hmapdev请把它放到 PATH或设置 hmapdev.path。");
return;
}
await tc.runAndLog(["version"], process.cwd());
});
reg("hmapdev.listSdk", async () => {
out.show(true);
await tc.runAndLog(["sdk", "list"], process.cwd());
await refresh();
});
reg("hmapdev.installSdk", async () => {
const v = await vscode.window.showInputBox({
title: "安装 SDK 版本",
prompt: '输入完整版本号(如 1.2.0)或 latest。注意SDK 版本跟随内核中版本patch 位恒为 .0。',
placeHolder: "1.2.0",
});
if (!v) {
return;
}
tc.runInTerminal(["sdk", "install", `v${v.replace(/^v/, "")}`], process.cwd(), "hmapdev sdk install");
void vscode.window.showInformationMessage(`安装完成后执行「hmapdev: 刷新状态」以重新校验。`);
});
reg("hmapdev.useSdk", async () => {
const list = await tc.sdkList(true);
if (!list || list.length === 0) {
void vscode.window.showWarningMessage("没有探测到已安装的 SDK 版本先跑「hmapdev: 列出 SDK 版本」看看)。");
return;
}
const pick = await vscode.window.showQuickPick(list, { title: "切换当前 SDK 版本(存储里的 current" });
if (!pick) {
return;
}
tc.runInTerminal(["sdk", "use", `v${pick}`], process.cwd(), "hmapdev sdk use");
setTimeout(() => void refresh(), 1500);
});
reg("hmapdev.tailKernelLog", async () => {
const cfgDir = vscode.workspace.getConfiguration("hmapdev");
let dataDir = cfgDir.get<string>("kernelDataDir", "");
if (!dataDir) {
const answer = await vscode.window.showInputBox({
title: "内核数据目录",
prompt: "homed -data 指向的目录(用于跟随内核日志)。填一次会记住到设置里。",
placeHolder: "/home/newqqagent",
});
if (!answer) {
return;
}
dataDir = answer;
await cfgDir.update("kernelDataDir", dataDir, vscode.ConfigurationTarget.Workspace);
}
const uri = await findPlg();
const filter = uri ? (await readPlg(uri))?.name ?? "" : "";
tailChild?.kill();
tailChild = tc.tailKernelLog(dataDir, filter);
out.show(true);
});
reg("hmapdev.stopTailKernelLog", () => {
if (tailChild) {
tailChild.kill();
tailChild = undefined;
out.appendLine("已停止跟随内核日志");
}
});
reg("hmapdev.openPlgJson", async () => {
const uri = await findPlg();
if (uri) {
await vscode.window.showTextDocument(await vscode.workspace.openTextDocument(uri));
} else {
void vscode.window.showWarningMessage("工作区里没有 plg.json。");
}
});
reg("hmapdev.refresh", async () => {
await tc.sdkList(true);
await tc.version(true);
await refresh();
});
// 任务提供者:把 hmapdev 动作接进 VSCode 的任务体系(可绑定快捷键 / 串联依赖 / 复用问题匹配器)
context.subscriptions.push(
vscode.tasks.registerTaskProvider("hmapdev", {
provideTasks: async () => {
const dir = await pluginDir();
if (!dir) {
return [];
}
const mk = (action: string, label: string, args: string[]) => {
const def: vscode.TaskDefinition = { type: "hmapdev", action };
const exec = new vscode.ProcessExecution(
vscode.workspace.getConfiguration("hmapdev").get<string>("path", "hmapdev") || "hmapdev",
args,
{ cwd: dir }
);
return new vscode.Task(def, vscode.TaskScope.Workspace, label, "hmapdev", exec, ["$hmapdev-go"]);
};
return [
mk("build", "hmapdev: build", ["build"]),
mk("buildAll", "hmapdev: build (all targets)", ["build", "--target", "all"]),
mk("clean", "hmapdev: clean", ["clean"]),
mk("run", "hmapdev: run (interpreted)", ["debug", dir]),
];
},
resolveTask: (task) => task,
})
);
// plg.json 变化 → 重算诊断(含保存与外部修改)
const watcher = vscode.workspace.createFileSystemWatcher("**/plg.json");
context.subscriptions.push(
watcher,
watcher.onDidChange(() => void refresh()),
watcher.onDidCreate(() => void refresh()),
watcher.onDidDelete(() => void refresh())
);
void refresh();
}
export function deactivate(): void {
tailChild?.kill();
tailChild = undefined;
}

View File

@ -0,0 +1,106 @@
import * as assert from "node:assert/strict";
import { test } from "node:test";
import {
PlgConfig,
Severity,
compareVersions,
isFullVersion,
isMinorVersion,
normalizeVersion,
parseSdkList,
parseToolchainVersion,
statusBarText,
validatePlg,
} from "../core";
const good: PlgConfig = { name: "memo", version: "0.1.0", entry: "plugin.bin", sdk: "1.2.0" };
test("isFullVersion / isMinorVersion 区分完整版本与区间写法", () => {
assert.equal(isFullVersion("1.2.0"), true);
assert.equal(isFullVersion("v1.2.0"), true);
assert.equal(isFullVersion("1.2"), false);
assert.equal(isFullVersion("1.2.3.4"), false);
assert.equal(isMinorVersion("1.2"), true);
assert.equal(isMinorVersion("1.2.0"), false);
assert.equal(normalizeVersion("v1.2.0"), "1.2.0");
});
test("合法的 plg.json 不产生错误", () => {
const d = validatePlg(good, ["1.2.0"]);
assert.equal(d.filter((x) => x.severity === Severity.Error).length, 0, JSON.stringify(d));
});
test("缺必需字段要报错并指出字段", () => {
const d = validatePlg({ sdk: "1.2.0" }, ["1.2.0"]);
const fields = d.filter((x) => x.severity === Severity.Error).map((x) => x.field).sort();
assert.deepEqual(fields, ["entry", "name", "version"]);
});
test("区间写法 1.2 必须被拒,并说明 patch 位恒为 .0", () => {
const d = validatePlg({ ...good, sdk: "1.2" }, ["1.2.0"]);
const err = d.find((x) => x.field === "sdk" && x.severity === Severity.Error);
assert.ok(err, "区间写法应报错");
assert.match(err!.message, /完整版本号/);
assert.match(err!.message, /patch 位恒为 \.0/);
});
test("缺 sdk 字段只警告(向后兼容存量项目)", () => {
const d = validatePlg({ name: "memo", version: "0.1.0", entry: "plugin.bin" }, ["1.2.0"]);
const sdk = d.find((x) => x.field === "sdk");
assert.ok(sdk);
assert.equal(sdk!.severity, Severity.Warning);
});
test("声明的 SDK 未安装要报错并给出安装命令", () => {
const d = validatePlg(good, ["0.8.0"]);
const err = d.find((x) => x.field === "sdk" && x.severity === Severity.Error);
assert.ok(err, "未安装应报错");
assert.match(err!.message, /hmapdev sdk install v1\.2\.0/);
assert.match(err!.message, /0\.8\.0/);
});
test("探测不到已装列表时不误报未安装", () => {
const d = validatePlg(good, undefined);
assert.equal(d.filter((x) => x.severity === Severity.Error).length, 0, JSON.stringify(d));
});
test("windows 目标给提示(协议 2 未移植)", () => {
const d = validatePlg({ ...good, targets: "linux/amd64,windows/amd64" }, ["1.2.0"]);
const info = d.find((x) => x.field === "targets");
assert.ok(info);
assert.match(info!.message, /WSL2/);
});
test("parseSdkList 吃掉 * 标记与空格,并按数值排序", () => {
const text = ["Installed SDK versions:", " * v1.2.0", " v0.8.0", " v1.2.10"].join("\n");
assert.deepEqual(parseSdkList(text), ["0.8.0", "1.2.0", "1.2.10"]);
assert.deepEqual(parseSdkList("No SDK versions installed."), []);
});
test("parseToolchainVersion 从 self-report 里取版本", () => {
const text = ["hmapdev 1.2.0", " SDK 模块: gitcode.com/JianFeeeee/homeagent-sdk", " 构建用 Go: go1.25.12"].join("\n");
assert.equal(parseToolchainVersion(text), "1.2.0");
assert.equal(parseToolchainVersion("Usage:\n hmapdev init <name>"), "");
});
// 反向核对抓到的真缺陷:`hmapdev <非版本>` 形状的输出曾被当成版本号,
// 于是「工具链不在」会被显示成「工具链 <垃圾词>」,并让 SDK 诊断跟着失真。
test("parseToolchainVersion 不会把非版本 token 当成版本", () => {
for (const bad of ["hmapdev 未找到", "hmapdev error", "hmapdev not found", "hmapdev -v", "hmapdev"]) {
assert.equal(parseToolchainVersion(bad), "", `不应从 ${JSON.stringify(bad)} 解析出版本`);
}
assert.equal(parseToolchainVersion("hmapdev 1.3.0-dev"), "1.3.0-dev"); // 开发构建的后缀要带出来
assert.equal(parseToolchainVersion("hmapdev v1.2.0"), "1.2.0");
});
test("compareVersions 是数值比较1.2.10 > 1.2.9", () => {
assert.ok(compareVersions("1.2.10", "1.2.9") > 0);
assert.ok(compareVersions("1.2.0", "1.2.0") === 0);
assert.ok(compareVersions("0.8.0", "1.2.0") < 0);
});
test("状态栏文本包含插件、声明 SDK 与工具链版本", () => {
assert.equal(statusBarText(good, "1.2.0"), "memo · SDK 1.2.0 · hmapdev 1.2.0");
assert.equal(statusBarText({ ...good, sdk: undefined }, ""), "memo · SDK 未声明 · hmapdev 未找到");
});

View File

@ -0,0 +1,139 @@
import * as cp from "child_process";
import * as fs from "fs";
import * as path from "path";
import * as vscode from "vscode";
import { parseSdkList, parseToolchainVersion } from "./core";
/** execFile 的 Promise 版(不引第三方依赖)。 */
function execFile(
file: string,
args: string[],
cwd: string,
timeoutMs = 120_000
): Promise<{ code: number; stdout: string; stderr: string }> {
return new Promise((resolve) => {
cp.execFile(file, args, { cwd, timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
const code = err && typeof (err as { code?: number }).code === "number" ? (err as { code: number }).code : err ? 1 : 0;
resolve({ code, stdout: stdout ?? "", stderr: stderr ?? "" });
});
});
}
/**
* 工具链封装:定位 hmapdev、执行命令、缓存 version / sdk list。
*
* 为什么要缓存并显式 refreshSDK 存储会在外部变化(`hmapdev sdk install` 之后),
* 而诊断信息依赖它——不刷新就会一直报「未安装」。
*/
export class Toolchain {
private versionCache?: string;
private sdkCache?: string[];
private missing = false;
constructor(private readonly out: vscode.OutputChannel) {}
private get exe(): string {
return vscode.workspace.getConfiguration("hmapdev").get<string>("path", "hmapdev") || "hmapdev";
}
/** 记录一条消息到输出通道(加上工具链前缀,便于与构建输出区分)。 */
log(line: string): void {
this.out.appendLine(line);
}
async version(refresh = false): Promise<string> {
if (this.versionCache !== undefined && !refresh) {
return this.versionCache;
}
const r = await execFile(this.exe, ["version"], process.cwd(), 20_000);
if (r.code !== 0 && !r.stdout) {
this.missing = true;
this.versionCache = "";
return "";
}
this.missing = false;
this.versionCache = parseToolchainVersion(r.stdout + r.stderr);
return this.versionCache;
}
async sdkList(refresh = false): Promise<string[] | undefined> {
if (this.sdkCache !== undefined && !refresh) {
return this.sdkCache;
}
const r = await execFile(this.exe, ["sdk", "list"], process.cwd(), 20_000);
if (r.code !== 0 && !r.stdout) {
this.sdkCache = undefined; // 探测不到就不做「未安装」判断,避免误报
return undefined;
}
this.sdkCache = parseSdkList(r.stdout + r.stderr);
return this.sdkCache;
}
isMissing(): boolean {
return this.missing;
}
/** 在集成终端里执行(构建/运行这类长命令:要能看进度、能 Ctrl-C。 */
runInTerminal(args: string[], cwd: string, name: string): vscode.Terminal {
const term = vscode.window.createTerminal({ name, cwd });
term.show(true);
const cmd = [this.exe, ...args].map((a) => (/\s/.test(a) ? JSON.stringify(a) : a)).join(" ");
this.log(`$ ${cmd}`);
term.sendText(cmd, true);
return term;
}
/** 一次性执行并把输出写进输出通道(查询类命令)。 */
async runAndLog(args: string[], cwd: string): Promise<number> {
this.log(`$ ${this.exe} ${args.join(" ")}`);
const r = await execFile(this.exe, args, cwd, 60_000);
if (r.stdout) {
this.out.append(r.stdout);
}
if (r.stderr) {
this.out.append(r.stderr);
}
return r.code;
}
/**
* 跟随内核日志:定位 <dataDir>/log 下最新的 homed 日志并按插件名过滤。
*
* 为什么这是「调试插件」的正路:插件是子进程、跑在内核里,真正的问题几乎都
* 表现为内核日志里的几行(握手失败/崩溃重启/工具报错),在 IDE 里跟住它比
* 反复手动 tail 高效得多。
*/
tailKernelLog(dataDir: string, filter: string): cp.ChildProcess | undefined {
const logDir = path.join(dataDir, "log");
if (!fs.existsSync(logDir)) {
this.log(`error: 日志目录不存在:${logDir}hmapdev.kernelDataDir 是否指对?)`);
return undefined;
}
const newest = fs
.readdirSync(logDir)
.filter((f) => f.startsWith("homed_") && f.endsWith(".log"))
.map((f) => ({ f, m: fs.statSync(path.join(logDir, f)).mtimeMs }))
.sort((a, b) => b.m - a.m)[0];
if (!newest) {
this.log(`error: ${logDir} 下没有 homed_*.log`);
return undefined;
}
const file = path.join(logDir, newest.f);
this.log(`跟随 ${file}${filter ? `(过滤 ${filter}` : ""}`);
const child = cp.spawn("tail", ["-F", file], { stdio: ["ignore", "pipe", "pipe"] });
const emit = (buf: Buffer) => {
for (const line of buf.toString("utf8").split(/\r?\n/)) {
if (!line) {
continue;
}
if (!filter || line.includes(filter)) {
this.out.appendLine(line);
}
}
};
child.stdout?.on("data", emit);
child.stderr?.on("data", emit);
return child;
}
}

View File

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "commonjs",
"moduleResolution": "node",
"lib": ["ES2020"],
"outDir": "out",
"rootDir": "src",
"strict": true,
"noImplicitOverride": true,
"noUnusedLocals": true,
"sourceMap": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node", "vscode"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "out"]
}