@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.
- package/README.md +77 -2
- package/bin/cli.js +327 -0
- package/bin/runner.js +39 -0
- package/catalog/knowledge-core.json +7970 -0
- package/lib/agent.js +160 -0
- package/lib/catalog-dto.js +69 -0
- package/lib/compatibility.js +312 -0
- package/lib/detector.js +321 -0
- package/lib/doctor.js +179 -0
- package/lib/downloader.js +219 -0
- package/lib/installer.js +375 -0
- package/lib/knowledge.js +327 -0
- package/lib/ledger.js +302 -0
- package/lib/platform.js +113 -0
- package/package.json +52 -4
- package/skills/sensecraft/SKILL.md +183 -0
- package/skills/sensecraft/references/app-only.md +273 -0
- package/skills/sensecraft/references/consult-and-select.md +56 -0
- package/skills/sensecraft/references/deploy.md +300 -0
- package/skills/sensecraft/references/diagnose.md +133 -0
- package/skills/sensecraft/references/error-catalog.md +33 -0
- package/skills/sensecraft/references/hardware-bom.md +83 -0
- package/skills/sensecraft/references/offline.md +78 -0
- package/skills/sensecraft/references/verify.md +136 -0
package/lib/knowledge.js
ADDED
|
@@ -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
|
+
}
|