@seeed-studio/solution-cli 0.0.0-stage → 0.2.0

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,327 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import https from 'node:https';
5
+ import http from 'node:http';
6
+ import { createCatalogDto } from './catalog-dto.js';
7
+ import { assertCompatibility, COMPATIBILITY_STATUS } from './compatibility.js';
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const DATA_FILE = path.join(__dirname, '..', 'catalog', 'knowledge-core.json');
12
+
13
+ let cachedKnowledgeCore = null;
14
+
15
+ /**
16
+ * Loads the bundled offline knowledge core file.
17
+ *
18
+ * @returns {Object} Bundled knowledge core data
19
+ */
20
+ export function loadKnowledgeCore() {
21
+ if (cachedKnowledgeCore) return cachedKnowledgeCore;
22
+ if (!fs.existsSync(DATA_FILE)) {
23
+ throw new Error(`Knowledge core manifest not found at ${DATA_FILE}. Run "npm run build:knowledge" to generate.`);
24
+ }
25
+ const raw = fs.readFileSync(DATA_FILE, 'utf-8');
26
+ cachedKnowledgeCore = JSON.parse(raw);
27
+ return cachedKnowledgeCore;
28
+ }
29
+
30
+ /**
31
+ * Performs an HTTP GET request with a timeout.
32
+ *
33
+ * @param {string} urlStr
34
+ * @param {Object} headers
35
+ * @param {number} timeoutMs
36
+ * @returns {Promise<Object>}
37
+ */
38
+ function fetchJson(urlStr, headers = {}, timeoutMs = 2500) {
39
+ return new Promise((resolve, reject) => {
40
+ const url = new URL(urlStr);
41
+ const client = url.protocol === 'https:' ? https : http;
42
+ const req = client.get(url, { headers, timeout: timeoutMs }, (res) => {
43
+ let body = '';
44
+ res.on('data', (chunk) => { body += chunk; });
45
+ res.on('end', () => {
46
+ if (res.statusCode >= 200 && res.statusCode < 300) {
47
+ try {
48
+ resolve(JSON.parse(body));
49
+ } catch (e) {
50
+ reject(new Error(`Invalid JSON from ${urlStr}: ${e.message}`));
51
+ }
52
+ } else {
53
+ reject(new Error(`HTTP ${res.statusCode} from ${urlStr}`));
54
+ }
55
+ });
56
+ });
57
+ req.on('timeout', () => {
58
+ req.destroy();
59
+ reject(new Error(`Timeout after ${timeoutMs}ms from ${urlStr}`));
60
+ });
61
+ req.on('error', reject);
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Searches Seeed Solutions Hub scenarios and reference designs.
67
+ *
68
+ * @param {Object} options
69
+ * @param {string} options.query - Free text search query
70
+ * @param {'zh' | 'en'} [options.locale='zh'] - Language preference
71
+ * @param {number} [options.limit=5] - Maximum number of results
72
+ * @returns {Promise<Object>} Standardized Catalog DTO
73
+ */
74
+ export async function searchHub({ query = '', locale = 'zh', limit = 5 } = {}) {
75
+ const qLower = (query || '').toLowerCase().trim();
76
+ const searchKey = process.env.SENSECRAFT_TYPESENSE_PURCHASE_SEARCH_KEY || process.env.TYPESENSE_SEARCH_KEY;
77
+ const host = process.env.TYPESENSE_HOST || 'https://rel-search.seeedstudio.com';
78
+
79
+ // Attempt online Typesense search if key is provided and not explicitly running offline
80
+ if (searchKey && !process.env.SENSECRAFT_OFFLINE_ONLY) {
81
+ try {
82
+ const searchUrl = `${host}/collections/Solution/documents/search?q=${encodeURIComponent(query)}&query_by=title,description,tags&per_page=${limit}`;
83
+ const data = await fetchJson(searchUrl, { 'X-TYPESENSE-API-KEY': searchKey }, 2500);
84
+ if (data && Array.isArray(data.hits)) {
85
+ const records = data.hits.map((hit) => {
86
+ const doc = hit.document || {};
87
+ return {
88
+ type: doc.doc_type || 'scenario',
89
+ id: doc.id || doc.slug,
90
+ title: doc.title,
91
+ summary: doc.summary || doc.description,
92
+ tags: doc.tags || [],
93
+ family_ids: doc.family_ids || [],
94
+ source: 'typesense',
95
+ };
96
+ });
97
+ return createCatalogDto({
98
+ source: 'typesense',
99
+ freshness: 'FRESH',
100
+ records: records.slice(0, limit),
101
+ });
102
+ }
103
+ } catch (err) {
104
+ // Graceful fallback to offline core with notice
105
+ }
106
+ }
107
+
108
+ // Offline fallback search over knowledge-core
109
+ const core = loadKnowledgeCore();
110
+ const matchedScenarios = [];
111
+ const matchedRefDesigns = [];
112
+
113
+ const tokens = qLower.split(/\s+/).filter(Boolean);
114
+
115
+ for (const s of core.scenarios || []) {
116
+ const haystack = [
117
+ s.id,
118
+ s.title?.zh,
119
+ s.title?.en,
120
+ s.subtitle?.zh,
121
+ s.subtitle?.en,
122
+ s.summary,
123
+ ...(s.tags || []),
124
+ ].join(' ').toLowerCase();
125
+
126
+ const score = tokens.reduce((acc, token) => (haystack.includes(token) ? acc + 1 : acc), 0);
127
+ if (tokens.length === 0 || score > 0) {
128
+ matchedScenarios.push({
129
+ type: 'scenario',
130
+ id: s.id,
131
+ title: s.title?.[locale] || s.title?.zh || s.title?.en || s.id,
132
+ summary: s.subtitle?.[locale] || s.subtitle?.zh || s.summary || '',
133
+ tags: s.tags || [],
134
+ family_ids: s.family_ids || [],
135
+ reference_designs: s.reference_designs || [],
136
+ score,
137
+ });
138
+ }
139
+ }
140
+
141
+ for (const rd of core.reference_designs || []) {
142
+ const haystack = [
143
+ rd.id,
144
+ rd.title?.zh,
145
+ rd.title?.en,
146
+ rd.summary?.zh,
147
+ rd.summary?.en,
148
+ ...(rd.presets || []),
149
+ ].join(' ').toLowerCase();
150
+
151
+ const score = tokens.reduce((acc, token) => (haystack.includes(token) ? acc + 1 : acc), 0);
152
+ if (tokens.length === 0 || score > 0) {
153
+ matchedRefDesigns.push({
154
+ type: 'reference_design',
155
+ id: rd.id,
156
+ title: rd.title?.[locale] || rd.title?.zh || rd.title?.en || rd.id,
157
+ summary: rd.summary?.[locale] || rd.summary?.zh || rd.summary?.en || '',
158
+ presets: rd.presets || [],
159
+ wiki_url: rd.wiki_url || '',
160
+ score,
161
+ });
162
+ }
163
+ }
164
+
165
+ // Sort by score descending and take top results
166
+ matchedScenarios.sort((a, b) => b.score - a.score);
167
+ matchedRefDesigns.sort((a, b) => b.score - a.score);
168
+
169
+ const combined = [...matchedScenarios, ...matchedRefDesigns].slice(0, limit);
170
+
171
+ return createCatalogDto({
172
+ source: 'bundled_manifest',
173
+ freshness: 'FRESH',
174
+ records: combined,
175
+ warnings: searchKey
176
+ ? ['Online Typesense request timed out; fell back to local bundled manifest.']
177
+ : ['No Typesense API key configured. Displaying verified local catalog.'],
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Queries product family information, SKUs, and official purchase links.
183
+ *
184
+ * @param {string} familyId
185
+ * @param {Object} [options]
186
+ * @param {string} [options.locale='zh']
187
+ * @returns {Object} Catalog DTO with product details
188
+ */
189
+ export function queryProductFamily(familyId, { locale = 'zh' } = {}) {
190
+ const core = loadKnowledgeCore();
191
+ const family = core.product_families?.[familyId];
192
+
193
+ if (!family) {
194
+ return createCatalogDto({
195
+ source: 'bundled_manifest',
196
+ freshness: 'FRESH',
197
+ records: [],
198
+ warnings: [`Product family "${familyId}" not found in local catalog.`],
199
+ });
200
+ }
201
+
202
+ return createCatalogDto({
203
+ source: 'bundled_manifest',
204
+ freshness: 'FRESH',
205
+ records: [{
206
+ family_id: family.id,
207
+ title: family.title?.[locale] || family.title?.zh || family.title?.en || family.id,
208
+ default_sku: family.default_sku,
209
+ regions: family.regions || [],
210
+ skus_count: (family.skus || []).length,
211
+ skus: (family.skus || []).slice(0, 10),
212
+ physical_spec: family.physical_spec || null,
213
+ required_accessories_count: (family.required_accessories || []).length,
214
+ optional_accessories_count: (family.optional_accessories || []).length,
215
+ }],
216
+ });
217
+ }
218
+
219
+ /**
220
+ * Queries and verifies accessories for a product family, running physical compatibility checks.
221
+ *
222
+ * @param {string} familyId
223
+ * @param {Object} [options]
224
+ * @param {string} [options.sku] - Specific host SKU
225
+ * @param {string} [options.locale='zh']
226
+ * @returns {Object} Catalog DTO with structured, verified accessories
227
+ */
228
+ export function queryAccessories(familyId, { sku = null, locale = 'zh' } = {}) {
229
+ const core = loadKnowledgeCore();
230
+ const family = core.product_families?.[familyId];
231
+
232
+ if (!family) {
233
+ return createCatalogDto({
234
+ source: 'bundled_manifest',
235
+ freshness: 'FRESH',
236
+ records: [],
237
+ warnings: [`Product family "${familyId}" not found in local catalog.`],
238
+ });
239
+ }
240
+
241
+ const hostPhysicalSpec = family.physical_spec || null;
242
+
243
+ // Process required accessories
244
+ const required = (family.required_accessories || []).map((acc) => {
245
+ const comp = assertCompatibility(hostPhysicalSpec, acc);
246
+ return {
247
+ category: 'REQUIRED',
248
+ id: acc.id,
249
+ name: acc.name,
250
+ sku: acc.sku,
251
+ url: acc.url,
252
+ qty: acc.qty || 1,
253
+ reason: acc.reason || 'Required for standard operation',
254
+ applies_when: acc.applies_when || null,
255
+ compatibility: {
256
+ is_compatible: comp.isCompatible,
257
+ status: comp.status,
258
+ details: comp.dimensions,
259
+ },
260
+ };
261
+ });
262
+
263
+ // Process optional accessories
264
+ const optional = (family.optional_accessories || []).map((acc) => {
265
+ const comp = assertCompatibility(hostPhysicalSpec, acc);
266
+ return {
267
+ category: 'OPTIONAL',
268
+ id: acc.id,
269
+ name: acc.name,
270
+ sku: acc.sku,
271
+ url: acc.url,
272
+ type: acc.type || 'peripheral',
273
+ compatibility: {
274
+ is_compatible: comp.isCompatible,
275
+ status: comp.status,
276
+ details: comp.dimensions,
277
+ },
278
+ };
279
+ });
280
+
281
+ return createCatalogDto({
282
+ source: 'bundled_manifest',
283
+ freshness: 'FRESH',
284
+ records: [{
285
+ family_id: family.id,
286
+ host_title: family.title?.[locale] || family.title?.zh || family.title?.en,
287
+ host_sku: sku || family.default_sku,
288
+ physical_spec: hostPhysicalSpec,
289
+ accessories: {
290
+ required,
291
+ optional,
292
+ },
293
+ }],
294
+ });
295
+ }
296
+
297
+ /**
298
+ * Generates an immutable DeploymentLockfile to isolate deployment apply from external network.
299
+ *
300
+ * @param {Object} options
301
+ * @param {string} options.solutionId
302
+ * @param {string} options.solutionVersion
303
+ * @param {Array<string>} options.selectedHardware
304
+ * @param {Array<Object>} [options.verifiedCompatibility=[]]
305
+ * @returns {Object} DeploymentLockfile
306
+ */
307
+ export function createDeploymentLockfile({
308
+ solutionId,
309
+ solutionVersion = '1.0.0',
310
+ selectedHardware = [],
311
+ verifiedCompatibility = [],
312
+ }) {
313
+ return {
314
+ $schema: 'https://spec.seeedstudio.com/schemas/v2/deployment-lock.json',
315
+ solution_id: solutionId,
316
+ solution_version: solutionVersion,
317
+ locked_at: new Date().toISOString(),
318
+ dataset_version: '2026-10-13.1',
319
+ selected_hardware: selectedHardware,
320
+ compatibility_assertions: verifiedCompatibility,
321
+ unresolved_requirements: [],
322
+ network_policy: {
323
+ commercial_queries_allowed: false,
324
+ apply_isolated: true,
325
+ },
326
+ };
327
+ }
package/lib/ledger.js ADDED
@@ -0,0 +1,302 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs';
3
+ import crypto from 'node:crypto';
4
+ import { getScHome } from './platform.js';
5
+
6
+ export function getLedgerPath() {
7
+ return path.join(getScHome(), 'skills-ledger.json');
8
+ }
9
+
10
+ export function getLockPath() {
11
+ return path.join(getScHome(), '.skills.lock');
12
+ }
13
+
14
+ export function withLedgerLock(fn) {
15
+ const lockFile = getLockPath();
16
+ const dir = path.dirname(lockFile);
17
+ fs.mkdirSync(dir, { recursive: true });
18
+
19
+ const maxAttempts = 60;
20
+ let attempts = 0;
21
+ let fd = null;
22
+
23
+ while (attempts++ < maxAttempts) {
24
+ try {
25
+ fd = fs.openSync(lockFile, 'wx');
26
+ // Record PID and timestamp into lock file
27
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, time: Date.now() }), 'utf8');
28
+ break;
29
+ } catch {
30
+ // Check if lock is stale or holder process is dead
31
+ try {
32
+ const raw = fs.readFileSync(lockFile, 'utf8');
33
+ const lockInfo = JSON.parse(raw);
34
+ let isDead = false;
35
+
36
+ if (lockInfo && lockInfo.pid) {
37
+ try {
38
+ // Check if process exists: kill(0) throws ESRCH if process is dead
39
+ process.kill(lockInfo.pid, 0);
40
+ } catch (err) {
41
+ if (err.code === 'ESRCH') {
42
+ isDead = true;
43
+ }
44
+ }
45
+ }
46
+
47
+ // Break stale lock only if process is dead or older than 60s
48
+ if (isDead || (Date.now() - lockInfo.time > 60000)) {
49
+ fs.unlinkSync(lockFile);
50
+ continue;
51
+ }
52
+ } catch {}
53
+
54
+ // Sleep 100ms synchronously
55
+ const end = Date.now() + 100;
56
+ while (Date.now() < end) {}
57
+ }
58
+ }
59
+
60
+ if (fd === null) {
61
+ throw new Error('Could not acquire installation lock; another SenseCraft operation may be running.');
62
+ }
63
+
64
+ try {
65
+ return fn();
66
+ } finally {
67
+ try { fs.closeSync(fd); } catch {}
68
+ try { fs.unlinkSync(lockFile); } catch {}
69
+ }
70
+ }
71
+
72
+ export function loadLedger() {
73
+ const ledgerPath = getLedgerPath();
74
+ if (!fs.existsSync(ledgerPath)) {
75
+ return {
76
+ schema_version: 1,
77
+ installations: {}
78
+ };
79
+ }
80
+ try {
81
+ const raw = fs.readFileSync(ledgerPath, 'utf8');
82
+ const parsed = JSON.parse(raw);
83
+ if (!parsed || typeof parsed !== 'object' || !parsed.installations) {
84
+ throw new Error('Malformed skills ledger JSON schema');
85
+ }
86
+ return parsed;
87
+ } catch (err) {
88
+ throw new Error(`Corrupted skills ledger at ${ledgerPath}: ${err.message}. Please inspect or backup.`);
89
+ }
90
+ }
91
+
92
+ export function saveLedger(ledger) {
93
+ const ledgerPath = getLedgerPath();
94
+ const dir = path.dirname(ledgerPath);
95
+ fs.mkdirSync(dir, { recursive: true });
96
+
97
+ const tempFile = path.join(dir, `.skills-ledger.tmp-${Date.now()}-${crypto.randomBytes(4).toString('hex')}`);
98
+ fs.writeFileSync(tempFile, JSON.stringify(ledger, null, 2), 'utf8');
99
+ fs.renameSync(tempFile, ledgerPath);
100
+ }
101
+
102
+ export function computeFileHash(filePath) {
103
+ if (!fs.existsSync(filePath)) return null;
104
+ const content = fs.readFileSync(filePath);
105
+ return crypto.createHash('sha256').update(content).digest('hex');
106
+ }
107
+
108
+ // Ensure target file stays strictly inside targetDir, refusing symlink escapes (Codex High 2 & P2)
109
+ export function isSafePathInside(childPath, parentDir) {
110
+ try {
111
+ const parentReal = fs.realpathSync(parentDir);
112
+
113
+ // Codex P2: Detect symlinks even if target does not exist yet (dangling symlinks)
114
+ const lstat = fs.lstatSync(childPath, { throwIfNoEntry: false });
115
+ if (lstat && lstat.isSymbolicLink()) {
116
+ const linkTarget = fs.readlinkSync(childPath);
117
+ const resolved = path.resolve(path.dirname(childPath), linkTarget);
118
+ if (!fs.existsSync(resolved)) {
119
+ return false; // Reject dangling symlink destination
120
+ }
121
+ const targetReal = fs.realpathSync(resolved);
122
+ const relTarget = path.relative(parentReal, targetReal);
123
+ return !relTarget.startsWith('..') && !path.isAbsolute(relTarget);
124
+ }
125
+
126
+ // If child exists, verify its realpath
127
+ if (fs.existsSync(childPath)) {
128
+ const childReal = fs.realpathSync(childPath);
129
+ const rel = path.relative(parentReal, childReal);
130
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
131
+ }
132
+ // If child doesn't exist yet, verify its nearest existing ancestor
133
+ let cur = path.resolve(childPath);
134
+ while (cur !== path.dirname(cur)) {
135
+ if (fs.existsSync(cur)) {
136
+ const curReal = fs.realpathSync(cur);
137
+ const rel = path.relative(parentReal, curReal);
138
+ return !rel.startsWith('..') && !path.isAbsolute(rel);
139
+ }
140
+ cur = path.dirname(cur);
141
+ }
142
+ return false;
143
+ } catch {
144
+ return false;
145
+ }
146
+ }
147
+
148
+ export function checkFileModifications(targetDir, filesMap) {
149
+ const modified = [];
150
+ const missing = [];
151
+ const intact = [];
152
+
153
+ for (const [relPath, recordedHash] of Object.entries(filesMap || {})) {
154
+ if (relPath.includes('..') || path.isAbsolute(relPath)) {
155
+ continue;
156
+ }
157
+ const fullPath = path.join(targetDir, relPath);
158
+ if (!isSafePathInside(fullPath, targetDir)) {
159
+ continue;
160
+ }
161
+
162
+ if (!fs.existsSync(fullPath)) {
163
+ missing.push(relPath);
164
+ } else {
165
+ const currentHash = computeFileHash(fullPath);
166
+ if (currentHash !== recordedHash) {
167
+ modified.push(relPath);
168
+ } else {
169
+ intact.push(relPath);
170
+ }
171
+ }
172
+ }
173
+
174
+ return { modified, missing, intact };
175
+ }
176
+
177
+ export function recordInstallation(agentId, skillsDir, targetDir, skillVersion, filesMap) {
178
+ const ledger = loadLedger();
179
+ ledger.installations = ledger.installations || {};
180
+
181
+ ledger.installations[agentId] = {
182
+ agent_id: agentId,
183
+ skills_dir: path.resolve(skillsDir),
184
+ target_dir: path.resolve(targetDir),
185
+ skill_version: skillVersion,
186
+ installed_at: new Date().toISOString(),
187
+ files: filesMap
188
+ };
189
+
190
+ ledger.last_updated = new Date().toISOString();
191
+ saveLedger(ledger);
192
+ }
193
+
194
+ export function removeInstallation(agentId, options = {}) {
195
+ const { force = false, dryRun = false } = options;
196
+ const runWithLock = dryRun ? fn => fn() : withLedgerLock;
197
+
198
+ return runWithLock(() => {
199
+ const ledger = loadLedger();
200
+
201
+ if (!ledger.installations || !ledger.installations[agentId]) {
202
+ return { success: false, reason: `No tracked installation found for agent "${agentId}".` };
203
+ }
204
+
205
+ const record = ledger.installations[agentId];
206
+ const targetDir = path.resolve(record.target_dir);
207
+
208
+ if (!fs.existsSync(targetDir)) {
209
+ if (!dryRun) {
210
+ delete ledger.installations[agentId];
211
+ saveLedger(ledger);
212
+ }
213
+ return { success: true, message: 'Target directory already absent. Cleaned ledger record.' };
214
+ }
215
+
216
+ const files = record.files || {};
217
+ let removedCount = 0;
218
+ let preservedCount = 0;
219
+ const actions = [];
220
+
221
+ for (const [relPath, recordedHash] of Object.entries(files)) {
222
+ if (relPath.includes('..') || path.isAbsolute(relPath)) {
223
+ continue;
224
+ }
225
+ const fullPath = path.join(targetDir, relPath);
226
+
227
+ // Verify safe path inside targetDir (Codex High 2: Prevent symlink escape)
228
+ if (!isSafePathInside(fullPath, targetDir)) {
229
+ actions.push({ file: fullPath, action: 'skip_symlink_escape' });
230
+ continue;
231
+ }
232
+
233
+ if (fs.existsSync(fullPath)) {
234
+ const currentHash = computeFileHash(fullPath);
235
+ if (currentHash === recordedHash || force) {
236
+ actions.push({ file: fullPath, action: 'delete' });
237
+ if (!dryRun) {
238
+ fs.unlinkSync(fullPath);
239
+ }
240
+ removedCount++;
241
+ } else {
242
+ actions.push({ file: fullPath, action: 'preserve_modified' });
243
+ preservedCount++;
244
+ }
245
+ }
246
+ }
247
+
248
+ // Remove marker file
249
+ const marker = path.join(targetDir, '.installed-by-sensecraft');
250
+ if (fs.existsSync(marker) && isSafePathInside(marker, targetDir)) {
251
+ actions.push({ file: marker, action: 'delete_marker' });
252
+ if (!dryRun) {
253
+ try { fs.unlinkSync(marker); } catch {}
254
+ }
255
+ }
256
+
257
+ if (!dryRun) {
258
+ cleanEmptyDirs(targetDir);
259
+ try {
260
+ if (fs.existsSync(targetDir) && fs.readdirSync(targetDir).length === 0) {
261
+ fs.rmdirSync(targetDir);
262
+ }
263
+ } catch {}
264
+
265
+ delete ledger.installations[agentId];
266
+ saveLedger(ledger);
267
+ }
268
+
269
+ return {
270
+ success: true,
271
+ removedCount,
272
+ preservedCount,
273
+ targetDir,
274
+ dryRun,
275
+ actions
276
+ };
277
+ });
278
+ }
279
+
280
+ function cleanEmptyDirs(dir) {
281
+ if (!fs.existsSync(dir)) return;
282
+ // Don't clean inside external symlinks
283
+ try {
284
+ const stat = fs.lstatSync(dir);
285
+ if (stat.isSymbolicLink()) return;
286
+ } catch {
287
+ return;
288
+ }
289
+
290
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
291
+ for (const entry of entries) {
292
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
293
+ const sub = path.join(dir, entry.name);
294
+ cleanEmptyDirs(sub);
295
+ try {
296
+ if (fs.readdirSync(sub).length === 0) {
297
+ fs.rmdirSync(sub);
298
+ }
299
+ } catch {}
300
+ }
301
+ }
302
+ }