@seeed-studio/solution-cli 0.2.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.
@@ -0,0 +1,277 @@
1
+ # SenseCraft Solution CLI 使用与集成全景指南
2
+ *(SenseCraft Solution CLI & AI Agent Skill Guide)*
3
+
4
+ > **定位**:本文档专门面向**方案架构师、现场应用工程师 (FAE)、嵌入式开发者以及 AI Agent (Claude Code / Codex / Antigravity / Work Buddy)**,系统性说明 `@seeed-studio/solution-cli` 的独特应用场景、安装流程、各功能指令的具体使用方式以及核心产出物。
5
+
6
+ ---
7
+
8
+ ## 目录
9
+
10
+ 1. [独特使用场景与核心价值](#1-独特使用场景与核心价值)
11
+ 2. [安装与 Agent 技能挂载](#2-安装与-agent-技能挂载)
12
+ 3. [核心功能、使用方法与具体产出](#3-核心功能使用方法与具体产出)
13
+ - [场景一:方案探索与工况诊断 (Hub Search)](#场景一方案探索与工况诊断-hub-search)
14
+ - [场景二:硬件产品规格与配件防呆评估 (Product & BOM)](#场景二硬件产品规格与配件防呆评估-product--bom)
15
+ - [场景三:设备状态感知与参数热修改 (Device Management)](#场景三设备状态感知与参数热修改-device-management)
16
+ - [场景四:自研模型替换与本地二次开发 (Secondary Dev)](#场景四自研模型替换与本地二次开发-secondary-dev)
17
+ 4. [双轨运行机制:在线直连与离线托底](#4-双轨运行机制在线直连与离线托底)
18
+ 5. [常见问题与环境自检 (Doctor)](#5-常见问题与环境自检-doctor)
19
+
20
+ ---
21
+
22
+ ## 1. 独特使用场景与核心价值
23
+
24
+ 在传统边缘物联网与 AI 项目落地过程中,开发者通常面临以下痛点:
25
+ - **方案选型盲目**:不清楚现场环境(集中还是分散、有无现有监控摄像头、有无市电)对硬件架构的致命影响;
26
+ - **配件采购踩坑**:工控机买回后发现电源电压不符(如 12V 误插 19V 算力板)、DC 插头尺寸不对(5.5x2.1mm 插不进 5.5x2.5mm)、M.2 接口插槽类型搞混(Key-M 固态插不进 Key-E Wi-Fi 槽);
27
+ - **参数变更成本高**:改个 MQTT Broker 地址或算法置信度阈值就需要重新编译镜像或重装整套系统;
28
+ - **AI Agent 缺乏边缘硬件感知**:纯大模型对话缺乏真实物理世界的接口约束,容易凭空编造不存在的硬件型号或非官方链接。
29
+
30
+ `@seeed-studio/solution-cli` 正是为解决这一系列问题而生,它具有三大独特角色:
31
+ 1. **方案专家的现场诊断仪**:内置各大行业工况诊断知识库,不仅能搜方案,还能直接指导工程师向客户提出决定成本与物理拓扑的“黄金追问”;
32
+ 2. **硬件采购防呆中枢**:内置物理规格匹配引擎,自动核算【必需配件】与【可选配件】,从源头阻断硬件返工;
33
+ 3. **AI Agent 的物理世界触手**:一键将 SenseCraft Skill 注入到主流 AI Agent 宿主中,让 Agent 具备从方案咨询、BOM 选型到现场部署、参数热更的全生命周期驱动能力。
34
+
35
+ ---
36
+
37
+ ## 2. 安装与 Agent 技能挂载
38
+
39
+ ### 2.1 一键免安装即时调用 (推荐)
40
+ 无需全局安装,安装或按需升级 Engine 与 Agent Skill 使用同一条命令:
41
+ ```bash
42
+ npx -y @seeed-studio/solution-cli@latest install -y
43
+ ```
44
+
45
+ 首次安装和重复执行都会检查签名稳定版 Engine:本机已有相同或更新版本
46
+ 时复用,版本较旧时升级。普通安装保留用户修改、未登记的 Skill 文件和受
47
+ 保护的符号链接。需要指定宿主时追加 `--agent claude,antigravity`;需要固
48
+ 定有效本地 Engine 时使用 `--engine-path <path>`。`--force` 可重新下载相同
49
+ 或较旧版本并覆盖已有 Skill 文件(包括用户修改),请只在有意覆盖时使用;
50
+ 受保护的外部符号链接不会追写,也不能降级更新的本机 Engine。
51
+
52
+ 自动复用/升级规则自 solution-cli 0.3.1 起生效;若 npm `latest` 仍为 0.3.0,
53
+ 这些规则尚未上线。本次验证时 0.3.1 待发布。latest 检查或签名校验失败会在
54
+ 修改 Engine、指针或 Skill 前停止;离线时请提供有效的 `--engine-path`。
55
+
56
+ The same command handles a fresh install and a user-requested upgrade. It checks
57
+ the signed stable Engine, reuses an equal/newer local version, preserves modified
58
+ skills, and stops before writes on latest/signature failure. Automatic reuse and
59
+ upgrade rules start with solution-cli 0.3.1; if npm `latest` is still 0.3.0,
60
+ those rules are not live. At this verification, 0.3.1 is pending publication.
61
+
62
+ ### 2.2 全局安装
63
+ ```bash
64
+ npm install -g @seeed-studio/solution-cli
65
+ ```
66
+
67
+ ### 2.3 跨 Agent 平台技能挂载
68
+ 执行 `solution-cli install` 时,工具会自动检测本机存在的 Agent 宿主环境,并将 `sensecraft` Skill 和全套参考手册安全复制到对应目录:
69
+
70
+ | Agent 宿主平台 | 技能默认配置路径 | 支持能力 |
71
+ |---|---|---|
72
+ | **Claude Code** | `~/.claude/skills/sensecraft` | 完整方案咨询、工况诊断、BOM 生成、现场部署 |
73
+ | **Codex (OpenAI)** | `~/.agents/skills/sensecraft` 或 `~/.codex/skills/sensecraft` | 代码审查、参数重构、二次开发自动化 |
74
+ | **Google Antigravity (AGY)** | `~/.gemini/config/skills/sensecraft` | 端到端自主诊断、多 Agent 协同实施 |
75
+ | **Work Buddy / Workbody** | `~/.workbuddy/skills/sensecraft` | 桌面端交互、方案展示、一键部署联动 |
76
+ | **CodeBuddy** | `~/.codebuddy/skills/sensecraft` | 代码库配对开发与自研模型构建 |
77
+
78
+ **非交互式多选安装示例**:
79
+ ```bash
80
+ # 同时为 Claude Code 和 Google Antigravity 部署技能,不弹窗确认
81
+ solution-cli install --agent claude,antigravity -y
82
+ ```
83
+
84
+ ---
85
+
86
+ ## 3. 核心功能、使用方法与具体产出
87
+
88
+ ### 场景一:方案探索与工况诊断 (Hub Search)
89
+
90
+ 当工程师或客户提出模糊需求(如“想在农田做微气象监测”、“工厂产线想做划痕检测”)时使用。
91
+
92
+ #### 1. 执行命令
93
+ ```bash
94
+ # 人类工程师阅读模式 (格式化终端高亮)
95
+ solution-cli hub search "农业"
96
+
97
+ # AI Agent 消费模式 (标准 JSON 格式的 Catalog DTO)
98
+ solution-cli hub search "跌倒检测" --json
99
+ ```
100
+
101
+ #### 2. 产出物内容 (What it Outputs)
102
+ 运行该命令可产出 4 项关键资产:
103
+ 1. **方案业务说明与技术架构**:解释为何适合在端侧运行、本地处理延迟、数据不出本地的隐私保障;
104
+ 2. **静默商业闭环出口**:自动为该方案生成带有安全追踪标记(`utm_source=sensecraft_agent`)的官方落地页与开发 Wiki;
105
+ 3. **官方开源仓库地址**:提供官方维护的 GitHub 源码仓库,为二次开发预留出口;
106
+ 4. **现场工况诊断提问指引 (Diagnostic Playbook)**:直接给出当前场景下**必须向客户确认的 2~3 个核心工况问题**及其背后的技术决断理由。
107
+
108
+ #### 3. 产出示例 (以“农业”搜索为例)
109
+ ```text
110
+ 🔍 Solutions Hub Search Results for "农业" (typesense):
111
+
112
+ 1. [Reference Design] 农业环境监测可部署方案 | LoRaWAN 自建看板 (agri_env_monitor)
113
+ 把 SenseCAP 土壤与空气传感器接到 Home Assistant 看板上,支持本地私有 ChirpStack 运行,断网可用。
114
+ 预设模式: cloud, tts_local, chirpstack_local
115
+ 现场工况诊断提问指引:
116
+ • 监测区域是集中式连栋温室大棚(百米级),还是大面积户外大田/果园(数公里级)?
117
+ (决断考量: 大棚内百米级可采用 RS485 总线;广域大田推荐 LoRaWAN 远距无线,配 SenseCAP M2 网关)
118
+ • 现场测点是否有 220V 市电供电,还是需要野外太阳能板/锂电池长续航供电?
119
+ (决断考量: 市电支持持续高频数据上报;无市电需选配支持休眠唤醒的超低功耗无线传感器)
120
+ • 需要监测哪些核心指标?仅空气温湿度,还是包括土壤剖面水肥墒情(水分/EC/pH)?
121
+ (决断考量: 决定是配置多合一微气象站,还是选配工业级土壤三参数探头与变送器)
122
+ ```
123
+
124
+ ---
125
+
126
+ ### 场景二:硬件产品规格与配件防呆评估 (Product & BOM)
127
+
128
+ 当确定了方案,需要进入商务采购阶段、给客户出具设备清单时使用。
129
+
130
+ #### 1. 执行命令
131
+ ```bash
132
+ # 1. 查询产品族详情与物理规格
133
+ solution-cli product query recomputer_j40 --json
134
+
135
+ # 2. 评估该产品族所需的官方配件与兼容性
136
+ solution-cli product accessories recomputer_j40 --json
137
+ ```
138
+
139
+ #### 2. 产出物内容 (What it Outputs)
140
+ 1. **精确分类的 BOM 清单**:
141
+ - **【必需配件 (REQUIRED)】**:没有该配件主机无法开机或无法运行(如专属电源适配器),附带选型理由;
142
+ - **【可选配件 (OPTIONAL)】**:根据工况选择的外设(如 NVMe 固态硬盘、Wi-Fi 模组、导轨安装卡扣、射频天线);
143
+ 2. **物理兼容性安全评级**:
144
+ - `VERIFIED`(通过):电压、引脚、尺寸完全匹配;
145
+ - `REQUIRES_ADAPTER`(需转接):如 M.2 2242 规格装入 2280 卡槽需加铜柱;
146
+ - `INCOMPATIBLE`(不兼容/危险):严禁搭配,防止烧毁板卡。
147
+
148
+ #### 3. 产出示例 (以 reComputer J40 为例)
149
+ ```json
150
+ {
151
+ "family_id": "recomputer_j40",
152
+ "physical_spec": {
153
+ "power_input": {
154
+ "connector": "dc_jack_5.5x2.5",
155
+ "voltage_v": 19.0,
156
+ "min_current_a": 4.74,
157
+ "polarity": "center_positive"
158
+ },
159
+ "m2_slots": [
160
+ {"key": "M", "size": "2280", "interface": "PCIe Gen4 x4 NVMe"},
161
+ {"key": "E", "size": "2230", "interface": "WiFi/BT"}
162
+ ]
163
+ },
164
+ "accessories": {
165
+ "required": [
166
+ {
167
+ "sku": "313080684",
168
+ "name": "19V 4.74A 电源适配器 (5.5x2.5mm)",
169
+ "category": "REQUIRED",
170
+ "compatibility": { "status": "VERIFIED" },
171
+ "url": "https://www.seeedstudio.com/Power-Adapter-19V-4-74A-p-5573.html?utm_source=sensecraft_agent..."
172
+ }
173
+ ],
174
+ "optional": [
175
+ {
176
+ "sku": "110991734",
177
+ "name": "M.2 2280 NVMe SSD 512GB",
178
+ "category": "OPTIONAL",
179
+ "compatibility": { "status": "VERIFIED" }
180
+ }
181
+ ]
182
+ }
183
+ }
184
+ ```
185
+
186
+ ---
187
+
188
+ ### 场景三:设备状态感知与参数热修改 (Device Management)
189
+
190
+ 当方案已经在现场或测试架部署完毕,需要调整运行参数(如更换 MQTT Broker、调整告警置信度阈值)时使用。
191
+
192
+ #### 1. 核心流程与接口
193
+ 系统支持**无需重新编译、无需重装整套系统**的零重启参数热修改:
194
+ 1. **查询当前设备活跃部署**:
195
+ `GET http://<host>:5823/api/device-management/active`
196
+ 2. **查询可修改参数**:
197
+ `GET http://<host>:5823/api/docker-devices/local/config/<solution_id>`
198
+ 检查参数字段中的 `reconfigurable: true` 标记。
199
+ 3. **提交热更新**:
200
+ `PUT http://<host>:5823/api/docker-devices/local/config/<solution_id>`
201
+ 传入目标键值,容器环境无感热加载生效。
202
+
203
+ ---
204
+
205
+ ### 场景四:自研模型替换与本地二次开发 (Secondary Dev)
206
+
207
+ 当官方自带的模型无法满足客户特殊工况(如需要自研模型、特殊缺陷检测、修改前端大屏 UI)时使用。
208
+
209
+ #### 1. 开发闭环步骤
210
+ 1. **克隆源码**:
211
+ 从 `solution-cli hub search` 返回的 `repo_url` 将源码克隆到本地用户工作区:
212
+ ```bash
213
+ git clone https://github.com/suharvest/sensecraft-solutions.git ./my-solutions
214
+ ```
215
+ 2. **端侧模型转换 (以 Jetson Orin 为例)**:
216
+ 在设备上使用 TensorRT 将自定义 ONNX 导出为 Engine:
217
+ ```bash
218
+ /usr/src/tensorrt/bin/trtexec --onnx=my_model.onnx --saveEngine=my_model.engine --fp16
219
+ ```
220
+ 3. **覆盖挂载配置**:
221
+ 在方案目录创建 `docker-compose.override.yml` 将本地自研模型映射进容器中。
222
+ 4. **驱动本地快速联调**:
223
+ 使用 `--solutions-dir` 参数,强制底层引擎加载本地修改版方案,彻底不碰官方缓存:
224
+ ```bash
225
+ sensecraft-engine deploy fall_detection --solutions-dir ./my-solutions/solutions
226
+ ```
227
+
228
+ ---
229
+
230
+ ## 4. 双轨运行机制:在线直连与离线托底
231
+
232
+ 为了保证在各类严苛现场环境下均能稳定运行,CLI 实现了智能双轨架构:
233
+
234
+ ```mermaid
235
+ flowchart TD
236
+ RunCmd["用户 / Agent 触发 CLI 命令"] --> NetCheck{"检查网络与凭证"}
237
+ NetCheck -- "有外网且含烘焙配置" --> Typesense["直连 Seeed Typesense 云端搜索集群"]
238
+ Typesense -- "返回最新上线方案与配件" --> Done["输出 Catalog DTO"]
239
+ NetCheck -- "纯内网 / 无凭证 / 超时" --> LocalCore["无感降级: 读取 catalog/knowledge-core.json"]
240
+ LocalCore -- "331 KB 本地知识库 (18场景/39方案/119硬件)" --> Done
241
+ ```
242
+
243
+ 1. **在线实时模式 (Online Live)**:
244
+ - 采用安全方案 B 在构建发布期烘焙了公开的只读 Search-Only 凭证;
245
+ - 自动直连 `https://rel-search.seeedstudio.com`,实时获取最新的方案、Wiki、案例及商城 SKU;
246
+ 2. **离线安全托底 (Offline Resilience)**:
247
+ - 打包了全量预生成的 `catalog/knowledge-core.json`(331 KB);
248
+ - 在军工、内网、无公网或云端响应超过 2.5 秒时,**自动毫秒级无感降级到本地内核**,工况诊断、BOM 评估与离线部署 100% 正常可用。
249
+
250
+ ---
251
+
252
+ ## 5. 常见问题与环境自检 (Doctor)
253
+
254
+ ### 1. 运行自检工具
255
+ 在部署前或遇到环境异常时,执行:
256
+ ```bash
257
+ solution-cli doctor
258
+ ```
259
+ 工具将逐项检测并给出修复指引:
260
+ - `OS Platform`: 确认宿主机操作系统及架构是否支持;
261
+ - `Docker Daemon`: 确认 Docker 服务是否正常运行;
262
+ - `Serial / Dialout Permissions`: 确认串口访问权限;
263
+ - `SenseCraft Engine`: 确认底座引擎完整性与版本;
264
+ - `Ledger Integrity`: 校验技能安装账本状态。
265
+
266
+ ### 2. 状态查询
267
+ ```bash
268
+ solution-cli status
269
+ ```
270
+ 实时查看当前 Engine 所在路径、安装版本、本地 Content 方案缓存数量以及各大 Agent 的配置状态。
271
+
272
+ ### 3. 干净卸载
273
+ 如需从某个 Agent 中移除 SenseCraft 技能:
274
+ ```bash
275
+ solution-cli uninstall --agent claude,antigravity
276
+ ```
277
+ 工具将依据安装账本(`~/.sensecraft/skills-ledger.json`)仅删除工具自身写入的文件,绝不误删用户自定义创建的技能。
package/lib/agent.js CHANGED
@@ -54,6 +54,12 @@ export function getKnownAgents() {
54
54
  }
55
55
  return path.join(home, '.codebuddy', 'skills');
56
56
  }
57
+ },
58
+ {
59
+ id: 'antigravity',
60
+ name: 'Google Antigravity (AGY)',
61
+ homeDirs: [path.join(home, '.gemini', 'config'), path.join(home, '.gemini', 'antigravity-cli')],
62
+ getSkillsDir: () => path.join(home, '.gemini', 'config', 'skills')
57
63
  }
58
64
  ];
59
65
  }
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(LATEST_MANIFEST_URL, { signal: controller.signal });
74
+ const res = await fetch(requestUrl, { signal: controller.signal });
38
75
  if (!res.ok) {
39
- throw new Error(`Failed to fetch engine manifest from ${LATEST_MANIFEST_URL}: HTTP ${res.status}`);
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
- const sigRes = await fetch(`${LATEST_MANIFEST_URL}.minisig`, { signal: controller.signal });
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 ${LATEST_MANIFEST_URL}.minisig: HTTP ${sigRes.status}`);
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
- return JSON.parse(manifestBytes.toString('utf8'));
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
- onProgress('Fetching latest release manifest...');
69
- const manifest = await fetchManifest();
70
- const asset = manifest.assets ? manifest.assets[platform.target] : null;
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 (fs.existsSync(finalBin)) {
173
- try { fs.renameSync(finalBin, backupBin); } catch {}
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 (fs.existsSync(backupBin)) {
246
+ if (hasEntry(backupBin)) {
183
247
  try { fs.unlinkSync(backupBin); } catch {}
184
248
  }
185
249
  } catch (swapErr) {
186
- if (fs.existsSync(backupBin)) {
250
+ if (hasEntry(backupBin)) {
187
251
  try { fs.renameSync(backupBin, finalBin); } catch {}
188
252
  }
189
253
  throw swapErr;
package/lib/installer.js CHANGED
@@ -8,8 +8,8 @@ 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';
11
+ import { detectExistingEngine, detectDesktopApp, verifyEngineBinary, compareSemver } from './detector.js';
12
+ import { downloadEngine, fetchManifest, validateReleaseManifest, pullSolutionContent } from './downloader.js';
13
13
  import { scanLocalAgents, promptAgentSelection } from './agent.js';
14
14
  import {
15
15
  recordInstallation,
@@ -69,52 +69,40 @@ export async function runInstall(options = {}) {
69
69
  engineBin = resolvedPath;
70
70
  engineVersion = check.version;
71
71
  reusedSource = 'custom';
72
- } else if (!force) {
73
- // 1.1 Check default / PATH engine
72
+ } else {
73
+ // Discover every compatible local source before comparing with the signed stable release.
74
74
  const existing = detectExistingEngine();
75
- if (existing) {
76
- logger(`✔ Found existing compatible Engine v${existing.version} (${existing.path})`);
77
- engineBin = path.resolve(existing.path);
78
- engineVersion = existing.version;
79
- reusedSource = existing.source;
80
- } else {
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
- }
75
+ const app = detectDesktopApp();
76
+ const candidates = [];
77
+ if (existing) candidates.push({ ...existing, kind: 'local' });
78
+ if (app.detected && app.compatible) candidates.push({ ...app, path: app.binPath, kind: 'desktop_app' });
79
+ if (app.detected && !app.compatible) {
80
+ logger(`ℹ Detected existing App but its engine is outdated/incompatible: ${app.reason}`);
105
81
  }
106
- }
107
-
108
- // 1.3 If no compatible local candidate, download standalone binary
109
- if (!engineBin) {
110
- if (dryRun) {
111
- logger('ℹ [dry-run] Would download standalone engine from CDN...');
82
+ candidates.sort((a, b) => compareSemver(b.version, a.version));
83
+ const selected = candidates[0] || null;
84
+
85
+ logger('🔐 Checking the signed stable Engine release manifest...');
86
+ const release = await fetchManifest();
87
+ validateReleaseManifest(release, platform);
88
+ const releaseVersion = release.version;
89
+ const selectedIsCurrent = selected && (compareSemver(selected.version, releaseVersion) > 0 ||
90
+ (compareSemver(selected.version, releaseVersion) === 0 && !force));
91
+
92
+ if (selectedIsCurrent) {
93
+ engineBin = path.resolve(selected.path);
94
+ engineVersion = selected.version;
95
+ reusedSource = selected.kind === 'desktop_app' ? 'desktop_app' : selected.source;
96
+ logger(`✔ Reusing highest compatible Engine v${engineVersion} (${engineBin}); signed stable is v${releaseVersion}`);
97
+ } else if (dryRun) {
112
98
  engineBin = path.resolve(getEngineBinaryPath());
113
- engineVersion = '0.0.3';
99
+ engineVersion = releaseVersion;
114
100
  reusedSource = 'download';
101
+ logger(`ℹ [dry-run] Would ${selected ? `upgrade Engine v${selected.version} to` : 'download'} signed stable Engine v${releaseVersion} to ${engineBin}`);
115
102
  } else {
116
- logger('⬇ Downloading latest standalone engine binary from CDN...');
103
+ logger(`⬇ ${selected ? `Upgrading Engine v${selected.version} to` : 'Downloading'} signed stable Engine v${releaseVersion} from CDN...`);
117
104
  const dl = await downloadEngine({
105
+ release,
118
106
  onProgress: msg => logger(` ${msg}`)
119
107
  });
120
108
  engineBin = path.resolve(dl.path);
@@ -168,6 +156,21 @@ export async function runInstall(options = {}) {
168
156
 
169
157
  Object.assign(filesWritten, copyResult.filesWritten);
170
158
 
159
+ if (copyResult.preservedFiles.includes('SKILL.md')) {
160
+ const preservedSkillPath = path.join(destDir, 'SKILL.md');
161
+ const preservedStat = fs.lstatSync(preservedSkillPath, { throwIfNoEntry: false });
162
+ if (preservedStat && !preservedStat.isSymbolicLink()) {
163
+ const preservedSkill = fs.readFileSync(preservedSkillPath, 'utf8');
164
+ const oldPathMatch = preservedSkill.match(/\*\*SenseCraft Runtime\*\*:[\s\S]*?full absolute path: `"([^"]+)"/);
165
+ const normalizePath = value => path.normalize(value.replaceAll('\\', '/'));
166
+ if (oldPathMatch && normalizePath(oldPathMatch[1]) !== normalizePath(engineBin)) {
167
+ logger(` ⚠️ Preserved user-modified SKILL.md still contains its previous Engine path; user text was not rewritten.`);
168
+ } else {
169
+ logger(' ⚠️ Preserved user-modified SKILL.md; user text was not rewritten.');
170
+ }
171
+ }
172
+ }
173
+
171
174
  // Customize SKILL.md only if actually copied by this install and not an external symlink (Codex P1)
172
175
  const skillMdPath = path.join(destDir, 'SKILL.md');
173
176
  if (fs.existsSync(skillMdPath) && copyResult.updatedFiles.includes('SKILL.md')) {
@@ -315,6 +318,7 @@ export function runUninstall(options = {}) {
315
318
  function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logger) {
316
319
  const filesWritten = {};
317
320
  const updatedFiles = [];
321
+ const preservedFiles = [];
318
322
  const entries = fs.readdirSync(srcDir, { withFileTypes: true });
319
323
 
320
324
  for (const entry of entries) {
@@ -333,6 +337,7 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
333
337
  const sub = copyFolderSafely(srcPath, destPath, baseDestDir, priorFiles, force, logger);
334
338
  Object.assign(filesWritten, sub.filesWritten);
335
339
  updatedFiles.push(...sub.updatedFiles);
340
+ preservedFiles.push(...sub.preservedFiles);
336
341
  } else {
337
342
  let shouldCopy = true;
338
343
 
@@ -348,12 +353,14 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
348
353
  // CRUCIAL (Codex High 1): Keep the ORIGINAL baseline hash in the ledger!
349
354
  // Do NOT record destHash, otherwise future uninstall will consider it unmodified!
350
355
  filesWritten[relPath] = recordedBaselineHash;
356
+ preservedFiles.push(relPath);
351
357
  }
352
358
  } else {
353
359
  // File exists on disk but was never in ledger (unmanaged foreign file)
354
360
  if (!force) {
355
361
  logger(` ⚠️ Refused overwriting unmanaged pre-existing file: ${relPath} (use --force)`);
356
362
  shouldCopy = false;
363
+ preservedFiles.push(relPath);
357
364
  }
358
365
  }
359
366
  }
@@ -371,5 +378,5 @@ function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logge
371
378
  }
372
379
  }
373
380
 
374
- return { filesWritten, updatedFiles };
381
+ return { filesWritten, updatedFiles, preservedFiles };
375
382
  }