@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/docs/cli-guide.md CHANGED
@@ -37,15 +37,28 @@
37
37
  ## 2. 安装与 Agent 技能挂载
38
38
 
39
39
  ### 2.1 一键免安装即时调用 (推荐)
40
- 无需全局安装,利用 `npx` 即可随时调用:
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 --solution fall_detection --solutions-dir ./my-solutions/solutions
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(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
  }
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 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&per_page=${limit}`;
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 rdUrl = `${host}/collections/reference_design/documents/search?q=${encodeURIComponent(query)}&query_by=name_zh_hans,name_en,summary_zh_hans,summary_en,tags&per_page=${limit}`;
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 onlineRecords = [];
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
- onlineRecords.push({
183
- type: 'scenario',
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(`https://www.seeedstudio.com/solutions/${docId}`, {
202
+ landing_url: appendUtm(rawLanding, {
191
203
  source: 'sensecraft_agent',
192
204
  medium: 'solution_consult',
193
205
  campaign: docId,
194
- content: 'scenario_landing',
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
- onlineRecords.push({
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: doc.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: doc.repo_url || `https://github.com/suharvest/sensecraft-solutions/tree/main/solutions/${docId}`,
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
- if (onlineRecords.length > 0) {
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: onlineRecords.slice(0, limit),
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 || `https://github.com/suharvest/sensecraft-solutions/tree/main/solutions/${rd.id}`,
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 = [...matchedScenarios, ...matchedRefDesigns].slice(0, limit);
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',