@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.
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
+ }
@@ -0,0 +1,113 @@
1
+ import os from 'node:os';
2
+ import path from 'node:path';
3
+ import fs from 'node:fs';
4
+
5
+ export function getPlatformInfo() {
6
+ const p = os.platform();
7
+ const a = os.arch();
8
+
9
+ let target = null;
10
+ let supported = true;
11
+ let note = '';
12
+
13
+ if (p === 'darwin') {
14
+ if (a === 'arm64') {
15
+ target = 'aarch64-apple-darwin';
16
+ } else {
17
+ supported = false;
18
+ note = 'macOS x86_64 (Intel) is not supported for standalone binary releases. Under Rosetta, run from a native ARM shell.';
19
+ }
20
+ } else if (p === 'linux') {
21
+ if (a === 'x64') {
22
+ target = 'x86_64-unknown-linux-musl';
23
+ } else if (a === 'arm64') {
24
+ target = 'aarch64-unknown-linux-musl';
25
+ } else {
26
+ supported = false;
27
+ note = `Linux architecture ${a} is not supported. Supported: x64, arm64.`;
28
+ }
29
+ } else if (p === 'win32') {
30
+ if (a === 'x64') {
31
+ target = 'x86_64-pc-windows-msvc';
32
+ } else {
33
+ supported = false;
34
+ note = `Windows architecture ${a} is not supported. Supported: x64.`;
35
+ }
36
+ } else {
37
+ supported = false;
38
+ note = `Operating system ${p} is not supported.`;
39
+ }
40
+
41
+ const isWindows = p === 'win32';
42
+ const binaryName = isWindows ? 'sensecraft-engine.exe' : 'sensecraft-engine';
43
+
44
+ return {
45
+ platform: p,
46
+ arch: a,
47
+ target,
48
+ supported,
49
+ note,
50
+ isWindows,
51
+ binaryName
52
+ };
53
+ }
54
+
55
+ export function getScHome() {
56
+ return process.env.SC_HOME || path.join(os.homedir(), '.sensecraft');
57
+ }
58
+
59
+ export function getEngineBinDir() {
60
+ return path.join(getScHome(), 'bin');
61
+ }
62
+
63
+ export function getEngineBinaryPath() {
64
+ const { binaryName } = getPlatformInfo();
65
+ return path.join(getEngineBinDir(), binaryName);
66
+ }
67
+
68
+ export function getContentDir() {
69
+ return path.join(getScHome(), 'content');
70
+ }
71
+
72
+ export function getConfiguredEnginePath() {
73
+ const pointerFile = path.join(getScHome(), 'engine.json');
74
+ if (fs.existsSync(pointerFile)) {
75
+ try {
76
+ const data = JSON.parse(fs.readFileSync(pointerFile, 'utf8'));
77
+ if (data && data.bin && fs.existsSync(data.bin) && isExecutable(data.bin)) {
78
+ return path.resolve(data.bin);
79
+ }
80
+ } catch {}
81
+ }
82
+ return path.resolve(getEngineBinaryPath());
83
+ }
84
+
85
+ export function setConfiguredEnginePath(binPath, version = null, source = null) {
86
+ const pointerFile = path.join(getScHome(), 'engine.json');
87
+ const dir = path.dirname(pointerFile);
88
+ fs.mkdirSync(dir, { recursive: true });
89
+ const payload = {
90
+ schema_version: 1,
91
+ bin: path.resolve(binPath),
92
+ version: version || 'unknown',
93
+ source: source || 'unknown',
94
+ updated_at: new Date().toISOString()
95
+ };
96
+ const tempFile = path.join(dir, `.engine.tmp-${Date.now()}`);
97
+ fs.writeFileSync(tempFile, JSON.stringify(payload, null, 2), 'utf8');
98
+ fs.renameSync(tempFile, pointerFile);
99
+ }
100
+
101
+ export function isExecutable(filePath) {
102
+ try {
103
+ const stat = fs.statSync(filePath);
104
+ if (!stat.isFile()) return false;
105
+ if (os.platform() === 'win32') {
106
+ return filePath.toLowerCase().endsWith('.exe') || filePath.toLowerCase().endsWith('.cmd');
107
+ }
108
+ // Unix: check execute permissions (owner/group/others)
109
+ return Boolean(stat.mode & 0o111);
110
+ } catch {
111
+ return false;
112
+ }
113
+ }
package/package.json CHANGED
@@ -1,6 +1,53 @@
1
1
  {
2
2
  "name": "@seeed-studio/solution-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Zero-friction installer and CLI runner for SenseCraft Engine and AI Agent Skills (Work Buddy, Claude Code, Codex, CodeBuddy)",
5
+ "type": "module",
6
+ "main": "./lib/installer.js",
7
+ "bin": {
8
+ "solution-cli": "bin/cli.js",
9
+ "sensecraft-solution-cli": "bin/cli.js",
10
+ "sensecraft": "bin/cli.js",
11
+ "sensecraft-engine": "bin/runner.js",
12
+ "sensecraft-skill": "bin/cli.js"
13
+ },
14
+ "publishConfig": {
15
+ "access": "public"
16
+ },
17
+ "files": [
18
+ "bin",
19
+ "lib",
20
+ "skills",
21
+ "README.md"
22
+ ],
23
+ "scripts": {
24
+ "test": "node --test tests/cli.test.mjs",
25
+ "install:self": "node bin/cli.js install",
26
+ "status": "node bin/cli.js status",
27
+ "doctor": "node bin/cli.js doctor"
28
+ },
29
+ "keywords": [
30
+ "seeed-studio",
31
+ "sensecraft",
32
+ "sensecraft-solution",
33
+ "iot",
34
+ "workbuddy",
35
+ "claude-code",
36
+ "codex",
37
+ "codebuddy",
38
+ "skills",
39
+ "edge-ai",
40
+ "jetson",
41
+ "raspberry-pi"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/Seeed-Studio/app_collaboration.git",
46
+ "directory": "packages/solution-cli"
47
+ },
48
+ "author": "Seeed Studio",
49
+ "license": "Apache-2.0",
50
+ "engines": {
51
+ "node": ">=18.0.0"
52
+ }
53
+ }
@@ -0,0 +1,176 @@
1
+ ---
2
+ name: sensecraft
3
+ description: Deploy, manage, and troubleshoot SenseCraft / Seeed Studio edge IoT solutions (智慧农业, 摔倒检测, 边缘安全, 智慧仓库, 语音助手, people counting, face recognition 等) on hardware devices (NVIDIA Jetson, Raspberry Pi 树莓派, reComputer, reCamera, SenseCAP Watcher, Grove Vision AI, ESP32) using the `sensecraft-engine` command-line engine (no desktop app needed). Use whenever the user asks to deploy (部署), install (安装方案), test, inspect, or troubleshoot a SenseCraft edge IoT solution or configure devices and sensors. Changing a solution's configuration needs the desktop app (references/app-only.md).
4
+ ---
5
+
6
+ # SenseCraft Solution
7
+
8
+ This skill drives the `sensecraft-engine` command-line engine. It works in any
9
+ agent that can read files and run shell commands. Every step is a
10
+ `sensecraft-engine` command; the engine starts its own background instance when
11
+ a command needs one. Do not call the instance's HTTP port with `curl`: use the
12
+ CLI, and `sensecraft-engine api <METHOD> <path>` for anything without its own
13
+ command.
14
+
15
+ Capabilities that need the **SenseCraft Solution desktop app** (editing a
16
+ solution's configuration, the app's `/api/ai/context`, diagnosing through the
17
+ app's logs) are in `references/app-only.md`. Use them only when the user has
18
+ the app running.
19
+
20
+ ## Host platform boundary
21
+
22
+ The standalone engine supports four host platforms: macOS ARM
23
+ (`aarch64-apple-darwin`), Linux ARM (`aarch64-unknown-linux-musl`), Linux
24
+ x86_64 (`x86_64-unknown-linux-musl`), and Windows x86_64
25
+ (`x86_64-pc-windows-msvc`). macOS x86_64 is not maintained; under Rosetta,
26
+ run the installer from a native ARM shell.
27
+
28
+ `docker_deploy` target `local` requires a supported Docker host. Windows native
29
+ `docker_local` is intentionally unsupported by both the Python and Rust
30
+ deployers; a Windows CLI can still use a `remote` target over SSH to a Linux
31
+ Docker host. WSL uses the Linux engine binary and Linux host behavior; do not
32
+ describe a WSL run as Windows-native local Docker support. USB device steps
33
+ remain hardware-dependent and require the user to connect and confirm the
34
+ device.
35
+
36
+ On macOS, local Docker uses the literal `docker` command found by the engine's
37
+ process environment. If Docker Desktop's CLI is only inside its application
38
+ bundle, start a new engine process with a temporary `PATH` containing that
39
+ bundle's `bin` directory; do not change global shell configuration or assume an
40
+ existing engine daemon inherits the new path. Verify the new engine instance
41
+ with `doctor --json` before selecting a local target. A running Docker Desktop
42
+ daemon is a host prerequisite, not evidence that a deployment has passed.
43
+
44
+ ## Rules
45
+
46
+ 1. **Credentials and secrets:** never invent a host, username or password —
47
+ ask the user. Replace passwords, tokens, API keys and any other secret a
48
+ deployment prints (for example a gateway token) with `<REDACTED>` in
49
+ anything you show or log. Tell the user where to find the value (on the
50
+ device, in the solution's page, in the file that holds it) instead of
51
+ printing it.
52
+ 2. **What you may do without asking, and what needs the user's OK:**
53
+
54
+ | Action | Ask first? |
55
+ |---|---|
56
+ | Read deployment status and logs (`status`, `logs`), engine log files, `doctor`, `device list/ports/scan` | No |
57
+ | Read-only checks on a device over SSH (`docker ps`, `docker logs`, `df -h`, `ss -ltnp`, `ping`) | No |
58
+ | Edit the solution's edit copy (desktop app only, see `references/app-only.md`) | No — show the diff afterwards; the user sees it in the app as "locally modified" and can restore the official version in one click |
59
+ | Anything a person does with their hands: plug in or swap a cable, connect a servo, sit in front of a camera, walk across a counting line, move or stand near a robot arm | **Yes** — tell the user what to do and wait for them to confirm it is done |
60
+ | Commands that move hardware (`robot torque`, `robot actions test`) | **Yes** — the user confirms nobody is within reach first |
61
+ | Change anything on a device: stop/remove containers, delete files, install packages, change system settings; `--replace-existing` | **Yes** |
62
+ | Redeploy | First retry: no. Every later retry: **yes** |
63
+ | Cancel a running deployment | **Yes** — it can leave a device half-configured |
64
+ | Prepare an issue report (the user submits it) or open a pull request | **Yes** |
65
+
66
+ 3. **At most 3 fix-and-retry rounds** per problem for deterministic failures
67
+ (wrong parameter, missing file, port taken, bad credentials, a container
68
+ that exits). After that, stop and give the user a report (what failed, what
69
+ you checked, what you changed). Transient registry or network errors follow
70
+ the backoff rule in `references/deploy.md` §6 and do not use up a round.
71
+ 4. **Preserving official cache & Local auto-patching**: Never edit files directly under the official content cache directory (`~/.sensecraft/content/`). When fixing a solution-level bug (timeout too short, wrong port/env var, minor syntax/transfer glitch, broken health check):
72
+ - In CLI / Engine mode: copy the solution from `~/.sensecraft/content/solutions/<id>` into `./.sensecraft-work/solutions/<id>`, apply the minimal fix in that copy. **Crucial engine switch step**: Because an existing engine daemon instance retains its initial solution directory in memory, you MUST stop the running instance first: `sensecraft-engine daemon stop`. Then re-run deployment with `--solutions-dir ./.sensecraft-work/solutions` so the fresh engine instance picks up the patched files. Show the user the patch diff.
73
+ - If running with the Desktop App: use the app's edit copy mechanism (`references/app-only.md` §4).
74
+ 5. **Reading output:** read the CLI's JSON output directly; it is small. If a
75
+ filter is needed, use `jq` only when it is already installed. Do not start
76
+ python, node or any other interpreter to parse output. Where a command has
77
+ a flag that prints just what you need (`--json`, `--preset`, `--device`,
78
+ `--since`), use it.
79
+ 6. **Temp files:** keep request bodies and scratch files in the current
80
+ working directory under `./.sensecraft-work/`, never in `/tmp`. Remove the
81
+ directory when the task ends, also after a failure.
82
+
83
+ ## Step 0 — Install the engine and the solutions (every task)
84
+
85
+ 1. Check whether it is installed: `sensecraft-engine --version`. If the command
86
+ is not found, also try `~/.sensecraft/bin/sensecraft-engine --version`
87
+ (Windows: `%USERPROFILE%\.sensecraft\bin\sensecraft-engine.cmd`); the
88
+ installer puts it there and prints the `PATH` line to add.
89
+ 2. Before running an installer, check its local prerequisites. On Unix run
90
+ `command -v curl || command -v wget`, `command -v sha256sum || command -v shasum`,
91
+ `command -v tar`, `command -v jq`, and `command -v minisign`. If wget is the
92
+ selected downloader, also require `wget --help | grep -- --compression` to
93
+ succeed; otherwise stop because the CDN may return gzip content. On Windows
94
+ PowerShell run `Get-Command irm`, `Get-Command Invoke-WebRequest`,
95
+ `Get-Command Expand-Archive`, `Get-Command ConvertFrom-Json`, and
96
+ `Get-Command minisign`. Windows uses PowerShell/.NET ZIP and JSON support;
97
+ it does not require `tar` or `jq`. If any check fails, report the missing
98
+ command and stop; do not install packages or start another interpreter.
99
+ `minisign` is required to verify the signed release manifest; Unix `jq` is
100
+ required by the shell installer to read it.
101
+ 3. Not installed → install it together with the solutions (ask the user first,
102
+ it downloads a program):
103
+
104
+ **Recommended (Zero-friction via solution-cli, auto-detects existing app & agents)**:
105
+ ```bash
106
+ npx @seeed-studio/solution-cli install -y
107
+ ```
108
+
109
+ **Fallback (Standalone shell script when Node.js is not present)**:
110
+ ```bash
111
+ curl --compressed -fsSL https://sensecraft-statics.seeed.cc/solution-app/engine/install.sh | sh -s -- --with-content
112
+ ```
113
+
114
+ If `curl` is unavailable but `wget` passed the prerequisite check, use the
115
+ equivalent command:
116
+
117
+ ```
118
+ wget --compression=auto -qO- https://sensecraft-statics.seeed.cc/solution-app/engine/install.sh | sh -s -- --with-content
119
+ ```
120
+
121
+ Windows PowerShell:
122
+
123
+ ```
124
+ & ([scriptblock]::Create((irm https://sensecraft-statics.seeed.cc/solution-app/engine/install.ps1))) -WithContent
125
+ ```
126
+
127
+ 4. Solutions are downloaded separately from the engine:
128
+
129
+ | Command | Does |
130
+ |---|---|
131
+ | `sensecraft-engine content status --offline --json` | Reads the local content store only; an empty store is a normal result with `installed: 0` and exit code 0 |
132
+ | `sensecraft-engine content status --json` | Compares local solutions with the online list |
133
+ | `sensecraft-engine content pull` | Downloads every solution (first run), or only `--solution <id>` |
134
+ | `sensecraft-engine content update` | Re-downloads installed solutions that changed online |
135
+
136
+ Run the offline status check before deciding whether content is installed.
137
+ If it returns exit code 0 with `installed: 0`, run `content pull`; do not
138
+ treat that status result as an error. A solution query such as `solution
139
+ list` or `deploy-info` exits with code 3 when no usable resource/solution
140
+ directory is available and tells you to run `content pull`. If a usable
141
+ directory exists but the requested solution ID is unknown, the query exits
142
+ with code 2. After `content pull` / `update`, if an engine instance is
143
+ already running it keeps the solutions it loaded at start: run
144
+ `sensecraft-engine daemon stop` when no deployment is running.
145
+ 5. Device pre-checks on this computer: `sensecraft-engine doctor --json`. Each
146
+ check has `status` (`ok` / `warn` / `fail`) and a `fix` command. Show the
147
+ user any `fail` and its `fix`; running a `fix` changes this computer and
148
+ needs their OK.
149
+
150
+ Use `--lang zh` on commands that take `--lang` when the user writes in Chinese,
151
+ `--lang en` otherwise.
152
+
153
+ ## Exit codes (all commands)
154
+
155
+ | Code | Meaning | Next step |
156
+ |---|---|---|
157
+ | 0 | Success / accepted | Continue |
158
+ | 1 | Deployment failed, cancelled or interrupted; for `counting` / `face` / `wizard` / `robot`: the device did not confirm | `references/diagnose.md` |
159
+ | 2 | Invalid arguments or request. A `missing_params` error includes `missing[]`; an unknown ID or malformed JSON is also code 2 and may have no `missing[]` | Ask for each field only when `missing[]` is present; otherwise inspect the ID, JSON shape and parameter spelling, then correct or ask for the specific value |
160
+ | 3 | Environment or conflict: a solution query has no usable resource/solution directory (`content pull`), device busy with another deployment (409), engine instance shutting down or incompatible, serial port busy | Read the message; pull content or fix the environment, then run again. An empty `content status --offline` store is exit 0, not this case |
161
+ | 4 | This step needs the desktop app (deployer not in the engine) | Tell the user to deploy this solution from the SenseCraft Solution app |
162
+ | 5 | Internal engine error | Engine bug: `references/diagnose.md` §6 |
163
+
164
+ ## Pick the task
165
+
166
+ | The user wants to... | Read |
167
+ |---|---|
168
+ | Deploy a solution | `references/deploy.md` |
169
+ | Finish a step that reported `verify_required` (dashboards, counting lines, face enrollment, servo setup, robot arm) | `references/verify.md` |
170
+ | Find out why a deployment failed or a deployed solution misbehaves | `references/diagnose.md` |
171
+ | Deploy on a site without internet (prepare an offline package first) | `references/offline.md` |
172
+ | Change a solution's configuration, or use the desktop app's API | `references/app-only.md` (requires the desktop app) |
173
+
174
+ They connect: **a failed deployment goes straight to `diagnose.md`** without
175
+ stopping to ask; a `verify_required` event goes to `verify.md`; the retry goes
176
+ back to `deploy.md` for the failed device only.