@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
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import crypto from 'node:crypto';
|
|
5
|
+
import { execFileSync } from 'node:child_process';
|
|
6
|
+
import { getPlatformInfo, getEngineBinDir, getEngineBinaryPath, getScHome } from './platform.js';
|
|
7
|
+
import { verifyEngineBinary } from './detector.js';
|
|
8
|
+
|
|
9
|
+
const LATEST_MANIFEST_URL = 'https://sensecraft-statics.seeed.cc/solution-app/engine/latest.json';
|
|
10
|
+
const MAX_ARCHIVE_BYTES = 50 * 1024 * 1024; // 50MB limit
|
|
11
|
+
const NETWORK_TIMEOUT_MS = 60000; // 60s timeout
|
|
12
|
+
|
|
13
|
+
export const PINNED_MINISIGN_PUBKEY = 'RWTg282IuJHsoMOFZC0DP1q0LrS384gJsCLrXyX3CIzIKLPUHm3GGmBv';
|
|
14
|
+
|
|
15
|
+
export function verifyManifestSignature(manifestBytes, sigText, pubKeyB64 = PINNED_MINISIGN_PUBKEY) {
|
|
16
|
+
try {
|
|
17
|
+
const pubRaw = Buffer.from(pubKeyB64, 'base64').subarray(10);
|
|
18
|
+
const spkiPrefix = Buffer.from('302a300506032b6570032100', 'hex');
|
|
19
|
+
const spkiKey = Buffer.concat([spkiPrefix, pubRaw]);
|
|
20
|
+
const publicKey = crypto.createPublicKey({ key: spkiKey, format: 'der', type: 'spki' });
|
|
21
|
+
|
|
22
|
+
const sigLines = sigText.trim().split('\n');
|
|
23
|
+
if (sigLines.length < 2) return false;
|
|
24
|
+
const sigRaw = Buffer.from(sigLines[1], 'base64').subarray(10);
|
|
25
|
+
|
|
26
|
+
const prehash = crypto.createHash('blake2b512').update(manifestBytes).digest();
|
|
27
|
+
return crypto.verify(null, prehash, publicKey, sigRaw);
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function fetchManifest() {
|
|
34
|
+
const controller = new AbortController();
|
|
35
|
+
const timeout = setTimeout(() => controller.abort(), 15000);
|
|
36
|
+
try {
|
|
37
|
+
const res = await fetch(LATEST_MANIFEST_URL, { signal: controller.signal });
|
|
38
|
+
if (!res.ok) {
|
|
39
|
+
throw new Error(`Failed to fetch engine manifest from ${LATEST_MANIFEST_URL}: HTTP ${res.status}`);
|
|
40
|
+
}
|
|
41
|
+
const manifestBytes = Buffer.from(await res.arrayBuffer());
|
|
42
|
+
|
|
43
|
+
// Fetch and verify detached signature (Codex P1)
|
|
44
|
+
const sigRes = await fetch(`${LATEST_MANIFEST_URL}.minisig`, { signal: controller.signal });
|
|
45
|
+
if (!sigRes.ok) {
|
|
46
|
+
throw new Error(`Failed to fetch manifest signature from ${LATEST_MANIFEST_URL}.minisig: HTTP ${sigRes.status}`);
|
|
47
|
+
}
|
|
48
|
+
const sigText = await sigRes.text();
|
|
49
|
+
const isSigValid = verifyManifestSignature(manifestBytes, sigText);
|
|
50
|
+
if (!isSigValid) {
|
|
51
|
+
throw new Error('Engine manifest signature verification failed against trusted Seeed public key');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return JSON.parse(manifestBytes.toString('utf8'));
|
|
55
|
+
} finally {
|
|
56
|
+
clearTimeout(timeout);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function downloadEngine(options = {}) {
|
|
61
|
+
const { onProgress = () => {} } = options;
|
|
62
|
+
const platform = getPlatformInfo();
|
|
63
|
+
|
|
64
|
+
if (!platform.supported || !platform.target) {
|
|
65
|
+
throw new Error(`Cannot download engine: ${platform.note}`);
|
|
66
|
+
}
|
|
67
|
+
|
|
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)`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
onProgress(`Downloading sensecraft-engine v${manifest.version}...`);
|
|
81
|
+
const controller = new AbortController();
|
|
82
|
+
const timeout = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS);
|
|
83
|
+
|
|
84
|
+
let buffer;
|
|
85
|
+
try {
|
|
86
|
+
const response = await fetch(asset.url, { signal: controller.signal });
|
|
87
|
+
if (!response.ok) {
|
|
88
|
+
throw new Error(`Failed to download binary from ${asset.url}: HTTP ${response.status}`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// Stream download and enforce byte limit strictly during streaming (Codex Medium 3)
|
|
92
|
+
if (!response.body) {
|
|
93
|
+
throw new Error('Response body is empty or not streamable');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const reader = response.body.getReader();
|
|
97
|
+
const chunks = [];
|
|
98
|
+
let receivedBytes = 0;
|
|
99
|
+
|
|
100
|
+
while (true) {
|
|
101
|
+
const { done, value } = await reader.read();
|
|
102
|
+
if (done) break;
|
|
103
|
+
receivedBytes += value.length;
|
|
104
|
+
if (receivedBytes > MAX_ARCHIVE_BYTES) {
|
|
105
|
+
controller.abort();
|
|
106
|
+
throw new Error(`Streamed download exceeded maximum allowed size of ${MAX_ARCHIVE_BYTES} bytes (aborted at ${receivedBytes} bytes)`);
|
|
107
|
+
}
|
|
108
|
+
chunks.push(value);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
buffer = Buffer.concat(chunks);
|
|
112
|
+
} finally {
|
|
113
|
+
clearTimeout(timeout);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
onProgress('Verifying cryptographic SHA256 integrity...');
|
|
117
|
+
const computedHash = crypto.createHash('sha256').update(buffer).digest('hex').toLowerCase();
|
|
118
|
+
const expectedHash = asset.sha256.toLowerCase();
|
|
119
|
+
|
|
120
|
+
if (computedHash !== expectedHash) {
|
|
121
|
+
throw new Error(`Integrity check failed! Expected SHA256: ${expectedHash}, computed: ${computedHash}`);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Staging extraction to ensure atomicity & verification before replacing
|
|
125
|
+
const scHome = getScHome();
|
|
126
|
+
const nonce = crypto.randomBytes(6).toString('hex');
|
|
127
|
+
const stageDir = path.join(scHome, `.stage-${nonce}`);
|
|
128
|
+
const binDir = getEngineBinDir();
|
|
129
|
+
fs.mkdirSync(stageDir, { recursive: true });
|
|
130
|
+
fs.mkdirSync(binDir, { recursive: true });
|
|
131
|
+
|
|
132
|
+
const tempArchive = path.join(stageDir, `engine-archive.${platform.isWindows ? 'zip' : 'tar.gz'}`);
|
|
133
|
+
fs.writeFileSync(tempArchive, buffer);
|
|
134
|
+
|
|
135
|
+
try {
|
|
136
|
+
onProgress('Extracting engine binary into staging area...');
|
|
137
|
+
if (platform.isWindows) {
|
|
138
|
+
execFileSync('powershell', ['-NoProfile', '-Command', '$ProgressPreference="SilentlyContinue"; Expand-Archive -LiteralPath $env:STAGE_ARCHIVE -DestinationPath $env:STAGE_DEST -Force'], {
|
|
139
|
+
env: {
|
|
140
|
+
...process.env,
|
|
141
|
+
STAGE_ARCHIVE: tempArchive,
|
|
142
|
+
STAGE_DEST: stageDir
|
|
143
|
+
},
|
|
144
|
+
stdio: 'ignore',
|
|
145
|
+
timeout: 30000
|
|
146
|
+
});
|
|
147
|
+
} else {
|
|
148
|
+
execFileSync('tar', ['-xzf', tempArchive, '-C', stageDir], {
|
|
149
|
+
stdio: 'ignore',
|
|
150
|
+
timeout: 30000
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const stagedBin = path.join(stageDir, platform.binaryName);
|
|
155
|
+
if (!fs.existsSync(stagedBin)) {
|
|
156
|
+
throw new Error(`Archive extraction succeeded but "${platform.binaryName}" was not found in archive.`);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!platform.isWindows) {
|
|
160
|
+
fs.chmodSync(stagedBin, 0o755);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
onProgress('Verifying extracted binary functionality...');
|
|
164
|
+
const verifyResult = verifyEngineBinary(stagedBin);
|
|
165
|
+
if (!verifyResult.valid) {
|
|
166
|
+
throw new Error(`Downloaded engine verification failed: ${verifyResult.reason}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const finalBin = getEngineBinaryPath();
|
|
170
|
+
const backupBin = path.join(binDir, `.backup-${platform.binaryName}-${nonce}`);
|
|
171
|
+
|
|
172
|
+
if (fs.existsSync(finalBin)) {
|
|
173
|
+
try { fs.renameSync(finalBin, backupBin); } catch {}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
try {
|
|
177
|
+
if (platform.isWindows) {
|
|
178
|
+
fs.copyFileSync(stagedBin, finalBin);
|
|
179
|
+
} else {
|
|
180
|
+
fs.renameSync(stagedBin, finalBin);
|
|
181
|
+
}
|
|
182
|
+
if (fs.existsSync(backupBin)) {
|
|
183
|
+
try { fs.unlinkSync(backupBin); } catch {}
|
|
184
|
+
}
|
|
185
|
+
} catch (swapErr) {
|
|
186
|
+
if (fs.existsSync(backupBin)) {
|
|
187
|
+
try { fs.renameSync(backupBin, finalBin); } catch {}
|
|
188
|
+
}
|
|
189
|
+
throw swapErr;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
onProgress(`Engine installed successfully to ${finalBin}`);
|
|
193
|
+
return {
|
|
194
|
+
version: verifyResult.version,
|
|
195
|
+
path: finalBin
|
|
196
|
+
};
|
|
197
|
+
} finally {
|
|
198
|
+
if (fs.existsSync(stageDir)) {
|
|
199
|
+
try { fs.rmSync(stageDir, { recursive: true, force: true }); } catch {}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function pullSolutionContent(binPath, onProgress = () => {}) {
|
|
205
|
+
onProgress('Pulling official solution definitions from CDN (sensecraft-engine content pull)...');
|
|
206
|
+
try {
|
|
207
|
+
execFileSync(binPath, ['content', 'pull'], {
|
|
208
|
+
encoding: 'utf8',
|
|
209
|
+
timeout: 120000,
|
|
210
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
211
|
+
});
|
|
212
|
+
onProgress('Solution content successfully pulled and verified.');
|
|
213
|
+
return { success: true };
|
|
214
|
+
} catch (err) {
|
|
215
|
+
const msg = err.stderr ? err.stderr.toString().trim() : err.message;
|
|
216
|
+
onProgress(`Notice: content pull completed with warning: ${msg}`);
|
|
217
|
+
return { success: false, warning: msg };
|
|
218
|
+
}
|
|
219
|
+
}
|
package/lib/installer.js
ADDED
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import {
|
|
5
|
+
getPlatformInfo,
|
|
6
|
+
getEngineBinaryPath,
|
|
7
|
+
getContentDir,
|
|
8
|
+
setConfiguredEnginePath,
|
|
9
|
+
getConfiguredEnginePath
|
|
10
|
+
} from './platform.js';
|
|
11
|
+
import { detectExistingEngine, detectDesktopApp, verifyEngineBinary } from './detector.js';
|
|
12
|
+
import { downloadEngine, pullSolutionContent } from './downloader.js';
|
|
13
|
+
import { scanLocalAgents, promptAgentSelection } from './agent.js';
|
|
14
|
+
import {
|
|
15
|
+
recordInstallation,
|
|
16
|
+
removeInstallation,
|
|
17
|
+
loadLedger,
|
|
18
|
+
computeFileHash,
|
|
19
|
+
withLedgerLock,
|
|
20
|
+
isSafePathInside
|
|
21
|
+
} from './ledger.js';
|
|
22
|
+
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = path.dirname(__filename);
|
|
25
|
+
const BUNDLED_SKILLS_SRC = path.resolve(__dirname, '..', 'skills', 'sensecraft');
|
|
26
|
+
|
|
27
|
+
export async function runInstall(options = {}) {
|
|
28
|
+
const {
|
|
29
|
+
agent = null,
|
|
30
|
+
yes = false,
|
|
31
|
+
dryRun = false,
|
|
32
|
+
skipContent = false,
|
|
33
|
+
force = false,
|
|
34
|
+
enginePath = null,
|
|
35
|
+
logger = console.log
|
|
36
|
+
} = options;
|
|
37
|
+
|
|
38
|
+
logger('\x1b[1m\x1b[34m====================================================\x1b[0m');
|
|
39
|
+
logger('\x1b[1m\x1b[34m SenseCraft Engine & AI Agent Skill Installer \x1b[0m');
|
|
40
|
+
logger('\x1b[1m\x1b[34m====================================================\x1b[0m\n');
|
|
41
|
+
|
|
42
|
+
const platform = getPlatformInfo();
|
|
43
|
+
if (!platform.supported) {
|
|
44
|
+
throw new Error(`Unsupported host: ${platform.note}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// --- Step 0: Pre-validate agent targets before ANY download/side-effects (Codex Medium 11) ---
|
|
48
|
+
const scanned = scanLocalAgents();
|
|
49
|
+
const selectedAgents = await promptAgentSelection(scanned, agent, yes);
|
|
50
|
+
|
|
51
|
+
if (selectedAgents.length === 0) {
|
|
52
|
+
logger('\nNo agents selected. Installation halted with no filesystem modifications.');
|
|
53
|
+
return { success: true, engineBin: null, installedAgents: [] };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// --- Step 1: Detect & Verify Engine Candidate ---
|
|
57
|
+
logger('🔍 Phase 1: Inspecting host environment for existing Engine/App...');
|
|
58
|
+
let engineBin = null;
|
|
59
|
+
let engineVersion = null;
|
|
60
|
+
let reusedSource = null;
|
|
61
|
+
|
|
62
|
+
if (enginePath) {
|
|
63
|
+
const resolvedPath = path.resolve(enginePath);
|
|
64
|
+
logger(`Checking user-specified engine at: ${resolvedPath}`);
|
|
65
|
+
const check = verifyEngineBinary(resolvedPath);
|
|
66
|
+
if (!check.valid) {
|
|
67
|
+
throw new Error(`Specified engine at ${resolvedPath} is not valid: ${check.reason}`);
|
|
68
|
+
}
|
|
69
|
+
engineBin = resolvedPath;
|
|
70
|
+
engineVersion = check.version;
|
|
71
|
+
reusedSource = 'custom';
|
|
72
|
+
} else if (!force) {
|
|
73
|
+
// 1.1 Check default / PATH engine
|
|
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
|
+
}
|
|
105
|
+
}
|
|
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...');
|
|
112
|
+
engineBin = path.resolve(getEngineBinaryPath());
|
|
113
|
+
engineVersion = '0.0.3';
|
|
114
|
+
reusedSource = 'download';
|
|
115
|
+
} else {
|
|
116
|
+
logger('⬇ Downloading latest standalone engine binary from CDN...');
|
|
117
|
+
const dl = await downloadEngine({
|
|
118
|
+
onProgress: msg => logger(` ${msg}`)
|
|
119
|
+
});
|
|
120
|
+
engineBin = path.resolve(dl.path);
|
|
121
|
+
engineVersion = dl.version;
|
|
122
|
+
reusedSource = 'download';
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Persist configured engine pointer
|
|
127
|
+
if (!dryRun) {
|
|
128
|
+
setConfiguredEnginePath(engineBin, engineVersion, reusedSource);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
logger(`\n📦 Engine ready: ${engineBin} (v${engineVersion}) [Source: ${reusedSource}]`);
|
|
132
|
+
|
|
133
|
+
// --- Step 2: Content Cache ---
|
|
134
|
+
if (!skipContent && !dryRun) {
|
|
135
|
+
logger('\n🔄 Phase 2: Ensuring IoT solutions catalog is populated...');
|
|
136
|
+
pullSolutionContent(engineBin, msg => logger(` ${msg}`));
|
|
137
|
+
} else if (skipContent) {
|
|
138
|
+
logger('\n🔄 Phase 2: Skipped solution pull (--skip-content).');
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Step 3: Inject Skills with Modification Protection inside Lock (Codex High 1, 2 & Medium 6) ---
|
|
142
|
+
logger(`\n🤖 Phase 3: AI Agent Skills Configuration (Targets: ${selectedAgents.map(a => a.name).join(', ')})...`);
|
|
143
|
+
|
|
144
|
+
const installedAgents = [];
|
|
145
|
+
|
|
146
|
+
const runWithLock = dryRun ? fn => fn() : withLedgerLock;
|
|
147
|
+
|
|
148
|
+
runWithLock(() => {
|
|
149
|
+
const ledger = dryRun ? { installations: {} } : loadLedger();
|
|
150
|
+
|
|
151
|
+
for (const a of selectedAgents) {
|
|
152
|
+
const destDir = path.resolve(path.join(a.skillsDir, 'sensecraft'));
|
|
153
|
+
logger(`\nConfiguring ${a.name} at: ${destDir}...`);
|
|
154
|
+
|
|
155
|
+
if (dryRun) {
|
|
156
|
+
logger(` [dry-run] Would write SKILL.md and references to ${destDir}`);
|
|
157
|
+
installedAgents.push({ id: a.id, name: a.name, path: destDir });
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
162
|
+
|
|
163
|
+
const priorRecord = ledger.installations ? ledger.installations[a.id] : null;
|
|
164
|
+
const priorFiles = priorRecord ? priorRecord.files : {};
|
|
165
|
+
|
|
166
|
+
const filesWritten = {};
|
|
167
|
+
const copyResult = copyFolderSafely(BUNDLED_SKILLS_SRC, destDir, destDir, priorFiles, force, logger);
|
|
168
|
+
|
|
169
|
+
Object.assign(filesWritten, copyResult.filesWritten);
|
|
170
|
+
|
|
171
|
+
// Customize SKILL.md only if actually copied by this install and not an external symlink (Codex P1)
|
|
172
|
+
const skillMdPath = path.join(destDir, 'SKILL.md');
|
|
173
|
+
if (fs.existsSync(skillMdPath) && copyResult.updatedFiles.includes('SKILL.md')) {
|
|
174
|
+
const skillStat = fs.lstatSync(skillMdPath, { throwIfNoEntry: false });
|
|
175
|
+
if (skillStat && !skillStat.isSymbolicLink()) {
|
|
176
|
+
let content = fs.readFileSync(skillMdPath, 'utf8');
|
|
177
|
+
const escapedBinPath = engineBin.replace(/\\/g, '/');
|
|
178
|
+
const instructionBlock = `\n> **SenseCraft Runtime**: All steps run via \`${escapedBinPath}\`. If shell $PATH does not contain \`sensecraft-engine\`, always invoke using the full absolute path: \`"${escapedBinPath}" <subcommand>\`.\n\n`;
|
|
179
|
+
|
|
180
|
+
if (!content.includes('SenseCraft Runtime:')) {
|
|
181
|
+
content = content.replace(/(---[\s\S]*?---\n)/, `$1${instructionBlock}`);
|
|
182
|
+
fs.writeFileSync(skillMdPath, content, 'utf8');
|
|
183
|
+
}
|
|
184
|
+
filesWritten['SKILL.md'] = computeFileHash(skillMdPath);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Write installation marker safely without following external symlinks (Codex P2)
|
|
189
|
+
const markerPath = path.join(destDir, '.installed-by-sensecraft');
|
|
190
|
+
if (isSafePathInside(markerPath, destDir)) {
|
|
191
|
+
const markerStat = fs.lstatSync(markerPath, { throwIfNoEntry: false });
|
|
192
|
+
if (markerStat && markerStat.isSymbolicLink()) {
|
|
193
|
+
try { fs.unlinkSync(markerPath); } catch {}
|
|
194
|
+
}
|
|
195
|
+
fs.writeFileSync(
|
|
196
|
+
markerPath,
|
|
197
|
+
`version=${engineVersion}\ninstalled_at=${new Date().toISOString()}\nengine_bin=${engineBin}\n`,
|
|
198
|
+
'utf8'
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Record into ledger
|
|
203
|
+
recordInstallation(a.id, a.skillsDir, destDir, engineVersion, filesWritten);
|
|
204
|
+
logger(` ✔ Skill successfully installed to: ${destDir}`);
|
|
205
|
+
installedAgents.push({ id: a.id, name: a.name, path: destDir });
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
logger('\n\x1b[1m\x1b[32m====================================================\x1b[0m');
|
|
210
|
+
logger('\x1b[1m\x1b[32m 🎉 SenseCraft Setup Completed Successfully! \x1b[0m');
|
|
211
|
+
logger('\x1b[1m\x1b[32m====================================================\x1b[0m');
|
|
212
|
+
logger(`\n🔧 Engine Binary: ${engineBin}`);
|
|
213
|
+
logger(`🤖 Configured Agents:`);
|
|
214
|
+
installedAgents.forEach(a => logger(` - ${a.name}: ${a.path}`));
|
|
215
|
+
logger('\n👉 You can now open Work Buddy or your chosen Agent and ask:');
|
|
216
|
+
logger(' "帮我把边缘安全方案 (edge_security) 部署到 Jetson (192.168.1.100)"');
|
|
217
|
+
logger(' "查看当前有哪些可部署的物联网方案"');
|
|
218
|
+
logger('----------------------------------------------------\n');
|
|
219
|
+
|
|
220
|
+
return {
|
|
221
|
+
success: true,
|
|
222
|
+
engineBin,
|
|
223
|
+
engineVersion,
|
|
224
|
+
installedAgents
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
export function runStatus(options = {}) {
|
|
229
|
+
const { json = false, logger = console.log } = options;
|
|
230
|
+
const binPath = getConfiguredEnginePath();
|
|
231
|
+
const engineCheck = verifyEngineBinary(binPath);
|
|
232
|
+
const ledger = loadLedger();
|
|
233
|
+
const scannedAgents = scanLocalAgents();
|
|
234
|
+
|
|
235
|
+
const statusObj = {
|
|
236
|
+
engine: {
|
|
237
|
+
path: binPath,
|
|
238
|
+
exists: fs.existsSync(binPath),
|
|
239
|
+
version: engineCheck.version || null,
|
|
240
|
+
valid: engineCheck.valid,
|
|
241
|
+
deployersCount: engineCheck.deployers?.length || 0
|
|
242
|
+
},
|
|
243
|
+
content: {
|
|
244
|
+
dir: getContentDir(),
|
|
245
|
+
manifestExists: fs.existsSync(path.join(getContentDir(), 'manifest.json'))
|
|
246
|
+
},
|
|
247
|
+
ledger: ledger.installations || {},
|
|
248
|
+
agents: scannedAgents.map(a => ({
|
|
249
|
+
id: a.id,
|
|
250
|
+
name: a.name,
|
|
251
|
+
detected: a.detected,
|
|
252
|
+
installed: a.installed,
|
|
253
|
+
skillsDir: a.skillsDir
|
|
254
|
+
}))
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
if (json) {
|
|
258
|
+
logger(JSON.stringify(statusObj, null, 2));
|
|
259
|
+
return statusObj;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
logger('\n📊 SenseCraft System Status:\n');
|
|
263
|
+
logger(` Engine Binary: ${binPath}`);
|
|
264
|
+
logger(` Engine Version: ${engineCheck.version || 'Not installed'} (${engineCheck.valid ? 'Valid' : 'Invalid'})`);
|
|
265
|
+
logger(` Deployers Available: ${engineCheck.deployers?.length || 0}`);
|
|
266
|
+
logger(` Content Directory: ${getContentDir()} (${statusObj.content.manifestExists ? 'Ready' : 'Not cached'})`);
|
|
267
|
+
logger('\n Configured Agent Skills:');
|
|
268
|
+
|
|
269
|
+
scannedAgents.forEach(a => {
|
|
270
|
+
const isTracked = Boolean(ledger.installations && ledger.installations[a.id]);
|
|
271
|
+
const tag = a.installed ? '\x1b[32m[Installed]\x1b[0m' : '\x1b[90m[Not Installed]\x1b[0m';
|
|
272
|
+
const ledgerTag = isTracked ? ' (Managed by CLI)' : '';
|
|
273
|
+
logger(` - ${a.name}: ${tag}${ledgerTag}`);
|
|
274
|
+
});
|
|
275
|
+
logger('');
|
|
276
|
+
|
|
277
|
+
return statusObj;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function runUninstall(options = {}) {
|
|
281
|
+
const { agent = null, force = false, dryRun = false, logger = console.log } = options;
|
|
282
|
+
const ledger = loadLedger();
|
|
283
|
+
const installedIds = Object.keys(ledger.installations || {});
|
|
284
|
+
|
|
285
|
+
if (installedIds.length === 0) {
|
|
286
|
+
logger('No managed SenseCraft skills are currently recorded in the ledger.');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
let targets = installedIds;
|
|
291
|
+
if (agent && agent.toLowerCase() !== 'all') {
|
|
292
|
+
targets = agent.split(',').map(s => s.trim().toLowerCase());
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
for (const id of targets) {
|
|
296
|
+
if (!ledger.installations[id]) {
|
|
297
|
+
logger(`⚠️ Target "${id}" is not tracked in ledger. Skipping.`);
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
logger(`Uninstalling skill for agent: ${id}${dryRun ? ' [dry-run]' : ''}...`);
|
|
301
|
+
const res = removeInstallation(id, { force, dryRun });
|
|
302
|
+
if (res.success) {
|
|
303
|
+
if (dryRun) {
|
|
304
|
+
logger(` [dry-run] Would remove ${res.actions?.length || 0} items from ${res.targetDir}`);
|
|
305
|
+
} else {
|
|
306
|
+
logger(` ✔ Removed ${res.removedCount} files from ${res.targetDir}. (Preserved ${res.preservedCount} modified files).`);
|
|
307
|
+
}
|
|
308
|
+
} else {
|
|
309
|
+
logger(` ⚠️ ${res.reason}`);
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Copy with modification safety (Codex High 1: preserve original hash baseline!)
|
|
315
|
+
function copyFolderSafely(srcDir, destDir, baseDestDir, priorFiles, force, logger) {
|
|
316
|
+
const filesWritten = {};
|
|
317
|
+
const updatedFiles = [];
|
|
318
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
319
|
+
|
|
320
|
+
for (const entry of entries) {
|
|
321
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
322
|
+
const destPath = path.join(destDir, entry.name);
|
|
323
|
+
const relPath = path.relative(baseDestDir, destPath);
|
|
324
|
+
|
|
325
|
+
// Symlink escape protection (Codex High 2)
|
|
326
|
+
if (!isSafePathInside(destPath, baseDestDir)) {
|
|
327
|
+
logger(` ⚠️ Refused copying to potential symlink escape target: ${destPath}`);
|
|
328
|
+
continue;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (entry.isDirectory()) {
|
|
332
|
+
fs.mkdirSync(destPath, { recursive: true });
|
|
333
|
+
const sub = copyFolderSafely(srcPath, destPath, baseDestDir, priorFiles, force, logger);
|
|
334
|
+
Object.assign(filesWritten, sub.filesWritten);
|
|
335
|
+
updatedFiles.push(...sub.updatedFiles);
|
|
336
|
+
} else {
|
|
337
|
+
let shouldCopy = true;
|
|
338
|
+
|
|
339
|
+
if (fs.existsSync(destPath)) {
|
|
340
|
+
const destHash = computeFileHash(destPath);
|
|
341
|
+
const recordedBaselineHash = priorFiles[relPath];
|
|
342
|
+
|
|
343
|
+
if (recordedBaselineHash) {
|
|
344
|
+
// File was previously installed. Check if user modified it.
|
|
345
|
+
if (destHash !== recordedBaselineHash && !force) {
|
|
346
|
+
logger(` ⚠️ Preserved user-modified file: ${relPath} (use --force to overwrite)`);
|
|
347
|
+
shouldCopy = false;
|
|
348
|
+
// CRUCIAL (Codex High 1): Keep the ORIGINAL baseline hash in the ledger!
|
|
349
|
+
// Do NOT record destHash, otherwise future uninstall will consider it unmodified!
|
|
350
|
+
filesWritten[relPath] = recordedBaselineHash;
|
|
351
|
+
}
|
|
352
|
+
} else {
|
|
353
|
+
// File exists on disk but was never in ledger (unmanaged foreign file)
|
|
354
|
+
if (!force) {
|
|
355
|
+
logger(` ⚠️ Refused overwriting unmanaged pre-existing file: ${relPath} (use --force)`);
|
|
356
|
+
shouldCopy = false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (shouldCopy) {
|
|
362
|
+
// If destPath is a symlink, unlink it first to avoid writing through it (Codex P2)
|
|
363
|
+
const destStat = fs.lstatSync(destPath, { throwIfNoEntry: false });
|
|
364
|
+
if (destStat && destStat.isSymbolicLink()) {
|
|
365
|
+
try { fs.unlinkSync(destPath); } catch {}
|
|
366
|
+
}
|
|
367
|
+
fs.copyFileSync(srcPath, destPath);
|
|
368
|
+
filesWritten[relPath] = computeFileHash(destPath);
|
|
369
|
+
updatedFiles.push(relPath);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
return { filesWritten, updatedFiles };
|
|
375
|
+
}
|