@seeed-studio/solution-cli 0.0.0-stage → 0.1.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,321 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { getScHome, getEngineBinaryPath, isExecutable } from './platform.js';
6
+
7
+ const MIN_COMPATIBLE_VERSION = '0.0.3';
8
+
9
+ // Strict SemVer 2.0 parser (Codex Medium 4: Reject garbage, disallow leading zeros)
10
+ const SEMVER_REGEX = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?$/;
11
+
12
+ export function parseSemver(vStr) {
13
+ if (!vStr) return null;
14
+ const clean = vStr.trim().replace(/^v/, '');
15
+ const match = clean.match(SEMVER_REGEX);
16
+ if (!match) return null;
17
+
18
+ return {
19
+ major: parseInt(match[1], 10),
20
+ minor: parseInt(match[2], 10),
21
+ patch: parseInt(match[3], 10),
22
+ prerelease: match[4] || null,
23
+ raw: match[0]
24
+ };
25
+ }
26
+
27
+ export function compareSemver(v1Str, v2Str) {
28
+ const v1 = parseSemver(v1Str);
29
+ const v2 = parseSemver(v2Str);
30
+ if (!v1 || !v2) return 0;
31
+
32
+ if (v1.major !== v2.major) return v1.major > v2.major ? 1 : -1;
33
+ if (v1.minor !== v2.minor) return v1.minor > v2.minor ? 1 : -1;
34
+ if (v1.patch !== v2.patch) return v1.patch > v2.patch ? 1 : -1;
35
+
36
+ // SemVer 2.0: 1.0.0-alpha < 1.0.0 (normal version has higher precedence than prerelease)
37
+ if (v1.prerelease && !v2.prerelease) return -1;
38
+ if (!v1.prerelease && v2.prerelease) return 1;
39
+ if (!v1.prerelease && !v2.prerelease) return 0;
40
+
41
+ // Compare prerelease identifiers per SemVer 2.0 §11
42
+ const id1 = v1.prerelease.split('.');
43
+ const id2 = v2.prerelease.split('.');
44
+ const len = Math.max(id1.length, id2.length);
45
+
46
+ for (let i = 0; i < len; i++) {
47
+ if (id1[i] === undefined) return -1;
48
+ if (id2[i] === undefined) return 1;
49
+ if (id1[i] === id2[i]) continue;
50
+
51
+ const num1 = /^\d+$/.test(id1[i]) ? parseInt(id1[i], 10) : null;
52
+ const num2 = /^\d+$/.test(id2[i]) ? parseInt(id2[i], 10) : null;
53
+
54
+ if (num1 !== null && num2 !== null) {
55
+ return num1 > num2 ? 1 : -1;
56
+ }
57
+ if (num1 !== null && num2 === null) return -1; // numeric has lower precedence than non-numeric
58
+ if (num1 === null && num2 !== null) return 1;
59
+
60
+ return id1[i].localeCompare(id2[i]);
61
+ }
62
+
63
+ return 0;
64
+ }
65
+
66
+ export function verifyEngineBinary(binPath) {
67
+ const absPath = path.resolve(binPath);
68
+ if (!fs.existsSync(absPath) || !isExecutable(absPath)) {
69
+ return { valid: false, reason: 'Binary not found or not executable' };
70
+ }
71
+
72
+ try {
73
+ const rawOut = execFileSync(absPath, ['--version'], {
74
+ encoding: 'utf8',
75
+ timeout: 3000,
76
+ stdio: ['ignore', 'pipe', 'pipe']
77
+ }).trim();
78
+
79
+ // Extract exact version token (e.g. from "sensecraft-engine 0.0.3")
80
+ const tokens = rawOut.split(/\s+/);
81
+ let versionCandidate = null;
82
+ for (const t of tokens) {
83
+ if (parseSemver(t)) {
84
+ versionCandidate = t;
85
+ break;
86
+ }
87
+ }
88
+
89
+ if (!versionCandidate) {
90
+ return { valid: false, reason: `Unrecognized engine version format in "${rawOut}"` };
91
+ }
92
+
93
+ const sem = parseSemver(versionCandidate);
94
+ if (!sem) {
95
+ return { valid: false, reason: `Invalid SemVer in engine version "${versionCandidate}"` };
96
+ }
97
+
98
+ if (compareSemver(sem.raw, MIN_COMPATIBLE_VERSION) < 0) {
99
+ return {
100
+ valid: false,
101
+ version: sem.raw,
102
+ reason: `Version ${sem.raw} is below required minimum ${MIN_COMPATIBLE_VERSION}`
103
+ };
104
+ }
105
+
106
+ // Verify deployer capability via meta --json (Codex Medium 4: strict structure and string elements)
107
+ let deployers = [];
108
+ try {
109
+ const metaOut = execFileSync(absPath, ['meta', '--json'], {
110
+ encoding: 'utf8',
111
+ timeout: 4000,
112
+ stdio: ['ignore', 'pipe', 'pipe']
113
+ });
114
+ const meta = JSON.parse(metaOut);
115
+ if (
116
+ !meta ||
117
+ !Array.isArray(meta.deployers) ||
118
+ meta.deployers.length === 0 ||
119
+ !meta.deployers.every(d => typeof d === 'string' && d.length > 0 && /^[a-z0-9_]+$/.test(d))
120
+ ) {
121
+ return {
122
+ valid: false,
123
+ version: sem.raw,
124
+ reason: 'Engine meta --json returned invalid or empty deployers specification'
125
+ };
126
+ }
127
+ deployers = meta.deployers;
128
+ } catch (err) {
129
+ return {
130
+ valid: false,
131
+ version: sem.raw,
132
+ reason: `Engine capability validation failed (meta --json): ${err.message}`
133
+ };
134
+ }
135
+
136
+ return {
137
+ valid: true,
138
+ version: sem.raw,
139
+ deployers,
140
+ binPath: absPath
141
+ };
142
+ } catch (err) {
143
+ return { valid: false, reason: `Execution failed: ${err.message}` };
144
+ }
145
+ }
146
+
147
+ export function detectExistingEngine() {
148
+ const defaultBin = getEngineBinaryPath();
149
+ const candidates = [];
150
+
151
+ if (fs.existsSync(defaultBin)) {
152
+ const check = verifyEngineBinary(defaultBin);
153
+ if (check.valid) {
154
+ candidates.push({
155
+ source: 'default',
156
+ path: defaultBin,
157
+ version: check.version,
158
+ deployers: check.deployers
159
+ });
160
+ }
161
+ }
162
+
163
+ // Scan PATH candidates
164
+ const envPath = process.env.PATH || '';
165
+ const delimiter = os.platform() === 'win32' ? ';' : ':';
166
+ const binName = os.platform() === 'win32' ? 'sensecraft-engine.exe' : 'sensecraft-engine';
167
+
168
+ for (const dir of envPath.split(delimiter)) {
169
+ if (!dir.trim()) continue;
170
+ const fullPath = path.join(dir.trim(), binName);
171
+ if (fs.existsSync(fullPath) && isExecutable(fullPath)) {
172
+ // Exclude npm package's runner shim to prevent recursive loop (Codex P1)
173
+ try {
174
+ const real = fs.realpathSync(fullPath);
175
+ if (real.endsWith('bin/runner.js') || real.endsWith('bin\\runner.js')) {
176
+ continue;
177
+ }
178
+ const head = fs.readFileSync(fullPath, 'utf8', { flag: 'r' }).slice(0, 500);
179
+ if (head.includes('runEngineProxy') || (head.includes('#!/usr/bin/env node') && head.includes('runner.js'))) {
180
+ continue;
181
+ }
182
+ } catch {}
183
+
184
+ const check = verifyEngineBinary(fullPath);
185
+ if (check.valid) {
186
+ candidates.push({
187
+ source: 'path',
188
+ path: fullPath,
189
+ version: check.version,
190
+ deployers: check.deployers
191
+ });
192
+ }
193
+ }
194
+ }
195
+
196
+ if (candidates.length === 0) return null;
197
+ candidates.sort((a, b) => compareSemver(b.version, a.version));
198
+ return candidates[0];
199
+ }
200
+
201
+ export function detectDesktopApp() {
202
+ const p = os.platform();
203
+ const candidates = [];
204
+
205
+ // Check 1: Tauri app pointer at ~/.sensecraft/engine.json
206
+ const pointerFile = path.join(getScHome(), 'engine.json');
207
+ if (fs.existsSync(pointerFile)) {
208
+ try {
209
+ const data = JSON.parse(fs.readFileSync(pointerFile, 'utf8'));
210
+ if (data && data.bin && fs.existsSync(data.bin)) {
211
+ candidates.push({
212
+ source: 'pointer',
213
+ appVersion: data.app_version || 'unknown',
214
+ binPath: path.resolve(data.bin)
215
+ });
216
+ }
217
+ } catch {}
218
+ }
219
+
220
+ // Check 2: Standard OS installation locations
221
+ if (p === 'darwin') {
222
+ const appBase = '/Applications/SenseCraft Solution.app';
223
+ if (fs.existsSync(appBase)) {
224
+ candidates.push({
225
+ source: 'os_app',
226
+ appPath: appBase,
227
+ binPath: path.join(appBase, 'Contents', 'MacOS', 'sensecraft-engine'),
228
+ solutionsPath: path.join(appBase, 'Contents', 'Resources', '_up_', 'sensecraft-solutions', 'solutions'),
229
+ skillsPath: path.join(appBase, 'Contents', 'Resources', '_up_', 'sensecraft-solutions', 'skills', 'sensecraft')
230
+ });
231
+ const cacheBin = path.join(
232
+ os.homedir(),
233
+ 'Library',
234
+ 'Caches',
235
+ 'com.seeedstudio.sensecraft-solution',
236
+ 'sidecar',
237
+ 'sensecraft-engine'
238
+ );
239
+ if (fs.existsSync(cacheBin)) {
240
+ candidates.push({
241
+ source: 'app_cache',
242
+ appPath: appBase,
243
+ binPath: cacheBin
244
+ });
245
+ }
246
+ }
247
+ } else if (p === 'win32') {
248
+ const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), 'AppData', 'Local');
249
+ const programFiles = process.env.ProgramFiles || 'C:\\Program Files';
250
+ const appPaths = [
251
+ path.join(localAppData, 'Programs', 'SenseCraft Solution'),
252
+ path.join(programFiles, 'SenseCraft Solution')
253
+ ];
254
+ for (const appPath of appPaths) {
255
+ if (fs.existsSync(appPath)) {
256
+ candidates.push({
257
+ source: 'os_app',
258
+ appPath,
259
+ binPath: path.join(appPath, 'resources', 'bin', 'sensecraft-engine.exe'),
260
+ solutionsPath: path.join(appPath, 'resources', 'sensecraft-solutions', 'solutions'),
261
+ skillsPath: path.join(appPath, 'resources', 'sensecraft-solutions', 'skills', 'sensecraft')
262
+ });
263
+ }
264
+ }
265
+ } else if (p === 'linux') {
266
+ const linuxPaths = ['/opt/SenseCraft Solution', '/opt/sensecraft-solution', '/usr/lib/sensecraft-solution'];
267
+ for (const appPath of linuxPaths) {
268
+ if (fs.existsSync(appPath)) {
269
+ candidates.push({
270
+ source: 'os_app',
271
+ appPath,
272
+ binPath: path.join(appPath, 'sensecraft-engine'),
273
+ solutionsPath: path.join(appPath, 'sensecraft-solutions', 'solutions'),
274
+ skillsPath: path.join(appPath, 'skills', 'sensecraft')
275
+ });
276
+ }
277
+ }
278
+ }
279
+
280
+ const validCandidates = [];
281
+ const rejectedReasons = [];
282
+
283
+ for (const c of candidates) {
284
+ if (c.binPath && fs.existsSync(c.binPath)) {
285
+ const verified = verifyEngineBinary(c.binPath);
286
+ if (verified.valid) {
287
+ validCandidates.push({
288
+ ...c,
289
+ version: verified.version,
290
+ deployers: verified.deployers
291
+ });
292
+ } else {
293
+ rejectedReasons.push(`${c.binPath}: ${verified.reason}`);
294
+ }
295
+ }
296
+ }
297
+
298
+ if (validCandidates.length > 0) {
299
+ validCandidates.sort((a, b) => compareSemver(b.version, a.version));
300
+ const chosen = validCandidates[0];
301
+ return {
302
+ detected: true,
303
+ compatible: true,
304
+ binPath: chosen.binPath,
305
+ version: chosen.version,
306
+ appPath: chosen.appPath,
307
+ solutionsPath: chosen.solutionsPath && fs.existsSync(chosen.solutionsPath) ? chosen.solutionsPath : null,
308
+ skillsPath: chosen.skillsPath && fs.existsSync(chosen.skillsPath) ? chosen.skillsPath : null
309
+ };
310
+ }
311
+
312
+ if (rejectedReasons.length > 0) {
313
+ return {
314
+ detected: true,
315
+ compatible: false,
316
+ reason: rejectedReasons.join('; ')
317
+ };
318
+ }
319
+
320
+ return { detected: false };
321
+ }
package/lib/doctor.js ADDED
@@ -0,0 +1,179 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+ import { execFileSync } from 'node:child_process';
5
+ import { getPlatformInfo, getEngineBinDir, getEngineBinaryPath, getConfiguredEnginePath, getContentDir } from './platform.js';
6
+ import { verifyEngineBinary } from './detector.js';
7
+ import { loadLedger, computeFileHash } from './ledger.js';
8
+
9
+ export function runDoctor() {
10
+ const platform = getPlatformInfo();
11
+ const checks = [];
12
+
13
+ // Check 1: Platform & Architecture
14
+ if (platform.supported) {
15
+ checks.push({
16
+ id: 'platform',
17
+ status: 'ok',
18
+ title: 'Host Platform & Architecture',
19
+ detail: `${platform.platform} (${platform.arch}) -> target ${platform.target}`
20
+ });
21
+ } else {
22
+ checks.push({
23
+ id: 'platform',
24
+ status: 'fail',
25
+ title: 'Host Platform & Architecture',
26
+ detail: platform.note
27
+ });
28
+ }
29
+
30
+ // Check 2: Engine Binary
31
+ const binPath = getConfiguredEnginePath();
32
+ const engineCheck = verifyEngineBinary(binPath);
33
+ if (engineCheck.valid) {
34
+ checks.push({
35
+ id: 'engine_binary',
36
+ status: 'ok',
37
+ title: 'SenseCraft Engine Binary',
38
+ detail: `v${engineCheck.version} at ${binPath} (${engineCheck.deployers?.length || 0} deployers verified)`
39
+ });
40
+ } else {
41
+ checks.push({
42
+ id: 'engine_binary',
43
+ status: 'warn',
44
+ title: 'SenseCraft Engine Binary',
45
+ detail: `${engineCheck.reason || 'Not installed'}. Run "sensecraft install" to provision.`
46
+ });
47
+ }
48
+
49
+ // Check 3: PATH Configuration
50
+ const binDir = getEngineBinDir();
51
+ const envPath = process.env.PATH || '';
52
+ const delimiter = platform.isWindows ? ';' : ':';
53
+ const isInPath = envPath.split(delimiter).some(p => path.resolve(p) === path.resolve(binDir));
54
+
55
+ if (isInPath) {
56
+ checks.push({
57
+ id: 'path',
58
+ status: 'ok',
59
+ title: 'Shell PATH Configuration',
60
+ detail: `Directory ${binDir} is present in $PATH`
61
+ });
62
+ } else {
63
+ const isWin = platform.isWindows;
64
+ const fixCmd = isWin
65
+ ? `[Environment]::SetEnvironmentVariable("Path", $env:Path + ";${binDir}", "User")`
66
+ : `export PATH="${binDir}:$PATH" (add to ~/.bashrc or ~/.zshrc)`;
67
+ checks.push({
68
+ id: 'path',
69
+ status: 'warn',
70
+ title: 'Shell PATH Configuration',
71
+ detail: `${binDir} is not in $PATH. Note: Skills use absolute paths, but for manual CLI use run: ${fixCmd}`
72
+ });
73
+ }
74
+
75
+ // Check 4: Solution Content Cache
76
+ const contentDir = getContentDir();
77
+ const manifestFile = path.join(contentDir, 'manifest.json');
78
+ const solutionsDir = path.join(contentDir, 'solutions');
79
+
80
+ if (fs.existsSync(manifestFile) && fs.existsSync(solutionsDir)) {
81
+ try {
82
+ const solutionEntries = fs.readdirSync(solutionsDir).filter(f => !f.startsWith('.'));
83
+ checks.push({
84
+ id: 'content',
85
+ status: 'ok',
86
+ title: 'IoT Solutions Catalog',
87
+ detail: `${solutionEntries.length} solutions cached in ${solutionsDir}`
88
+ });
89
+ } catch {
90
+ checks.push({
91
+ id: 'content',
92
+ status: 'warn',
93
+ title: 'IoT Solutions Catalog',
94
+ detail: 'Catalog exists but could not read solutions directory.'
95
+ });
96
+ }
97
+ } else {
98
+ checks.push({
99
+ id: 'content',
100
+ status: 'warn',
101
+ title: 'IoT Solutions Catalog',
102
+ detail: 'No local solution catalog cached. Run "sensecraft-engine content pull".'
103
+ });
104
+ }
105
+
106
+ // Check 5: Docker daemon
107
+ try {
108
+ execFileSync('docker', ['info'], { stdio: 'ignore', timeout: 3000 });
109
+ checks.push({
110
+ id: 'docker',
111
+ status: 'ok',
112
+ title: 'Docker Daemon',
113
+ detail: 'Docker CLI and daemon accessible for container deployments'
114
+ });
115
+ } catch {
116
+ checks.push({
117
+ id: 'docker',
118
+ status: 'warn',
119
+ title: 'Docker Daemon',
120
+ detail: 'Docker not running or current user lacks access. (Only needed for local Docker deployments)'
121
+ });
122
+ }
123
+
124
+ // Check 6: Linux serial permissions
125
+ if (platform.platform === 'linux') {
126
+ try {
127
+ const groups = execFileSync('groups', { encoding: 'utf8', timeout: 2000 }).trim();
128
+ const hasDialout = groups.includes('dialout') || groups.includes('uucp');
129
+ if (hasDialout) {
130
+ checks.push({
131
+ id: 'serial_permission',
132
+ status: 'ok',
133
+ title: 'Serial Port Permissions',
134
+ detail: 'User belongs to dialout/uucp group'
135
+ });
136
+ } else {
137
+ checks.push({
138
+ id: 'serial_permission',
139
+ status: 'warn',
140
+ title: 'Serial Port Permissions',
141
+ detail: 'User not in "dialout" group. USB serial flashing may require: sudo usermod -aG dialout $USER'
142
+ });
143
+ }
144
+ } catch {}
145
+ }
146
+
147
+ // Check 7: Skill Ledger
148
+ const ledger = loadLedger();
149
+ const installEntries = Object.entries(ledger.installations || {});
150
+ if (installEntries.length > 0) {
151
+ let allIntact = true;
152
+ for (const [agentId, rec] of installEntries) {
153
+ for (const [rel, recordedHash] of Object.entries(rec.files || {})) {
154
+ const full = path.join(rec.target_dir, rel);
155
+ if (!fs.existsSync(full) || computeFileHash(full) !== recordedHash) {
156
+ allIntact = false;
157
+ break;
158
+ }
159
+ }
160
+ }
161
+ checks.push({
162
+ id: 'skills_ledger',
163
+ status: allIntact ? 'ok' : 'warn',
164
+ title: 'Agent Skills Tracking',
165
+ detail: `${installEntries.length} agent(s) tracked. Status: ${allIntact ? 'all files intact' : 'files modified or missing'}`
166
+ });
167
+ }
168
+
169
+ const overall = checks.some(c => c.status === 'fail')
170
+ ? 'fail'
171
+ : checks.some(c => c.status === 'warn')
172
+ ? 'warn'
173
+ : 'ok';
174
+
175
+ return {
176
+ overall,
177
+ checks
178
+ };
179
+ }