@seeed-studio/solution-cli 0.3.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -9
- package/bin/cli.js +1 -1
- package/catalog/knowledge-core.json +666 -144
- package/docs/cli-guide.md +23 -10
- package/lib/downloader.js +85 -21
- package/lib/installer.js +50 -43
- package/lib/knowledge.js +141 -17
- package/package.json +1 -1
- package/skills/sensecraft/SKILL.md +22 -9
- package/skills/sensecraft/references/device-management.md +45 -13
- package/skills/sensecraft/references/diagnose.md +1 -1
- package/skills/sensecraft/references/secondary-development.md +2 -5
package/docs/cli-guide.md
CHANGED
|
@@ -37,15 +37,28 @@
|
|
|
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 -y
|
|
47
43
|
```
|
|
48
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
|
+
|
|
49
62
|
### 2.2 全局安装
|
|
50
63
|
```bash
|
|
51
64
|
npm install -g @seeed-studio/solution-cli
|
|
@@ -100,11 +113,11 @@ solution-cli hub search "跌倒检测" --json
|
|
|
100
113
|
把 SenseCAP 土壤与空气传感器接到 Home Assistant 看板上,支持本地私有 ChirpStack 运行,断网可用。
|
|
101
114
|
预设模式: cloud, tts_local, chirpstack_local
|
|
102
115
|
现场工况诊断提问指引:
|
|
103
|
-
• 监测区域是集中式连栋温室大棚(百米级),还是大面积户外大田/果园(数公里级)?
|
|
116
|
+
• 监测区域是集中式连栋温室大棚(百米级),还是大面积户外大田/果园(数公里级)?
|
|
104
117
|
(决断考量: 大棚内百米级可采用 RS485 总线;广域大田推荐 LoRaWAN 远距无线,配 SenseCAP M2 网关)
|
|
105
|
-
• 现场测点是否有 220V 市电供电,还是需要野外太阳能板/锂电池长续航供电?
|
|
118
|
+
• 现场测点是否有 220V 市电供电,还是需要野外太阳能板/锂电池长续航供电?
|
|
106
119
|
(决断考量: 市电支持持续高频数据上报;无市电需选配支持休眠唤醒的超低功耗无线传感器)
|
|
107
|
-
• 需要监测哪些核心指标?仅空气温湿度,还是包括土壤剖面水肥墒情(水分/EC/pH)?
|
|
120
|
+
• 需要监测哪些核心指标?仅空气温湿度,还是包括土壤剖面水肥墒情(水分/EC/pH)?
|
|
108
121
|
(决断考量: 决定是配置多合一微气象站,还是选配工业级土壤三参数探头与变送器)
|
|
109
122
|
```
|
|
110
123
|
|
|
@@ -209,7 +222,7 @@ solution-cli product accessories recomputer_j40 --json
|
|
|
209
222
|
4. **驱动本地快速联调**:
|
|
210
223
|
使用 `--solutions-dir` 参数,强制底层引擎加载本地修改版方案,彻底不碰官方缓存:
|
|
211
224
|
```bash
|
|
212
|
-
sensecraft-engine deploy
|
|
225
|
+
sensecraft-engine deploy fall_detection --solutions-dir ./my-solutions/solutions
|
|
213
226
|
```
|
|
214
227
|
|
|
215
228
|
---
|
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,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
|
|
73
|
-
//
|
|
72
|
+
} else {
|
|
73
|
+
// Discover every compatible local source before comparing with the signed stable release.
|
|
74
74
|
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
|
-
}
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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 =
|
|
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('
|
|
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
|
}
|
package/lib/knowledge.js
CHANGED
|
@@ -144,8 +144,12 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
144
144
|
const core = loadKnowledgeCore();
|
|
145
145
|
const onlinePromises = [];
|
|
146
146
|
|
|
147
|
+
const locKey = locale === 'zh' ? 'zh_hans' : locale;
|
|
148
|
+
const fetchLimit = Math.max(limit * 5, 30);
|
|
149
|
+
|
|
147
150
|
if (keySolution) {
|
|
148
|
-
const
|
|
151
|
+
const solFilter = 'published:!=false';
|
|
152
|
+
const solUrl = `${host}/collections/Solution/documents/search?q=${encodeURIComponent(query)}&query_by=title_zh_hans,title_en,excerpt_zh_hans,excerpt_en,display_tags_zh_hans&filter_by=${encodeURIComponent(solFilter)}&per_page=${fetchLimit}`;
|
|
149
153
|
onlinePromises.push(
|
|
150
154
|
fetchJson(solUrl, { 'X-TYPESENSE-API-KEY': keySolution }, 2500)
|
|
151
155
|
.then((data) => ({ type: 'scenario', hits: data?.hits || [] }))
|
|
@@ -154,7 +158,8 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
154
158
|
}
|
|
155
159
|
|
|
156
160
|
if (keyRefDesign) {
|
|
157
|
-
const
|
|
161
|
+
const rdFilter = 'published:!=false';
|
|
162
|
+
const rdUrl = `${host}/collections/reference_design/documents/search?q=${encodeURIComponent(query)}&query_by=name_zh_hans,name_en,summary_zh_hans,summary_en,tags&filter_by=${encodeURIComponent(rdFilter)}&per_page=${fetchLimit}`;
|
|
158
163
|
onlinePromises.push(
|
|
159
164
|
fetchJson(rdUrl, { 'X-TYPESENSE-API-KEY': keyRefDesign }, 2500)
|
|
160
165
|
.then((data) => ({ type: 'reference_design', hits: data?.hits || [] }))
|
|
@@ -163,13 +168,19 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
163
168
|
}
|
|
164
169
|
|
|
165
170
|
const results = await Promise.all(onlinePromises);
|
|
166
|
-
const
|
|
171
|
+
const onlineScenarios = [];
|
|
172
|
+
const onlineRefDesigns = [];
|
|
167
173
|
|
|
168
174
|
for (const res of results) {
|
|
169
175
|
if (res.type === 'scenario') {
|
|
170
176
|
for (const hit of res.hits) {
|
|
171
177
|
const doc = hit.document || {};
|
|
178
|
+
// Filter unreleased or staging scenarios from production recommendations
|
|
179
|
+
if (doc.published === false || doc.visibility === 'staging' || doc.visibility === 'draft') continue;
|
|
180
|
+
if (doc[`visibility_${locKey}`] === 'staging' || doc[`visibility_${locKey}`] === 'draft') continue;
|
|
181
|
+
|
|
172
182
|
const docId = doc.id || doc.slug;
|
|
183
|
+
const docType = doc.doc_type || 'scenario';
|
|
173
184
|
const title = locale === 'en'
|
|
174
185
|
? (doc.title_en || doc.title_zh_hans || docId)
|
|
175
186
|
: (doc.title_zh_hans || doc.title_en || docId);
|
|
@@ -178,22 +189,23 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
178
189
|
: (doc.excerpt_zh_hans || doc.seo_description_zh_hans || doc.excerpt_en || '');
|
|
179
190
|
const tags = (locale === 'en' ? doc.display_tags_en : doc.display_tags_zh_hans) || doc.display_tags || [];
|
|
180
191
|
const pb = core.diagnostic_playbooks?.[docId] || doc.diagnostic_playbook || null;
|
|
192
|
+
const rawLanding = doc.external_url || doc.landing_url || `https://www.seeedstudio.com/solutions/${docId}`;
|
|
181
193
|
|
|
182
|
-
|
|
183
|
-
type:
|
|
194
|
+
onlineScenarios.push({
|
|
195
|
+
type: docType,
|
|
184
196
|
id: docId,
|
|
185
197
|
title,
|
|
186
198
|
summary,
|
|
187
199
|
tags,
|
|
188
200
|
family_ids: doc.family_ids || [],
|
|
189
201
|
reference_designs: doc.reference_designs || [],
|
|
190
|
-
landing_url: appendUtm(
|
|
202
|
+
landing_url: appendUtm(rawLanding, {
|
|
191
203
|
source: 'sensecraft_agent',
|
|
192
204
|
medium: 'solution_consult',
|
|
193
205
|
campaign: docId,
|
|
194
|
-
content:
|
|
206
|
+
content: `${docType}_landing`,
|
|
195
207
|
}),
|
|
196
|
-
repo_url: '',
|
|
208
|
+
repo_url: doc.repo_url || '',
|
|
197
209
|
diagnostic_playbook: pb,
|
|
198
210
|
source: 'typesense',
|
|
199
211
|
});
|
|
@@ -201,6 +213,10 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
201
213
|
} else if (res.type === 'reference_design') {
|
|
202
214
|
for (const hit of res.hits) {
|
|
203
215
|
const doc = hit.document || {};
|
|
216
|
+
// Filter unreleased or staging reference designs from production recommendations
|
|
217
|
+
if (doc.published === false || doc.visibility === 'staging' || doc.visibility === 'draft') continue;
|
|
218
|
+
const locKey = locale === 'zh' ? 'zh_hans' : locale;
|
|
219
|
+
if (doc[`visibility_${locKey}`] === 'staging' || doc[`visibility_${locKey}`] === 'draft') continue;
|
|
204
220
|
const docId = doc.id;
|
|
205
221
|
const title = locale === 'en'
|
|
206
222
|
? (doc.name_en || doc.name_zh_hans || docId)
|
|
@@ -209,7 +225,7 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
209
225
|
? (doc.summary_en || doc.tagline_en || doc.summary_zh_hans || '')
|
|
210
226
|
: (doc.summary_zh_hans || doc.tagline_zh_hans || doc.summary_en || '');
|
|
211
227
|
|
|
212
|
-
let pb = doc.diagnostic_playbook || null;
|
|
228
|
+
let pb = doc.diagnostic_playbook || core.diagnostic_playbooks?.[docId] || null;
|
|
213
229
|
if (!pb) {
|
|
214
230
|
for (const sc of core.scenarios || []) {
|
|
215
231
|
if ((sc.reference_designs || []).includes(docId)) {
|
|
@@ -219,12 +235,24 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
219
235
|
}
|
|
220
236
|
}
|
|
221
237
|
|
|
222
|
-
|
|
238
|
+
const rawPresets = doc.presets || [];
|
|
239
|
+
const presets = rawPresets.map((p) => {
|
|
240
|
+
if (typeof p === 'string') return p;
|
|
241
|
+
if (p && typeof p === 'object') {
|
|
242
|
+
return (locale === 'en' ? (p.name_en || p.name || p.id) : (p.name_zh_hans || p.name || p.id)) || p.id || '';
|
|
243
|
+
}
|
|
244
|
+
return String(p || '');
|
|
245
|
+
}).filter(Boolean);
|
|
246
|
+
|
|
247
|
+
const bundledRd = (core.reference_designs || []).find((r) => r.id === docId);
|
|
248
|
+
const repoUrl = doc.repo_url || bundledRd?.repo_url || '';
|
|
249
|
+
|
|
250
|
+
onlineRefDesigns.push({
|
|
223
251
|
type: 'reference_design',
|
|
224
252
|
id: docId,
|
|
225
253
|
title,
|
|
226
254
|
summary,
|
|
227
|
-
presets
|
|
255
|
+
presets,
|
|
228
256
|
landing_url: appendUtm(`https://www.seeedstudio.com/solutions/reference-designs/${docId}`, {
|
|
229
257
|
source: 'sensecraft_agent',
|
|
230
258
|
medium: 'solution_consult',
|
|
@@ -236,7 +264,7 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
236
264
|
medium: 'tech_wiki',
|
|
237
265
|
campaign: docId,
|
|
238
266
|
}) : '',
|
|
239
|
-
repo_url:
|
|
267
|
+
repo_url: repoUrl,
|
|
240
268
|
diagnostic_playbook: pb,
|
|
241
269
|
source: 'typesense',
|
|
242
270
|
});
|
|
@@ -244,11 +272,90 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
244
272
|
}
|
|
245
273
|
}
|
|
246
274
|
|
|
247
|
-
|
|
275
|
+
// If one collection returned results but the other was unavailable/failed/empty, supplement from bundled catalog
|
|
276
|
+
if ((onlineScenarios.length > 0 && onlineRefDesigns.length === 0) || (onlineRefDesigns.length > 0 && onlineScenarios.length === 0)) {
|
|
277
|
+
const tokens = qLower.split(/\s+/).filter(Boolean);
|
|
278
|
+
if (onlineScenarios.length === 0) {
|
|
279
|
+
for (const s of core.scenarios || []) {
|
|
280
|
+
if (s.published === false || s.visibility === 'staging' || s.visibility === 'draft') continue;
|
|
281
|
+
const locKey = locale === 'zh' ? 'zh_hans' : locale;
|
|
282
|
+
if (s[`visibility_${locKey}`] === 'staging' || s[`visibility_${locKey}`] === 'draft') continue;
|
|
283
|
+
|
|
284
|
+
const haystack = [s.id, s.title?.zh, s.title?.en, s.subtitle?.zh, s.subtitle?.en, s.summary, ...(s.tags || [])].join(' ').toLowerCase();
|
|
285
|
+
const score = tokens.reduce((acc, t) => (haystack.includes(t) ? acc + 1 : acc), 0);
|
|
286
|
+
if (tokens.length === 0 || score > 0) {
|
|
287
|
+
onlineScenarios.push({
|
|
288
|
+
type: 'scenario',
|
|
289
|
+
id: s.id,
|
|
290
|
+
title: s.title?.[locale] || s.title?.zh || s.title?.en || s.id,
|
|
291
|
+
summary: s.subtitle?.[locale] || s.subtitle?.zh || s.summary || '',
|
|
292
|
+
tags: s.tags || [],
|
|
293
|
+
family_ids: s.family_ids || [],
|
|
294
|
+
reference_designs: s.reference_designs || [],
|
|
295
|
+
landing_url: appendUtm(s.landing_url || `https://www.seeedstudio.com/solutions/${s.id}`, {
|
|
296
|
+
source: 'sensecraft_agent',
|
|
297
|
+
medium: 'solution_consult',
|
|
298
|
+
campaign: s.id,
|
|
299
|
+
content: 'scenario_landing',
|
|
300
|
+
}),
|
|
301
|
+
repo_url: s.repo_url || '',
|
|
302
|
+
diagnostic_playbook: s.diagnostic_playbook || null,
|
|
303
|
+
source: 'bundled_manifest',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
if (onlineRefDesigns.length === 0) {
|
|
309
|
+
for (const rd of core.reference_designs || []) {
|
|
310
|
+
if (rd.published === false || rd.visibility === 'staging' || rd.visibility === 'draft') continue;
|
|
311
|
+
const locKey = locale === 'zh' ? 'zh_hans' : locale;
|
|
312
|
+
if (rd[`visibility_${locKey}`] === 'staging' || rd[`visibility_${locKey}`] === 'draft') continue;
|
|
313
|
+
|
|
314
|
+
const haystack = [rd.id, rd.title?.zh, rd.title?.en, rd.summary?.zh, rd.summary?.en, ...(rd.presets || [])].join(' ').toLowerCase();
|
|
315
|
+
const score = tokens.reduce((acc, t) => (haystack.includes(t) ? acc + 1 : acc), 0);
|
|
316
|
+
if (tokens.length === 0 || score > 0) {
|
|
317
|
+
onlineRefDesigns.push({
|
|
318
|
+
type: 'reference_design',
|
|
319
|
+
id: rd.id,
|
|
320
|
+
title: rd.title?.[locale] || rd.title?.zh || rd.title?.en || rd.id,
|
|
321
|
+
summary: rd.summary?.[locale] || rd.summary?.zh || rd.summary?.en || '',
|
|
322
|
+
presets: rd.presets || [],
|
|
323
|
+
landing_url: appendUtm(rd.landing_url || `https://www.seeedstudio.com/solutions/reference-designs/${rd.id}`, {
|
|
324
|
+
source: 'sensecraft_agent',
|
|
325
|
+
medium: 'solution_consult',
|
|
326
|
+
campaign: rd.id,
|
|
327
|
+
content: 'solution_landing',
|
|
328
|
+
}),
|
|
329
|
+
wiki_url: rd.wiki_url ? appendUtm(rd.wiki_url, {
|
|
330
|
+
source: 'sensecraft_agent',
|
|
331
|
+
medium: 'tech_wiki',
|
|
332
|
+
campaign: rd.id,
|
|
333
|
+
}) : '',
|
|
334
|
+
repo_url: rd.repo_url || '',
|
|
335
|
+
diagnostic_playbook: rd.diagnostic_playbook || null,
|
|
336
|
+
source: 'bundled_manifest',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const mergedRecords = [];
|
|
344
|
+
const maxOnline = Math.max(onlineScenarios.length, onlineRefDesigns.length);
|
|
345
|
+
for (let i = 0; i < maxOnline && mergedRecords.length < limit; i++) {
|
|
346
|
+
if (i < onlineScenarios.length && mergedRecords.length < limit) {
|
|
347
|
+
mergedRecords.push(onlineScenarios[i]);
|
|
348
|
+
}
|
|
349
|
+
if (i < onlineRefDesigns.length && mergedRecords.length < limit) {
|
|
350
|
+
mergedRecords.push(onlineRefDesigns[i]);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (mergedRecords.length > 0) {
|
|
248
355
|
return createCatalogDto({
|
|
249
|
-
source: 'typesense',
|
|
356
|
+
source: (onlineScenarios.some(r => r.source === 'typesense') || onlineRefDesigns.some(r => r.source === 'typesense')) ? 'typesense' : 'bundled_manifest',
|
|
250
357
|
freshness: 'FRESH',
|
|
251
|
-
records:
|
|
358
|
+
records: mergedRecords,
|
|
252
359
|
});
|
|
253
360
|
}
|
|
254
361
|
} catch {
|
|
@@ -264,6 +371,10 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
264
371
|
const tokens = qLower.split(/\s+/).filter(Boolean);
|
|
265
372
|
|
|
266
373
|
for (const s of core.scenarios || []) {
|
|
374
|
+
if (s.published === false || s.visibility === 'staging' || s.visibility === 'draft') continue;
|
|
375
|
+
const locKey = locale === 'zh' ? 'zh_hans' : locale;
|
|
376
|
+
if (s[`visibility_${locKey}`] === 'staging' || s[`visibility_${locKey}`] === 'draft') continue;
|
|
377
|
+
|
|
267
378
|
const haystack = [
|
|
268
379
|
s.id,
|
|
269
380
|
s.title?.zh,
|
|
@@ -298,6 +409,10 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
298
409
|
}
|
|
299
410
|
|
|
300
411
|
for (const rd of core.reference_designs || []) {
|
|
412
|
+
if (rd.published === false || rd.visibility === 'staging' || rd.visibility === 'draft') continue;
|
|
413
|
+
const locKey = locale === 'zh' ? 'zh_hans' : locale;
|
|
414
|
+
if (rd[`visibility_${locKey}`] === 'staging' || rd[`visibility_${locKey}`] === 'draft') continue;
|
|
415
|
+
|
|
301
416
|
const haystack = [
|
|
302
417
|
rd.id,
|
|
303
418
|
rd.title?.zh,
|
|
@@ -326,7 +441,7 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
326
441
|
medium: 'tech_wiki',
|
|
327
442
|
campaign: rd.id,
|
|
328
443
|
}) : '',
|
|
329
|
-
repo_url: rd.repo_url ||
|
|
444
|
+
repo_url: rd.repo_url || '',
|
|
330
445
|
diagnostic_playbook: rd.diagnostic_playbook || null,
|
|
331
446
|
score,
|
|
332
447
|
});
|
|
@@ -337,7 +452,16 @@ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
|
|
|
337
452
|
matchedScenarios.sort((a, b) => b.score - a.score);
|
|
338
453
|
matchedRefDesigns.sort((a, b) => b.score - a.score);
|
|
339
454
|
|
|
340
|
-
const combined = [
|
|
455
|
+
const combined = [];
|
|
456
|
+
const maxOffline = Math.max(matchedScenarios.length, matchedRefDesigns.length);
|
|
457
|
+
for (let i = 0; i < maxOffline && combined.length < limit; i++) {
|
|
458
|
+
if (i < matchedScenarios.length && combined.length < limit) {
|
|
459
|
+
combined.push(matchedScenarios[i]);
|
|
460
|
+
}
|
|
461
|
+
if (i < matchedRefDesigns.length && combined.length < limit) {
|
|
462
|
+
combined.push(matchedRefDesigns[i]);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
341
465
|
|
|
342
466
|
return createCatalogDto({
|
|
343
467
|
source: 'bundled_manifest',
|