@seeed-studio/solution-cli 0.3.0 → 0.3.2
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 +35 -15
- package/bin/cli.js +104 -38
- package/bin/runner.js +1 -1
- package/catalog/knowledge-core.json +666 -144
- package/docs/cli-guide.md +58 -17
- package/lib/agent.js +5 -5
- package/lib/detector.js +3 -3
- package/lib/downloader.js +85 -21
- package/lib/installer.js +88 -53
- package/lib/knowledge.js +141 -17
- package/lib/ledger.js +1 -1
- package/package.json +3 -6
- package/skills/{sensecraft → solution-cli}/SKILL.md +40 -14
- package/skills/{sensecraft → solution-cli}/references/consult-and-select.md +4 -0
- package/skills/{sensecraft → solution-cli}/references/device-management.md +45 -13
- package/skills/{sensecraft → solution-cli}/references/diagnose.md +1 -1
- package/skills/{sensecraft → solution-cli}/references/secondary-development.md +2 -5
- /package/skills/{sensecraft → solution-cli}/references/app-only.md +0 -0
- /package/skills/{sensecraft → solution-cli}/references/deploy.md +0 -0
- /package/skills/{sensecraft → solution-cli}/references/error-catalog.md +0 -0
- /package/skills/{sensecraft → solution-cli}/references/hardware-bom.md +0 -0
- /package/skills/{sensecraft → solution-cli}/references/offline.md +0 -0
- /package/skills/{sensecraft → solution-cli}/references/verify.md +0 -0
package/docs/cli-guide.md
CHANGED
|
@@ -30,37 +30,78 @@
|
|
|
30
30
|
`@seeed-studio/solution-cli` 正是为解决这一系列问题而生,它具有三大独特角色:
|
|
31
31
|
1. **方案专家的现场诊断仪**:内置各大行业工况诊断知识库,不仅能搜方案,还能直接指导工程师向客户提出决定成本与物理拓扑的“黄金追问”;
|
|
32
32
|
2. **硬件采购防呆中枢**:内置物理规格匹配引擎,自动核算【必需配件】与【可选配件】,从源头阻断硬件返工;
|
|
33
|
-
3. **AI Agent 的物理世界触手**:一键将
|
|
33
|
+
3. **AI Agent 的物理世界触手**:一键将 solution-cli Skill 注入到主流 AI Agent 宿主中,让 Agent 具备从方案咨询、BOM 选型到现场部署、参数热更的全生命周期驱动能力。
|
|
34
34
|
|
|
35
35
|
---
|
|
36
36
|
|
|
37
37
|
## 2. 安装与 Agent 技能挂载
|
|
38
38
|
|
|
39
39
|
### 2.1 一键免安装即时调用 (推荐)
|
|
40
|
-
|
|
40
|
+
无需全局安装,安装或按需升级 Engine 与 Agent Skill 使用同一条命令:
|
|
41
41
|
```bash
|
|
42
|
-
|
|
43
|
-
npx @seeed-studio/solution-cli --help
|
|
44
|
-
|
|
45
|
-
# 交互式扫描并为本机所有 Agent 安装技能
|
|
46
|
-
npx @seeed-studio/solution-cli install
|
|
42
|
+
npx -y @seeed-studio/solution-cli@latest install --agent workbuddy -y
|
|
47
43
|
```
|
|
48
44
|
|
|
45
|
+
上面的示例明确安装到 WorkBuddy。其他宿主将 `workbuddy` 替换为当前实际的
|
|
46
|
+
Agent ID。需要交互选择时使用 `npx -y @seeed-studio/solution-cli@latest install`;
|
|
47
|
+
检测到一个或多个 Agent 时,直接回车默认选择全部已检测的 Agent,输入对应序号
|
|
48
|
+
选择指定宿主。非交互模式下,`install -y` 未提供 `--agent` 且检测到 Agent 时也会
|
|
49
|
+
选择全部已检测的 Agent。未检测到 Agent 时,这些默认仍使用 CLI 的默认目标集合;
|
|
50
|
+
需要明确控制范围时请提供 `--agent <agent-id>`。
|
|
51
|
+
|
|
52
|
+
首次安装和重复执行都会检查签名稳定版 Engine:本机已有相同或更新版本
|
|
53
|
+
时复用,版本较旧时升级。普通安装保留用户修改、未登记的 Skill 文件和受
|
|
54
|
+
保护的符号链接。需要指定宿主时追加 `--agent claude,antigravity`;需要固
|
|
55
|
+
定有效本地 Engine 时使用 `--engine-path <path>`。`--force` 可重新下载相同
|
|
56
|
+
或较旧版本并覆盖已有 Skill 文件(包括用户修改),请只在有意覆盖时使用;
|
|
57
|
+
受保护的外部符号链接不会追写,也不能降级更新的本机 Engine。
|
|
58
|
+
|
|
59
|
+
自动复用/升级规则自 solution-cli 0.3.1 起生效;该版本已公开发布。latest 检查或签名校验失败会在
|
|
60
|
+
修改 Engine、指针或 Skill 前停止;离线时请提供有效的 `--engine-path`。
|
|
61
|
+
|
|
62
|
+
The same command handles a fresh install and a user-requested upgrade. It checks
|
|
63
|
+
the signed stable Engine, reuses an equal/newer local version, preserves modified
|
|
64
|
+
skills, and stops before writes on latest/signature failure. Automatic reuse and
|
|
65
|
+
upgrade rules start with publicly published solution-cli 0.3.1. The example
|
|
66
|
+
targets WorkBuddy with `--agent workbuddy`; replace it with the current Agent ID
|
|
67
|
+
for another host. Use `npx -y @seeed-studio/solution-cli@latest install` for the
|
|
68
|
+
interactive form: when one or more Agents are detected, pressing Enter selects
|
|
69
|
+
all detected Agents, while entering a number selects one host. In non-interactive
|
|
70
|
+
mode, `install -y` without `--agent` also selects all detected Agents when any
|
|
71
|
+
are detected. If none are detected, these defaults still use the CLI's default
|
|
72
|
+
target set; pass `--agent <agent-id>` to control the scope explicitly.
|
|
73
|
+
|
|
49
74
|
### 2.2 全局安装
|
|
50
75
|
```bash
|
|
51
76
|
npm install -g @seeed-studio/solution-cli
|
|
77
|
+
solution-cli install --agent workbuddy -y
|
|
52
78
|
```
|
|
53
79
|
|
|
80
|
+
全局安装后的挂载规则相同:示例明确选择 WorkBuddy;其他宿主请将
|
|
81
|
+
`workbuddy` 替换为当前实际 Agent ID。交互执行 `solution-cli install` 时,
|
|
82
|
+
检测到一个或多个 Agent 后直接回车选择全部已检测的 Agent,输入对应序号选择指定
|
|
83
|
+
宿主;非交互执行 `solution-cli install -y` 且未提供 `--agent` 时,检测到 Agent
|
|
84
|
+
也会选择全部已检测的 Agent。未检测到 Agent 时,这些默认仍使用 CLI 的默认目标集合;
|
|
85
|
+
需要明确控制范围时请提供 `--agent <agent-id>`。
|
|
86
|
+
|
|
87
|
+
The global command targets WorkBuddy explicitly. Replace `workbuddy` with the
|
|
88
|
+
current Agent ID for another host. Interactive `solution-cli install` selects all
|
|
89
|
+
detected Agents on Enter when any are detected, or one host by number;
|
|
90
|
+
non-interactive `solution-cli install -y` without `--agent` also selects all
|
|
91
|
+
detected Agents when any are detected. If none are detected, these defaults still
|
|
92
|
+
use the CLI's default target set; pass `--agent <agent-id>` to control the scope
|
|
93
|
+
explicitly.
|
|
94
|
+
|
|
54
95
|
### 2.3 跨 Agent 平台技能挂载
|
|
55
|
-
执行 `solution-cli install` 时,工具会自动检测本机存在的 Agent 宿主环境,并将 `
|
|
96
|
+
执行 `solution-cli install` 时,工具会自动检测本机存在的 Agent 宿主环境,并将 `solution-cli` Skill 和全套参考手册安全复制到对应目录:
|
|
56
97
|
|
|
57
98
|
| Agent 宿主平台 | 技能默认配置路径 | 支持能力 |
|
|
58
99
|
|---|---|---|
|
|
59
|
-
| **Claude Code** | `~/.claude/skills/
|
|
60
|
-
| **Codex (OpenAI)** | `~/.agents/skills/
|
|
61
|
-
| **Google Antigravity (AGY)** | `~/.gemini/config/skills/
|
|
62
|
-
| **Work Buddy / Workbody** | `~/.workbuddy/skills/
|
|
63
|
-
| **CodeBuddy** | `~/.codebuddy/skills/
|
|
100
|
+
| **Claude Code** | `~/.claude/skills/solution-cli` | 完整方案咨询、工况诊断、BOM 生成、现场部署 |
|
|
101
|
+
| **Codex (OpenAI)** | `~/.agents/skills/solution-cli` 或 `~/.codex/skills/solution-cli` | 代码审查、参数重构、二次开发自动化 |
|
|
102
|
+
| **Google Antigravity (AGY)** | `~/.gemini/config/skills/solution-cli` | 端到端自主诊断、多 Agent 协同实施 |
|
|
103
|
+
| **Work Buddy / Workbody** | `~/.workbuddy/skills/solution-cli` | 桌面端交互、方案展示、一键部署联动 |
|
|
104
|
+
| **CodeBuddy** | `~/.codebuddy/skills/solution-cli` | 代码库配对开发与自研模型构建 |
|
|
64
105
|
|
|
65
106
|
**非交互式多选安装示例**:
|
|
66
107
|
```bash
|
|
@@ -100,11 +141,11 @@ solution-cli hub search "跌倒检测" --json
|
|
|
100
141
|
把 SenseCAP 土壤与空气传感器接到 Home Assistant 看板上,支持本地私有 ChirpStack 运行,断网可用。
|
|
101
142
|
预设模式: cloud, tts_local, chirpstack_local
|
|
102
143
|
现场工况诊断提问指引:
|
|
103
|
-
• 监测区域是集中式连栋温室大棚(百米级),还是大面积户外大田/果园(数公里级)?
|
|
144
|
+
• 监测区域是集中式连栋温室大棚(百米级),还是大面积户外大田/果园(数公里级)?
|
|
104
145
|
(决断考量: 大棚内百米级可采用 RS485 总线;广域大田推荐 LoRaWAN 远距无线,配 SenseCAP M2 网关)
|
|
105
|
-
• 现场测点是否有 220V 市电供电,还是需要野外太阳能板/锂电池长续航供电?
|
|
146
|
+
• 现场测点是否有 220V 市电供电,还是需要野外太阳能板/锂电池长续航供电?
|
|
106
147
|
(决断考量: 市电支持持续高频数据上报;无市电需选配支持休眠唤醒的超低功耗无线传感器)
|
|
107
|
-
• 需要监测哪些核心指标?仅空气温湿度,还是包括土壤剖面水肥墒情(水分/EC/pH)?
|
|
148
|
+
• 需要监测哪些核心指标?仅空气温湿度,还是包括土壤剖面水肥墒情(水分/EC/pH)?
|
|
108
149
|
(决断考量: 决定是配置多合一微气象站,还是选配工业级土壤三参数探头与变送器)
|
|
109
150
|
```
|
|
110
151
|
|
|
@@ -209,7 +250,7 @@ solution-cli product accessories recomputer_j40 --json
|
|
|
209
250
|
4. **驱动本地快速联调**:
|
|
210
251
|
使用 `--solutions-dir` 参数,强制底层引擎加载本地修改版方案,彻底不碰官方缓存:
|
|
211
252
|
```bash
|
|
212
|
-
sensecraft-engine deploy
|
|
253
|
+
sensecraft-engine deploy fall_detection --solutions-dir ./my-solutions/solutions
|
|
213
254
|
```
|
|
214
255
|
|
|
215
256
|
---
|
package/lib/agent.js
CHANGED
|
@@ -15,8 +15,8 @@ export function getKnownAgents() {
|
|
|
15
15
|
// Check where an existing installation might already be
|
|
16
16
|
const p1 = path.join(home, '.workbuddy', 'skills');
|
|
17
17
|
const p2 = path.join(home, '.workbody', 'skills');
|
|
18
|
-
if (fs.existsSync(path.join(p1, '
|
|
19
|
-
if (fs.existsSync(path.join(p2, '
|
|
18
|
+
if (fs.existsSync(path.join(p1, 'solution-cli', 'SKILL.md'))) return p1;
|
|
19
|
+
if (fs.existsSync(path.join(p2, 'solution-cli', 'SKILL.md'))) return p2;
|
|
20
20
|
if (fs.existsSync(path.join(home, '.workbuddy'))) return p1;
|
|
21
21
|
if (fs.existsSync(path.join(home, '.workbody'))) return p2;
|
|
22
22
|
return p1;
|
|
@@ -36,8 +36,8 @@ export function getKnownAgents() {
|
|
|
36
36
|
// Honor existing installation first to avoid path drift (Codex Medium 13)
|
|
37
37
|
const pAgents = path.join(home, '.agents', 'skills');
|
|
38
38
|
const pCodex = path.join(home, '.codex', 'skills');
|
|
39
|
-
if (fs.existsSync(path.join(pAgents, '
|
|
40
|
-
if (fs.existsSync(path.join(pCodex, '
|
|
39
|
+
if (fs.existsSync(path.join(pAgents, 'solution-cli', 'SKILL.md'))) return pAgents;
|
|
40
|
+
if (fs.existsSync(path.join(pCodex, 'solution-cli', 'SKILL.md'))) return pCodex;
|
|
41
41
|
// Default to .agents/skills if .agents exists, otherwise .codex/skills if .codex exists
|
|
42
42
|
if (fs.existsSync(path.join(home, '.agents'))) return pAgents;
|
|
43
43
|
if (fs.existsSync(path.join(home, '.codex'))) return pCodex;
|
|
@@ -69,7 +69,7 @@ export function scanLocalAgents() {
|
|
|
69
69
|
return agents.map(a => {
|
|
70
70
|
const skillsDir = a.getSkillsDir();
|
|
71
71
|
const detected = a.homeDirs.some(dir => fs.existsSync(dir)) || fs.existsSync(skillsDir);
|
|
72
|
-
const installed = fs.existsSync(path.join(skillsDir, '
|
|
72
|
+
const installed = fs.existsSync(path.join(skillsDir, 'solution-cli', 'SKILL.md'));
|
|
73
73
|
return {
|
|
74
74
|
...a,
|
|
75
75
|
detected,
|
package/lib/detector.js
CHANGED
|
@@ -226,7 +226,7 @@ export function detectDesktopApp() {
|
|
|
226
226
|
appPath: appBase,
|
|
227
227
|
binPath: path.join(appBase, 'Contents', 'MacOS', 'sensecraft-engine'),
|
|
228
228
|
solutionsPath: path.join(appBase, 'Contents', 'Resources', '_up_', 'sensecraft-solutions', 'solutions'),
|
|
229
|
-
skillsPath: path.join(appBase, 'Contents', 'Resources', '_up_', 'sensecraft-solutions', 'skills', '
|
|
229
|
+
skillsPath: path.join(appBase, 'Contents', 'Resources', '_up_', 'sensecraft-solutions', 'skills', 'solution-cli')
|
|
230
230
|
});
|
|
231
231
|
const cacheBin = path.join(
|
|
232
232
|
os.homedir(),
|
|
@@ -258,7 +258,7 @@ export function detectDesktopApp() {
|
|
|
258
258
|
appPath,
|
|
259
259
|
binPath: path.join(appPath, 'resources', 'bin', 'sensecraft-engine.exe'),
|
|
260
260
|
solutionsPath: path.join(appPath, 'resources', 'sensecraft-solutions', 'solutions'),
|
|
261
|
-
skillsPath: path.join(appPath, 'resources', 'sensecraft-solutions', 'skills', '
|
|
261
|
+
skillsPath: path.join(appPath, 'resources', 'sensecraft-solutions', 'skills', 'solution-cli')
|
|
262
262
|
});
|
|
263
263
|
}
|
|
264
264
|
}
|
|
@@ -271,7 +271,7 @@ export function detectDesktopApp() {
|
|
|
271
271
|
appPath,
|
|
272
272
|
binPath: path.join(appPath, 'sensecraft-engine'),
|
|
273
273
|
solutionsPath: path.join(appPath, 'sensecraft-solutions', 'solutions'),
|
|
274
|
-
skillsPath: path.join(appPath, 'skills', '
|
|
274
|
+
skillsPath: path.join(appPath, 'skills', 'solution-cli')
|
|
275
275
|
});
|
|
276
276
|
}
|
|
277
277
|
}
|
package/lib/downloader.js
CHANGED
|
@@ -4,13 +4,41 @@ import fs from 'node:fs';
|
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import { execFileSync } from 'node:child_process';
|
|
6
6
|
import { getPlatformInfo, getEngineBinDir, getEngineBinaryPath, getScHome } from './platform.js';
|
|
7
|
-
import { verifyEngineBinary } from './detector.js';
|
|
7
|
+
import { verifyEngineBinary, parseSemver } from './detector.js';
|
|
8
8
|
|
|
9
9
|
const LATEST_MANIFEST_URL = 'https://sensecraft-statics.seeed.cc/solution-app/engine/latest.json';
|
|
10
|
+
const VERSIONED_MANIFEST_BASE_URL = 'https://sensecraft-statics.seeed.cc/solution-app/engine';
|
|
10
11
|
const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024; // 50MB limit
|
|
11
12
|
const NETWORK_TIMEOUT_MS = 60000; // 60s timeout
|
|
12
13
|
|
|
13
14
|
export const PINNED_MINISIGN_PUBKEY = 'RWTg282IuJHsoMOFZC0DP1q0LrS384gJsCLrXyX3CIzIKLPUHm3GGmBv';
|
|
15
|
+
const verifiedReleases = new WeakSet();
|
|
16
|
+
|
|
17
|
+
function deepFreeze(value) {
|
|
18
|
+
if (!value || typeof value !== 'object' || Object.isFrozen(value)) return value;
|
|
19
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
20
|
+
return Object.freeze(value);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function validateReleaseManifest(manifest, platform) {
|
|
24
|
+
if (!manifest || typeof manifest !== 'object') throw new Error('Engine release manifest is not an object');
|
|
25
|
+
if (manifest.channel !== 'stable') throw new Error(`Engine release manifest channel must be stable (got ${manifest.channel || 'missing'})`);
|
|
26
|
+
const version = manifest.version;
|
|
27
|
+
const parsedVersion = parseSemver(version);
|
|
28
|
+
if (!parsedVersion || parsedVersion.raw !== version || parsedVersion.prerelease) {
|
|
29
|
+
throw new Error(`Engine release manifest contains an invalid release version: ${version || 'missing'}`);
|
|
30
|
+
}
|
|
31
|
+
const asset = manifest.assets?.[platform.target];
|
|
32
|
+
let assetUrl;
|
|
33
|
+
try { assetUrl = new URL(asset?.url); } catch {}
|
|
34
|
+
if (!asset || typeof asset !== 'object' || typeof asset.url !== 'string' || assetUrl?.protocol !== 'https:' || !/^[a-f0-9]{64}$/i.test(asset.sha256 || '')) {
|
|
35
|
+
throw new Error(`No valid compatible release asset found for target "${platform.target}" in version ${version}`);
|
|
36
|
+
}
|
|
37
|
+
if (asset.size !== undefined && (!Number.isInteger(asset.size) || asset.size <= 0 || asset.size > MAX_ARCHIVE_BYTES)) {
|
|
38
|
+
throw new Error(`Declared asset size (${asset.size} bytes) is invalid or exceeds safety maximum (${MAX_ARCHIVE_BYTES} bytes)`);
|
|
39
|
+
}
|
|
40
|
+
return manifest;
|
|
41
|
+
}
|
|
14
42
|
|
|
15
43
|
export function verifyManifestSignature(manifestBytes, sigText, pubKeyB64 = PINNED_MINISIGN_PUBKEY) {
|
|
16
44
|
try {
|
|
@@ -30,20 +58,30 @@ export function verifyManifestSignature(manifestBytes, sigText, pubKeyB64 = PINN
|
|
|
30
58
|
}
|
|
31
59
|
}
|
|
32
60
|
|
|
33
|
-
export async function fetchManifest() {
|
|
61
|
+
export async function fetchManifest({ version } = {}) {
|
|
62
|
+
let manifestUrl = LATEST_MANIFEST_URL;
|
|
63
|
+
if (version !== undefined) {
|
|
64
|
+
const parsedVersion = parseSemver(version);
|
|
65
|
+
if (!parsedVersion || parsedVersion.raw !== version || parsedVersion.prerelease) {
|
|
66
|
+
throw new Error(`Invalid stable Engine release version requested: ${version}`);
|
|
67
|
+
}
|
|
68
|
+
manifestUrl = `${VERSIONED_MANIFEST_BASE_URL}/${version}/manifest.json`;
|
|
69
|
+
}
|
|
34
70
|
const controller = new AbortController();
|
|
35
71
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
72
|
+
let requestUrl = manifestUrl;
|
|
36
73
|
try {
|
|
37
|
-
const res = await fetch(
|
|
74
|
+
const res = await fetch(requestUrl, { signal: controller.signal });
|
|
38
75
|
if (!res.ok) {
|
|
39
|
-
throw new Error(`Failed to fetch engine manifest from ${
|
|
76
|
+
throw new Error(`Failed to fetch engine manifest from ${manifestUrl}: HTTP ${res.status}`);
|
|
40
77
|
}
|
|
41
78
|
const manifestBytes = Buffer.from(await res.arrayBuffer());
|
|
42
79
|
|
|
43
80
|
// Fetch and verify detached signature (Codex P1)
|
|
44
|
-
|
|
81
|
+
requestUrl = `${manifestUrl}.minisig`;
|
|
82
|
+
const sigRes = await fetch(requestUrl, { signal: controller.signal });
|
|
45
83
|
if (!sigRes.ok) {
|
|
46
|
-
throw new Error(`Failed to fetch manifest signature from ${
|
|
84
|
+
throw new Error(`Failed to fetch manifest signature from ${manifestUrl}.minisig: HTTP ${sigRes.status}`);
|
|
47
85
|
}
|
|
48
86
|
const sigText = await sigRes.text();
|
|
49
87
|
const isSigValid = verifyManifestSignature(manifestBytes, sigText);
|
|
@@ -51,7 +89,24 @@ export async function fetchManifest() {
|
|
|
51
89
|
throw new Error('Engine manifest signature verification failed against trusted Seeed public key');
|
|
52
90
|
}
|
|
53
91
|
|
|
54
|
-
|
|
92
|
+
let manifest;
|
|
93
|
+
try { manifest = JSON.parse(manifestBytes.toString('utf8')); } catch (err) {
|
|
94
|
+
throw new Error(`Engine release manifest is not valid JSON: ${err.message}`);
|
|
95
|
+
}
|
|
96
|
+
if (version !== undefined && manifest.version !== version) {
|
|
97
|
+
throw new Error(`Versioned Engine manifest mismatch: requested ${version}, received ${manifest.version || 'missing'}`);
|
|
98
|
+
}
|
|
99
|
+
const release = deepFreeze(manifest);
|
|
100
|
+
verifiedReleases.add(release);
|
|
101
|
+
return release;
|
|
102
|
+
} catch (err) {
|
|
103
|
+
if (err?.name === 'AbortError' || err?.name === 'TypeError') {
|
|
104
|
+
throw new Error(
|
|
105
|
+
`Unable to fetch Engine release metadata/signature from ${requestUrl}. Check CDN network connectivity and retry, or use a valid --engine-path.`,
|
|
106
|
+
{ cause: err }
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
throw err;
|
|
55
110
|
} finally {
|
|
56
111
|
clearTimeout(timeout);
|
|
57
112
|
}
|
|
@@ -65,17 +120,14 @@ export async function downloadEngine(options = {}) {
|
|
|
65
120
|
throw new Error(`Cannot download engine: ${platform.note}`);
|
|
66
121
|
}
|
|
67
122
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
if (!asset || !asset.url || !asset.sha256) {
|
|
73
|
-
throw new Error(`No compatible release asset found for target "${platform.target}" in version ${manifest.version}`);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
if (asset.size && asset.size > MAX_ARCHIVE_BYTES) {
|
|
77
|
-
throw new Error(`Declared asset size (${asset.size} bytes) exceeds safety maximum (${MAX_ARCHIVE_BYTES} bytes)`);
|
|
123
|
+
let manifest = options.release;
|
|
124
|
+
if (manifest === undefined) {
|
|
125
|
+
onProgress('Fetching latest release manifest...');
|
|
126
|
+
manifest = await fetchManifest();
|
|
78
127
|
}
|
|
128
|
+
if (!verifiedReleases.has(manifest)) throw new Error('Engine release metadata was not verified by the trusted manifest flow');
|
|
129
|
+
validateReleaseManifest(manifest, platform);
|
|
130
|
+
const asset = manifest.assets[platform.target];
|
|
79
131
|
|
|
80
132
|
onProgress(`Downloading sensecraft-engine v${manifest.version}...`);
|
|
81
133
|
const controller = new AbortController();
|
|
@@ -113,6 +165,10 @@ export async function downloadEngine(options = {}) {
|
|
|
113
165
|
clearTimeout(timeout);
|
|
114
166
|
}
|
|
115
167
|
|
|
168
|
+
if (asset.size !== undefined && buffer.length !== asset.size) {
|
|
169
|
+
throw new Error(`Downloaded archive size ${buffer.length} bytes does not match signed manifest size ${asset.size} bytes`);
|
|
170
|
+
}
|
|
171
|
+
|
|
116
172
|
onProgress('Verifying cryptographic SHA256 integrity...');
|
|
117
173
|
const computedHash = crypto.createHash('sha256').update(buffer).digest('hex').toLowerCase();
|
|
118
174
|
const expectedHash = asset.sha256.toLowerCase();
|
|
@@ -165,12 +221,20 @@ export async function downloadEngine(options = {}) {
|
|
|
165
221
|
if (!verifyResult.valid) {
|
|
166
222
|
throw new Error(`Downloaded engine verification failed: ${verifyResult.reason}`);
|
|
167
223
|
}
|
|
224
|
+
if (verifyResult.version !== manifest.version) {
|
|
225
|
+
throw new Error(`Downloaded engine version ${verifyResult.version} does not match signed manifest version ${manifest.version}`);
|
|
226
|
+
}
|
|
168
227
|
|
|
169
228
|
const finalBin = getEngineBinaryPath();
|
|
170
229
|
const backupBin = path.join(binDir, `.backup-${platform.binaryName}-${nonce}`);
|
|
230
|
+
const hasEntry = filePath => Boolean(fs.lstatSync(filePath, { throwIfNoEntry: false }));
|
|
171
231
|
|
|
172
|
-
if (
|
|
173
|
-
try {
|
|
232
|
+
if (hasEntry(finalBin)) {
|
|
233
|
+
try {
|
|
234
|
+
fs.renameSync(finalBin, backupBin);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
throw new Error(`Unable to back up existing Engine before swap: ${err.message}`);
|
|
237
|
+
}
|
|
174
238
|
}
|
|
175
239
|
|
|
176
240
|
try {
|
|
@@ -179,11 +243,11 @@ export async function downloadEngine(options = {}) {
|
|
|
179
243
|
} else {
|
|
180
244
|
fs.renameSync(stagedBin, finalBin);
|
|
181
245
|
}
|
|
182
|
-
if (
|
|
246
|
+
if (hasEntry(backupBin)) {
|
|
183
247
|
try { fs.unlinkSync(backupBin); } catch {}
|
|
184
248
|
}
|
|
185
249
|
} catch (swapErr) {
|
|
186
|
-
if (
|
|
250
|
+
if (hasEntry(backupBin)) {
|
|
187
251
|
try { fs.renameSync(backupBin, finalBin); } catch {}
|
|
188
252
|
}
|
|
189
253
|
throw swapErr;
|
package/lib/installer.js
CHANGED
|
@@ -8,9 +8,9 @@ import {
|
|
|
8
8
|
setConfiguredEnginePath,
|
|
9
9
|
getConfiguredEnginePath
|
|
10
10
|
} from './platform.js';
|
|
11
|
-
import { detectExistingEngine, detectDesktopApp, verifyEngineBinary } from './detector.js';
|
|
12
|
-
import { downloadEngine, pullSolutionContent } from './downloader.js';
|
|
13
|
-
import { scanLocalAgents, promptAgentSelection } from './agent.js';
|
|
11
|
+
import { detectExistingEngine, detectDesktopApp, verifyEngineBinary, compareSemver } from './detector.js';
|
|
12
|
+
import { downloadEngine, fetchManifest, validateReleaseManifest, pullSolutionContent } from './downloader.js';
|
|
13
|
+
import { getKnownAgents, scanLocalAgents, promptAgentSelection } from './agent.js';
|
|
14
14
|
import {
|
|
15
15
|
recordInstallation,
|
|
16
16
|
removeInstallation,
|
|
@@ -22,7 +22,31 @@ import {
|
|
|
22
22
|
|
|
23
23
|
const __filename = fileURLToPath(import.meta.url);
|
|
24
24
|
const __dirname = path.dirname(__filename);
|
|
25
|
-
const
|
|
25
|
+
const SKILL_DIR_NAME = 'solution-cli';
|
|
26
|
+
const LEGACY_SKILL_DIR_NAME = 'sensecraft';
|
|
27
|
+
const BUNDLED_SKILLS_SRC = path.resolve(__dirname, '..', 'skills', SKILL_DIR_NAME);
|
|
28
|
+
|
|
29
|
+
function assertCanonicalSkillState(ledger, agents) {
|
|
30
|
+
const allAgents = agents;
|
|
31
|
+
const errors = [];
|
|
32
|
+
|
|
33
|
+
for (const agent of allAgents) {
|
|
34
|
+
const skillsDir = agent.skillsDir || agent.getSkillsDir();
|
|
35
|
+
const expectedTarget = path.resolve(path.join(skillsDir, SKILL_DIR_NAME));
|
|
36
|
+
const legacySkill = path.join(skillsDir, LEGACY_SKILL_DIR_NAME, 'SKILL.md');
|
|
37
|
+
if (fs.lstatSync(legacySkill, { throwIfNoEntry: false })) {
|
|
38
|
+
errors.push(`${agent.id} still has legacy skill at ${legacySkill}; run uninstall --agent ${agent.id} before installing solution-cli. If the file is untracked or remains after uninstall because it was modified, back it up and retire the legacy skill directory before retrying; user files are not removed automatically.`);
|
|
39
|
+
}
|
|
40
|
+
const record = ledger.installations?.[agent.id];
|
|
41
|
+
if (record && path.resolve(record.target_dir) !== expectedTarget) {
|
|
42
|
+
errors.push(`${agent.id} is managed at legacy target ${record.target_dir}; run uninstall --agent ${agent.id} before installing solution-cli`);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (errors.length > 0) {
|
|
47
|
+
throw new Error(`Legacy SenseCraft skill state blocks canonical solution-cli installation:\n- ${errors.join('\n- ')}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
26
50
|
|
|
27
51
|
export async function runInstall(options = {}) {
|
|
28
52
|
const {
|
|
@@ -36,7 +60,7 @@ export async function runInstall(options = {}) {
|
|
|
36
60
|
} = options;
|
|
37
61
|
|
|
38
62
|
logger('\x1b[1m\x1b[34m====================================================\x1b[0m');
|
|
39
|
-
logger('\x1b[1m\x1b[34m
|
|
63
|
+
logger('\x1b[1m\x1b[34m Solution CLI Installer \x1b[0m');
|
|
40
64
|
logger('\x1b[1m\x1b[34m====================================================\x1b[0m\n');
|
|
41
65
|
|
|
42
66
|
const platform = getPlatformInfo();
|
|
@@ -53,6 +77,9 @@ export async function runInstall(options = {}) {
|
|
|
53
77
|
return { success: true, engineBin: null, installedAgents: [] };
|
|
54
78
|
}
|
|
55
79
|
|
|
80
|
+
// Validate ownership and legacy targets before engine selection or pointer/download writes.
|
|
81
|
+
assertCanonicalSkillState(loadLedger(), selectedAgents);
|
|
82
|
+
|
|
56
83
|
// --- Step 1: Detect & Verify Engine Candidate ---
|
|
57
84
|
logger('🔍 Phase 1: Inspecting host environment for existing Engine/App...');
|
|
58
85
|
let engineBin = null;
|
|
@@ -69,52 +96,40 @@ export async function runInstall(options = {}) {
|
|
|
69
96
|
engineBin = resolvedPath;
|
|
70
97
|
engineVersion = check.version;
|
|
71
98
|
reusedSource = 'custom';
|
|
72
|
-
} else
|
|
73
|
-
//
|
|
99
|
+
} else {
|
|
100
|
+
// Discover every compatible local source before comparing with the signed stable release.
|
|
74
101
|
const existing = detectExistingEngine();
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
// 1.2 Check Desktop App
|
|
82
|
-
const app = detectDesktopApp();
|
|
83
|
-
if (app.detected && app.compatible) {
|
|
84
|
-
logger(`✔ Found compatible SenseCraft Desktop App at: ${app.appPath || app.binPath}`);
|
|
85
|
-
logger(` Engine binary inside App: ${app.binPath} (v${app.version})`);
|
|
86
|
-
|
|
87
|
-
const targetBin = path.resolve(getEngineBinaryPath());
|
|
88
|
-
if (!dryRun) {
|
|
89
|
-
fs.mkdirSync(path.dirname(targetBin), { recursive: true });
|
|
90
|
-
if (platform.isWindows) {
|
|
91
|
-
fs.copyFileSync(app.binPath, targetBin);
|
|
92
|
-
} else {
|
|
93
|
-
if (fs.existsSync(targetBin)) fs.unlinkSync(targetBin);
|
|
94
|
-
fs.symlinkSync(app.binPath, targetBin);
|
|
95
|
-
}
|
|
96
|
-
engineBin = targetBin;
|
|
97
|
-
} else {
|
|
98
|
-
engineBin = app.binPath;
|
|
99
|
-
}
|
|
100
|
-
engineVersion = app.version;
|
|
101
|
-
reusedSource = 'desktop_app';
|
|
102
|
-
} else if (app.detected && !app.compatible) {
|
|
103
|
-
logger(`ℹ Detected existing App but its engine is outdated/incompatible: ${app.reason}. Falling back to fresh engine download.`);
|
|
104
|
-
}
|
|
102
|
+
const app = detectDesktopApp();
|
|
103
|
+
const candidates = [];
|
|
104
|
+
if (existing) candidates.push({ ...existing, kind: 'local' });
|
|
105
|
+
if (app.detected && app.compatible) candidates.push({ ...app, path: app.binPath, kind: 'desktop_app' });
|
|
106
|
+
if (app.detected && !app.compatible) {
|
|
107
|
+
logger(`ℹ Detected existing App but its engine is outdated/incompatible: ${app.reason}`);
|
|
105
108
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
109
|
+
candidates.sort((a, b) => compareSemver(b.version, a.version));
|
|
110
|
+
const selected = candidates[0] || null;
|
|
111
|
+
|
|
112
|
+
logger('🔐 Checking the signed stable Engine release manifest...');
|
|
113
|
+
const release = await fetchManifest();
|
|
114
|
+
validateReleaseManifest(release, platform);
|
|
115
|
+
const releaseVersion = release.version;
|
|
116
|
+
const selectedIsCurrent = selected && (compareSemver(selected.version, releaseVersion) > 0 ||
|
|
117
|
+
(compareSemver(selected.version, releaseVersion) === 0 && !force));
|
|
118
|
+
|
|
119
|
+
if (selectedIsCurrent) {
|
|
120
|
+
engineBin = path.resolve(selected.path);
|
|
121
|
+
engineVersion = selected.version;
|
|
122
|
+
reusedSource = selected.kind === 'desktop_app' ? 'desktop_app' : selected.source;
|
|
123
|
+
logger(`✔ Reusing highest compatible Engine v${engineVersion} (${engineBin}); signed stable is v${releaseVersion}`);
|
|
124
|
+
} else if (dryRun) {
|
|
112
125
|
engineBin = path.resolve(getEngineBinaryPath());
|
|
113
|
-
engineVersion =
|
|
126
|
+
engineVersion = releaseVersion;
|
|
114
127
|
reusedSource = 'download';
|
|
128
|
+
logger(`ℹ [dry-run] Would ${selected ? `upgrade Engine v${selected.version} to` : 'download'} signed stable Engine v${releaseVersion} to ${engineBin}`);
|
|
115
129
|
} else {
|
|
116
|
-
logger('
|
|
130
|
+
logger(`⬇ ${selected ? `Upgrading Engine v${selected.version} to` : 'Downloading'} signed stable Engine v${releaseVersion} from CDN...`);
|
|
117
131
|
const dl = await downloadEngine({
|
|
132
|
+
release,
|
|
118
133
|
onProgress: msg => logger(` ${msg}`)
|
|
119
134
|
});
|
|
120
135
|
engineBin = path.resolve(dl.path);
|
|
@@ -147,9 +162,10 @@ export async function runInstall(options = {}) {
|
|
|
147
162
|
|
|
148
163
|
runWithLock(() => {
|
|
149
164
|
const ledger = dryRun ? { installations: {} } : loadLedger();
|
|
165
|
+
assertCanonicalSkillState(ledger, selectedAgents);
|
|
150
166
|
|
|
151
167
|
for (const a of selectedAgents) {
|
|
152
|
-
const destDir = path.resolve(path.join(a.skillsDir,
|
|
168
|
+
const destDir = path.resolve(path.join(a.skillsDir, SKILL_DIR_NAME));
|
|
153
169
|
logger(`\nConfiguring ${a.name} at: ${destDir}...`);
|
|
154
170
|
|
|
155
171
|
if (dryRun) {
|
|
@@ -168,6 +184,21 @@ export async function runInstall(options = {}) {
|
|
|
168
184
|
|
|
169
185
|
Object.assign(filesWritten, copyResult.filesWritten);
|
|
170
186
|
|
|
187
|
+
if (copyResult.preservedFiles.includes('SKILL.md')) {
|
|
188
|
+
const preservedSkillPath = path.join(destDir, 'SKILL.md');
|
|
189
|
+
const preservedStat = fs.lstatSync(preservedSkillPath, { throwIfNoEntry: false });
|
|
190
|
+
if (preservedStat && !preservedStat.isSymbolicLink()) {
|
|
191
|
+
const preservedSkill = fs.readFileSync(preservedSkillPath, 'utf8');
|
|
192
|
+
const oldPathMatch = preservedSkill.match(/\*\*Solution CLI Runtime\*\*:[\s\S]*?full absolute path: `"([^"]+)"/);
|
|
193
|
+
const normalizePath = value => path.normalize(value.replaceAll('\\', '/'));
|
|
194
|
+
if (oldPathMatch && normalizePath(oldPathMatch[1]) !== normalizePath(engineBin)) {
|
|
195
|
+
logger(` ⚠️ Preserved user-modified SKILL.md still contains its previous Engine path; user text was not rewritten.`);
|
|
196
|
+
} else {
|
|
197
|
+
logger(' ⚠️ Preserved user-modified SKILL.md; user text was not rewritten.');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
171
202
|
// Customize SKILL.md only if actually copied by this install and not an external symlink (Codex P1)
|
|
172
203
|
const skillMdPath = path.join(destDir, 'SKILL.md');
|
|
173
204
|
if (fs.existsSync(skillMdPath) && copyResult.updatedFiles.includes('SKILL.md')) {
|
|
@@ -175,9 +206,9 @@ export async function runInstall(options = {}) {
|
|
|
175
206
|
if (skillStat && !skillStat.isSymbolicLink()) {
|
|
176
207
|
let content = fs.readFileSync(skillMdPath, 'utf8');
|
|
177
208
|
const escapedBinPath = engineBin.replace(/\\/g, '/');
|
|
178
|
-
const instructionBlock = `\n> **
|
|
209
|
+
const instructionBlock = `\n> **Solution CLI Runtime**: Engine operations such as \`device\`, \`deploy\`, and \`logs\` must use the full absolute path: \`"${escapedBinPath}" <subcommand>\`. Use \`solution-cli\` for catalog and product queries; if it is not on $PATH, invoke \`npx -y --package=@seeed-studio/solution-cli@latest solution-cli <args>\`.\n\n`;
|
|
179
210
|
|
|
180
|
-
if (!content.includes('
|
|
211
|
+
if (!content.includes('**Solution CLI Runtime**:')) {
|
|
181
212
|
content = content.replace(/(---[\s\S]*?---\n)/, `$1${instructionBlock}`);
|
|
182
213
|
fs.writeFileSync(skillMdPath, content, 'utf8');
|
|
183
214
|
}
|
|
@@ -186,7 +217,7 @@ export async function runInstall(options = {}) {
|
|
|
186
217
|
}
|
|
187
218
|
|
|
188
219
|
// Write installation marker safely without following external symlinks (Codex P2)
|
|
189
|
-
const markerPath = path.join(destDir, '.installed-by-
|
|
220
|
+
const markerPath = path.join(destDir, '.installed-by-solution-cli');
|
|
190
221
|
if (isSafePathInside(markerPath, destDir)) {
|
|
191
222
|
const markerStat = fs.lstatSync(markerPath, { throwIfNoEntry: false });
|
|
192
223
|
if (markerStat && markerStat.isSymbolicLink()) {
|
|
@@ -207,7 +238,7 @@ export async function runInstall(options = {}) {
|
|
|
207
238
|
});
|
|
208
239
|
|
|
209
240
|
logger('\n\x1b[1m\x1b[32m====================================================\x1b[0m');
|
|
210
|
-
logger('\x1b[1m\x1b[32m 🎉
|
|
241
|
+
logger('\x1b[1m\x1b[32m 🎉 Solution CLI Setup Completed Successfully! \x1b[0m');
|
|
211
242
|
logger('\x1b[1m\x1b[32m====================================================\x1b[0m');
|
|
212
243
|
logger(`\n🔧 Engine Binary: ${engineBin}`);
|
|
213
244
|
logger(`🤖 Configured Agents:`);
|
|
@@ -259,7 +290,7 @@ export function runStatus(options = {}) {
|
|
|
259
290
|
return statusObj;
|
|
260
291
|
}
|
|
261
292
|
|
|
262
|
-
logger('\n📊
|
|
293
|
+
logger('\n📊 Solution CLI System Status:\n');
|
|
263
294
|
logger(` Engine Binary: ${binPath}`);
|
|
264
295
|
logger(` Engine Version: ${engineCheck.version || 'Not installed'} (${engineCheck.valid ? 'Valid' : 'Invalid'})`);
|
|
265
296
|
logger(` Deployers Available: ${engineCheck.deployers?.length || 0}`);
|
|
@@ -283,7 +314,7 @@ export function runUninstall(options = {}) {
|
|
|
283
314
|
const installedIds = Object.keys(ledger.installations || {});
|
|
284
315
|
|
|
285
316
|
if (installedIds.length === 0) {
|
|
286
|
-
logger('No managed
|
|
317
|
+
logger('No managed Solution CLI skills are currently recorded in the ledger.');
|
|
287
318
|
return;
|
|
288
319
|
}
|
|
289
320
|
|
|
@@ -315,6 +346,7 @@ export function runUninstall(options = {}) {
|
|
|
315
346
|
function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logger) {
|
|
316
347
|
const filesWritten = {};
|
|
317
348
|
const updatedFiles = [];
|
|
349
|
+
const preservedFiles = [];
|
|
318
350
|
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
319
351
|
|
|
320
352
|
for (const entry of entries) {
|
|
@@ -333,6 +365,7 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
|
|
|
333
365
|
const sub = copyFolderSafely(srcPath, destPath, baseDestDir, priorFiles, force, logger);
|
|
334
366
|
Object.assign(filesWritten, sub.filesWritten);
|
|
335
367
|
updatedFiles.push(...sub.updatedFiles);
|
|
368
|
+
preservedFiles.push(...sub.preservedFiles);
|
|
336
369
|
} else {
|
|
337
370
|
let shouldCopy = true;
|
|
338
371
|
|
|
@@ -348,12 +381,14 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
|
|
|
348
381
|
// CRUCIAL (Codex High 1): Keep the ORIGINAL baseline hash in the ledger!
|
|
349
382
|
// Do NOT record destHash, otherwise future uninstall will consider it unmodified!
|
|
350
383
|
filesWritten[relPath] = recordedBaselineHash;
|
|
384
|
+
preservedFiles.push(relPath);
|
|
351
385
|
}
|
|
352
386
|
} else {
|
|
353
387
|
// File exists on disk but was never in ledger (unmanaged foreign file)
|
|
354
388
|
if (!force) {
|
|
355
389
|
logger(` ⚠️ Refused overwriting unmanaged pre-existing file: ${relPath} (use --force)`);
|
|
356
390
|
shouldCopy = false;
|
|
391
|
+
preservedFiles.push(relPath);
|
|
357
392
|
}
|
|
358
393
|
}
|
|
359
394
|
}
|
|
@@ -371,5 +406,5 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
|
|
|
371
406
|
}
|
|
372
407
|
}
|
|
373
408
|
|
|
374
|
-
return { filesWritten, updatedFiles };
|
|
409
|
+
return { filesWritten, updatedFiles, preservedFiles };
|
|
375
410
|
}
|