@manfred-kunze-dev/iot-cli 3.4.0 → 3.5.0-dev.25
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 +66 -33
- package/dist/commands/auth.js +18 -37
- package/dist/commands/flash-profile.d.ts +3 -0
- package/dist/commands/flash-profile.js +180 -0
- package/dist/commands/flash.d.ts +3 -0
- package/dist/commands/flash.js +176 -0
- package/dist/index.js +2 -0
- package/dist/lib/flash/boot-partition.d.ts +26 -0
- package/dist/lib/flash/boot-partition.js +128 -0
- package/dist/lib/flash/bootstrap.d.ts +35 -0
- package/dist/lib/flash/bootstrap.js +105 -0
- package/dist/lib/flash/platform.d.ts +23 -0
- package/dist/lib/flash/platform.js +75 -0
- package/dist/lib/flash/profile.d.ts +23 -0
- package/dist/lib/flash/profile.js +45 -0
- package/dist/lib/flash/resolve.d.ts +45 -0
- package/dist/lib/flash/resolve.js +95 -0
- package/dist/lib/prompt.d.ts +13 -0
- package/dist/lib/prompt.js +54 -0
- package/openapi/openapi.json +11375 -32
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -37,12 +37,12 @@ iot auth status
|
|
|
37
37
|
|
|
38
38
|
The CLI supports multiple authentication methods (highest priority first):
|
|
39
39
|
|
|
40
|
-
| Method
|
|
41
|
-
|
|
42
|
-
| CLI flags
|
|
43
|
-
| Environment variables | `IOT_API_KEY`, `IOT_BASE_URL`
|
|
44
|
-
| Local `.iot` file
|
|
45
|
-
| Config store
|
|
40
|
+
| Method | Example |
|
|
41
|
+
| --------------------- | ---------------------------------------------------------- |
|
|
42
|
+
| CLI flags | `--api-key sk_... --base-url https://...` |
|
|
43
|
+
| Environment variables | `IOT_API_KEY`, `IOT_BASE_URL` |
|
|
44
|
+
| Local `.iot` file | JSON file in the current directory or any ancestor |
|
|
45
|
+
| Config store | `~/.config/iot-cli/config.json` (set via `iot auth login`) |
|
|
46
46
|
|
|
47
47
|
```bash
|
|
48
48
|
iot auth login # Interactive setup
|
|
@@ -70,27 +70,60 @@ You can also switch contexts for a single invocation:
|
|
|
70
70
|
iot --context staging auth status
|
|
71
71
|
```
|
|
72
72
|
|
|
73
|
+
## Flashing devices
|
|
74
|
+
|
|
75
|
+
`iot flash` provisions an SD card that **already carries a 2kw-os base image**
|
|
76
|
+
(write that with Raspberry Pi Imager first). It mints a registration token,
|
|
77
|
+
writes a validated `bootstrap.toml` onto the boot partition, and waits for the
|
|
78
|
+
device to enrol.
|
|
79
|
+
|
|
80
|
+
```bash
|
|
81
|
+
# One-off, fully explicit
|
|
82
|
+
iot flash --name "Werk2 Gateway" --target D:\ \
|
|
83
|
+
--ssh-key ~/.ssh/rpi_test.pub \
|
|
84
|
+
--wifi-ssid Plant-Primary --wifi-psk 'secret'
|
|
85
|
+
|
|
86
|
+
# Fleet flashing: store the stable bits once, then only name each device
|
|
87
|
+
iot flash profile set bench --ssh-key ~/.ssh/rpi_test.pub --wifi-mode store
|
|
88
|
+
iot flash profile use bench
|
|
89
|
+
iot flash --name "Bench Pi 3"
|
|
90
|
+
|
|
91
|
+
# Customer site: profile holds the SSH key, WiFi is prompted and never stored
|
|
92
|
+
iot flash profile set customer --ssh-key ~/.ssh/rpi_test.pub --wifi-mode prompt-always
|
|
93
|
+
iot flash --profile customer --name "Acme Line 2"
|
|
94
|
+
|
|
95
|
+
# CI / scripting
|
|
96
|
+
iot flash --name ci-device --target /media/ci/boot --ssh-key ./key.pub \
|
|
97
|
+
--wifi-ssid CI --wifi-psk ci --non-interactive --no-wait --json
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Values resolve as **flag → env → profile → prompt → error**. With
|
|
101
|
+
`--non-interactive`, a missing value exits non-zero naming the flag instead of
|
|
102
|
+
prompting.
|
|
103
|
+
|
|
104
|
+
Not in scope: downloading or writing the OS image itself.
|
|
105
|
+
|
|
73
106
|
## Commands (foundation — v1)
|
|
74
107
|
|
|
75
|
-
| Command
|
|
76
|
-
|
|
77
|
-
| `auth`
|
|
78
|
-
| `config`
|
|
79
|
-
| `context` | Manage CLI contexts for multiple environments
|
|
80
|
-
| `docs`
|
|
108
|
+
| Command | Description |
|
|
109
|
+
| --------- | ---------------------------------------------- |
|
|
110
|
+
| `auth` | Login, logout, and check auth status |
|
|
111
|
+
| `config` | Get, set, and list global configuration values |
|
|
112
|
+
| `context` | Manage CLI contexts for multiple environments |
|
|
113
|
+
| `docs` | Browse API documentation from the terminal |
|
|
81
114
|
|
|
82
115
|
Resource commands (`devices`, `sensors`, `sites`, `readings`, `events`, …) ship in later releases.
|
|
83
116
|
|
|
84
117
|
### Global Options
|
|
85
118
|
|
|
86
|
-
| Flag
|
|
87
|
-
|
|
88
|
-
| `--api-key <key>`
|
|
89
|
-
| `--base-url <url>` | Override the base URL
|
|
90
|
-
| `--context <name>` | Use a named context for this invocation without switching the active one
|
|
91
|
-
| `--json`
|
|
92
|
-
| `--no-color`
|
|
93
|
-
| `--verbose`
|
|
119
|
+
| Flag | Description |
|
|
120
|
+
| ------------------ | --------------------------------------------------------------------------------------- |
|
|
121
|
+
| `--api-key <key>` | Override the API key |
|
|
122
|
+
| `--base-url <url>` | Override the base URL |
|
|
123
|
+
| `--context <name>` | Use a named context for this invocation without switching the active one |
|
|
124
|
+
| `--json` | Output raw JSON instead of formatted tables |
|
|
125
|
+
| `--no-color` | Disable colored output |
|
|
126
|
+
| `--verbose` | Print resolved base URL, masked key, and request method+path on stderr before each call |
|
|
94
127
|
|
|
95
128
|
## Output
|
|
96
129
|
|
|
@@ -98,17 +131,17 @@ Stdout carries the command's data payload (table, JSON, created resource ID). Ev
|
|
|
98
131
|
|
|
99
132
|
Exit codes:
|
|
100
133
|
|
|
101
|
-
| Code | Meaning
|
|
102
|
-
|
|
103
|
-
| 0
|
|
104
|
-
| 1
|
|
105
|
-
| 2
|
|
106
|
-
| 3
|
|
107
|
-
| 4
|
|
108
|
-
| 5
|
|
109
|
-
| 6
|
|
110
|
-
| 7
|
|
111
|
-
| 8
|
|
134
|
+
| Code | Meaning |
|
|
135
|
+
| ---- | ----------------------- |
|
|
136
|
+
| 0 | Success |
|
|
137
|
+
| 1 | Generic error |
|
|
138
|
+
| 2 | Not authenticated (401) |
|
|
139
|
+
| 3 | Forbidden (403) |
|
|
140
|
+
| 4 | Not found (404) |
|
|
141
|
+
| 5 | Conflict (409) |
|
|
142
|
+
| 6 | Validation error (422) |
|
|
143
|
+
| 7 | Server error (5xx) |
|
|
144
|
+
| 8 | Network error |
|
|
112
145
|
|
|
113
146
|
## Development
|
|
114
147
|
|
|
@@ -129,8 +162,8 @@ npm run typecheck
|
|
|
129
162
|
npm test
|
|
130
163
|
|
|
131
164
|
# Regenerate typed client from a running dev backend
|
|
132
|
-
SPRING_PROFILES_ACTIVE=spec ./mvnw.cmd
|
|
133
|
-
npm run generate
|
|
165
|
+
SPRING_PROFILES_ACTIVE=spec ./mvnw.cmd spring-boot:run # run from backend/
|
|
166
|
+
npm run generate # run from cli/
|
|
134
167
|
```
|
|
135
168
|
|
|
136
169
|
See [`docs/superpowers/specs/2026-05-25-iot-cli-foundation-design.md`](../docs/superpowers/specs/2026-05-25-iot-cli-foundation-design.md) for the full design.
|
package/dist/commands/auth.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Command } from "commander";
|
|
2
2
|
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { promptHidden } from "../lib/prompt.js";
|
|
3
4
|
import { DEFAULT_BASE_URL, getActiveContextName, getActiveContext, getContextCount, setContext, setActiveContext, deleteContext, isJsonOutput, resolveConfig, } from "../lib/config.js";
|
|
4
5
|
import { getClient } from "../lib/client.js";
|
|
5
|
-
import { printJson, printKeyValue, printSuccess, maskKey } from "../lib/output.js";
|
|
6
|
+
import { printJson, printKeyValue, printSuccess, maskKey, } from "../lib/output.js";
|
|
6
7
|
import { handleError } from "../lib/errors.js";
|
|
7
8
|
export function makeAuthCommand() {
|
|
8
9
|
const cmd = new Command("auth").description("Manage authentication");
|
|
@@ -17,7 +18,10 @@ export function makeAuthCommand() {
|
|
|
17
18
|
const contextName = getActiveContextName();
|
|
18
19
|
const currentCtx = getActiveContext();
|
|
19
20
|
if (!apiKey || !baseUrl) {
|
|
20
|
-
const rl = createInterface({
|
|
21
|
+
const rl = createInterface({
|
|
22
|
+
input: process.stdin,
|
|
23
|
+
output: process.stderr,
|
|
24
|
+
});
|
|
21
25
|
try {
|
|
22
26
|
if (!baseUrl) {
|
|
23
27
|
const defaultUrl = currentCtx?.baseUrl ?? DEFAULT_BASE_URL;
|
|
@@ -40,7 +44,9 @@ export function makeAuthCommand() {
|
|
|
40
44
|
// Validate by probing /v1/devices/summary so the user knows the
|
|
41
45
|
// creds work before they exit the prompt.
|
|
42
46
|
const { client } = getClient(command);
|
|
43
|
-
const { data, error } = await client.GET("/v1/devices/summary"
|
|
47
|
+
const { data, error } = await client.GET("/v1/devices/summary", {
|
|
48
|
+
params: { query: { pageable: {} } },
|
|
49
|
+
});
|
|
44
50
|
if (error) {
|
|
45
51
|
throw new Error("Saved credentials, but the validation probe failed. " +
|
|
46
52
|
"Double-check the API key and base URL with `iot auth status`.");
|
|
@@ -50,7 +56,7 @@ export function makeAuthCommand() {
|
|
|
50
56
|
success: true,
|
|
51
57
|
context: contextName,
|
|
52
58
|
baseUrl,
|
|
53
|
-
devices: data?.
|
|
59
|
+
devices: data?.totalElements ?? null,
|
|
54
60
|
});
|
|
55
61
|
}
|
|
56
62
|
else {
|
|
@@ -87,11 +93,13 @@ export function makeAuthCommand() {
|
|
|
87
93
|
const contextName = getActiveContextName();
|
|
88
94
|
try {
|
|
89
95
|
const { client } = getClient(command);
|
|
90
|
-
const { data, error } = await client.GET("/v1/devices/summary"
|
|
96
|
+
const { data, error } = await client.GET("/v1/devices/summary", {
|
|
97
|
+
params: { query: { pageable: {} } },
|
|
98
|
+
});
|
|
91
99
|
if (error) {
|
|
92
100
|
throw error;
|
|
93
101
|
}
|
|
94
|
-
const devices = data?.
|
|
102
|
+
const devices = data?.totalElements ?? 0;
|
|
95
103
|
if (json) {
|
|
96
104
|
printJson({
|
|
97
105
|
authenticated: true,
|
|
@@ -153,7 +161,10 @@ export function makeAuthCommand() {
|
|
|
153
161
|
// Last context — keep the entry, just blank the key. This matches
|
|
154
162
|
// the spec: "logout clears the active context's apiKey, keeps baseUrl
|
|
155
163
|
// so re-login is one prompt."
|
|
156
|
-
setContext(contextName, {
|
|
164
|
+
setContext(contextName, {
|
|
165
|
+
apiKey: "",
|
|
166
|
+
baseUrl: ctx?.baseUrl ?? DEFAULT_BASE_URL,
|
|
167
|
+
});
|
|
157
168
|
}
|
|
158
169
|
else {
|
|
159
170
|
deleteContext(contextName);
|
|
@@ -171,34 +182,4 @@ export function makeAuthCommand() {
|
|
|
171
182
|
});
|
|
172
183
|
return cmd;
|
|
173
184
|
}
|
|
174
|
-
/**
|
|
175
|
-
* Tiny hidden-input helper for API keys. readline.question doesn't natively
|
|
176
|
-
* support hiding input on Windows/POSIX consistently, so we mute the muxed
|
|
177
|
-
* output stream while the user types.
|
|
178
|
-
*/
|
|
179
|
-
function promptHidden(rl, prompt) {
|
|
180
|
-
return new Promise((resolveAnswer, rejectAnswer) => {
|
|
181
|
-
const mutableStdout = rl.output;
|
|
182
|
-
const original = mutableStdout.write.bind(mutableStdout);
|
|
183
|
-
let muted = false;
|
|
184
|
-
mutableStdout.write = ((chunk, enc, cb) => {
|
|
185
|
-
if (muted && typeof chunk === "string") {
|
|
186
|
-
return original("", enc, cb);
|
|
187
|
-
}
|
|
188
|
-
return original(chunk, enc, cb);
|
|
189
|
-
});
|
|
190
|
-
process.stderr.write(prompt);
|
|
191
|
-
muted = true;
|
|
192
|
-
rl.question("")
|
|
193
|
-
.then((answer) => {
|
|
194
|
-
mutableStdout.write = original;
|
|
195
|
-
process.stderr.write("\n");
|
|
196
|
-
resolveAnswer(answer);
|
|
197
|
-
})
|
|
198
|
-
.catch((err) => {
|
|
199
|
-
mutableStdout.write = original;
|
|
200
|
-
rejectAnswer(err);
|
|
201
|
-
});
|
|
202
|
-
});
|
|
203
|
-
}
|
|
204
185
|
//# sourceMappingURL=auth.js.map
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { isJsonOutput } from "../lib/config.js";
|
|
3
|
+
import { printJson, printTable, printSuccess, printKeyValue, } from "../lib/output.js";
|
|
4
|
+
import { handleError } from "../lib/errors.js";
|
|
5
|
+
import { getAllFlashProfiles, getFlashProfile, setFlashProfile, deleteFlashProfile, getActiveFlashProfileName, setActiveFlashProfile, DEFAULT_COUNTRY, DEFAULT_HOSTNAME_PATTERN, } from "../lib/flash/profile.js";
|
|
6
|
+
import { pairWifiNetworks } from "../lib/flash/resolve.js";
|
|
7
|
+
function collect(value, previous = []) {
|
|
8
|
+
return [...previous, value];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* WiFi PSKs are a secret and must never reach stdout. Every JSON-emitting
|
|
12
|
+
* path (list, show) funnels through here rather than trusting each call
|
|
13
|
+
* site to strip credentials individually.
|
|
14
|
+
*/
|
|
15
|
+
function redactProfile(p) {
|
|
16
|
+
return {
|
|
17
|
+
...p,
|
|
18
|
+
wifiNetworks: p.wifiNetworks?.map((n) => ({
|
|
19
|
+
ssid: n.ssid,
|
|
20
|
+
psk: n.psk ? "***" : undefined,
|
|
21
|
+
})),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function makeFlashProfileCommand() {
|
|
25
|
+
const cmd = new Command("profile").description("Manage reusable flash profiles");
|
|
26
|
+
cmd
|
|
27
|
+
.command("list")
|
|
28
|
+
.description("List flash profiles")
|
|
29
|
+
.action((_opts, command) => {
|
|
30
|
+
try {
|
|
31
|
+
const profiles = getAllFlashProfiles();
|
|
32
|
+
const active = getActiveFlashProfileName();
|
|
33
|
+
if (isJsonOutput(command)) {
|
|
34
|
+
const redacted = Object.fromEntries(Object.entries(profiles).map(([name, p]) => [
|
|
35
|
+
name,
|
|
36
|
+
redactProfile(p),
|
|
37
|
+
]));
|
|
38
|
+
printJson({ active, profiles: redacted });
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const rows = Object.entries(profiles).map(([name, p]) => ({
|
|
42
|
+
active: name === active ? "*" : "",
|
|
43
|
+
name,
|
|
44
|
+
country: p.country,
|
|
45
|
+
wifiMode: p.wifiMode,
|
|
46
|
+
networks: String(p.wifiNetworks?.length ?? 0),
|
|
47
|
+
}));
|
|
48
|
+
if (rows.length === 0) {
|
|
49
|
+
process.stderr.write('No flash profiles. Create one with "iot flash profile set <name>".\n');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
printTable(rows, [
|
|
53
|
+
{ key: "active", header: "" },
|
|
54
|
+
{ key: "name", header: "NAME" },
|
|
55
|
+
{ key: "country", header: "COUNTRY" },
|
|
56
|
+
{ key: "wifiMode", header: "WIFI MODE" },
|
|
57
|
+
{ key: "networks", header: "NETWORKS" },
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
handleError(err, { json: isJsonOutput(command) });
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
cmd
|
|
65
|
+
.command("show <name>")
|
|
66
|
+
.description("Show one profile (WiFi passphrases are not printed)")
|
|
67
|
+
.action((name, _opts, command) => {
|
|
68
|
+
try {
|
|
69
|
+
const p = getFlashProfile(name);
|
|
70
|
+
if (!p)
|
|
71
|
+
throw new Error(`Flash profile "${name}" does not exist.`);
|
|
72
|
+
const safe = redactProfile(p);
|
|
73
|
+
if (isJsonOutput(command)) {
|
|
74
|
+
printJson(safe);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
printKeyValue({
|
|
78
|
+
Country: p.country,
|
|
79
|
+
"Hostname Pattern": p.hostnamePattern,
|
|
80
|
+
"WiFi Mode": p.wifiMode,
|
|
81
|
+
Networks: (p.wifiNetworks ?? []).map((n) => n.ssid).join(", ") || "(none)",
|
|
82
|
+
"SSH Key": p.sshKeyPath ?? "(unset)",
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
catch (err) {
|
|
86
|
+
handleError(err, { json: isJsonOutput(command) });
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
cmd
|
|
90
|
+
.command("set <name>")
|
|
91
|
+
.description("Create or update a flash profile")
|
|
92
|
+
.option("--ssh-key <path>", "Operator SSH public key path")
|
|
93
|
+
.option("--country <cc>", "WiFi regulatory domain")
|
|
94
|
+
.option("--hostname-pattern <pattern>", "Hostname pattern")
|
|
95
|
+
.option("--wifi-mode <mode>", "store | prompt-always")
|
|
96
|
+
.option("--wifi-ssid <ssid>", "WiFi SSID to store (repeatable, priority order)", collect)
|
|
97
|
+
.option("--wifi-psk <psk>", "WiFi passphrase (repeatable, pairs with --wifi-ssid)", collect)
|
|
98
|
+
.option("--profile-id <uuid>", "DeviceProfile applied at provision")
|
|
99
|
+
.action((name, opts, command) => {
|
|
100
|
+
try {
|
|
101
|
+
const existing = getFlashProfile(name);
|
|
102
|
+
// Only replace stored networks when the user actually passed
|
|
103
|
+
// --wifi-ssid; otherwise leave whatever is already on the profile
|
|
104
|
+
// alone (same merge pattern as sshKeyPath/profileId below).
|
|
105
|
+
const wifiFromFlags = pairWifiNetworks(opts.wifiSsid ?? [], opts.wifiPsk ?? []);
|
|
106
|
+
// An omitted --wifi-mode is no longer a hard "prompt-always"
|
|
107
|
+
// default (that silently discarded --wifi-ssid/--wifi-psk with a
|
|
108
|
+
// success message — #373 Case A). Passing --wifi-ssid with no
|
|
109
|
+
// explicit mode now infers "store"; otherwise an existing
|
|
110
|
+
// profile's stored mode is preserved so an unrelated partial
|
|
111
|
+
// update (e.g. --ssh-key alone) can't clobber it back to the
|
|
112
|
+
// default and wipe stored networks (#373 Case B).
|
|
113
|
+
const effectiveMode = opts.wifiMode ??
|
|
114
|
+
(wifiFromFlags.length > 0 ? "store" : existing?.wifiMode) ??
|
|
115
|
+
"prompt-always";
|
|
116
|
+
if (effectiveMode !== "store" && effectiveMode !== "prompt-always") {
|
|
117
|
+
throw new Error('--wifi-mode must be "store" or "prompt-always".');
|
|
118
|
+
}
|
|
119
|
+
if (wifiFromFlags.length > 0 && effectiveMode === "prompt-always") {
|
|
120
|
+
throw new Error("--wifi-ssid/--wifi-psk require --wifi-mode store (prompt-always never stores WiFi).");
|
|
121
|
+
}
|
|
122
|
+
setFlashProfile(name, {
|
|
123
|
+
...existing,
|
|
124
|
+
sshKeyPath: opts.sshKey ?? existing?.sshKeyPath,
|
|
125
|
+
country: opts.country ?? existing?.country ?? DEFAULT_COUNTRY,
|
|
126
|
+
hostnamePattern: opts.hostnamePattern ??
|
|
127
|
+
existing?.hostnamePattern ??
|
|
128
|
+
DEFAULT_HOSTNAME_PATTERN,
|
|
129
|
+
wifiMode: effectiveMode,
|
|
130
|
+
wifiNetworks: wifiFromFlags.length > 0 ? wifiFromFlags : existing?.wifiNetworks,
|
|
131
|
+
profileId: opts.profileId ?? existing?.profileId,
|
|
132
|
+
});
|
|
133
|
+
if (isJsonOutput(command)) {
|
|
134
|
+
printJson({ success: true, name });
|
|
135
|
+
}
|
|
136
|
+
else {
|
|
137
|
+
printSuccess(`Saved flash profile "${name}".`, "stderr");
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
handleError(err, { json: isJsonOutput(command) });
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
cmd
|
|
145
|
+
.command("use <name>")
|
|
146
|
+
.description("Set the default flash profile")
|
|
147
|
+
.action((name, _opts, command) => {
|
|
148
|
+
try {
|
|
149
|
+
setActiveFlashProfile(name);
|
|
150
|
+
if (isJsonOutput(command)) {
|
|
151
|
+
printJson({ success: true, active: name });
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
printSuccess(`Using flash profile "${name}".`, "stderr");
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
catch (err) {
|
|
158
|
+
handleError(err, { json: isJsonOutput(command) });
|
|
159
|
+
}
|
|
160
|
+
});
|
|
161
|
+
cmd
|
|
162
|
+
.command("delete <name>")
|
|
163
|
+
.description("Delete a flash profile")
|
|
164
|
+
.action((name, _opts, command) => {
|
|
165
|
+
try {
|
|
166
|
+
deleteFlashProfile(name);
|
|
167
|
+
if (isJsonOutput(command)) {
|
|
168
|
+
printJson({ success: true, deleted: name });
|
|
169
|
+
}
|
|
170
|
+
else {
|
|
171
|
+
printSuccess(`Deleted flash profile "${name}".`, "stderr");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
catch (err) {
|
|
175
|
+
handleError(err, { json: isJsonOutput(command) });
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
return cmd;
|
|
179
|
+
}
|
|
180
|
+
//# sourceMappingURL=flash-profile.js.map
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import { getClient } from "../lib/client.js";
|
|
3
|
+
import { isJsonOutput } from "../lib/config.js";
|
|
4
|
+
import { printJson, printKeyValue, printSuccess } from "../lib/output.js";
|
|
5
|
+
import { handleError } from "../lib/errors.js";
|
|
6
|
+
import { promptText, promptSecret } from "../lib/prompt.js";
|
|
7
|
+
import { resolveFlashInputs } from "../lib/flash/resolve.js";
|
|
8
|
+
import { inspectBootPartition, detectBootPartitions, writeBootstrapFile, } from "../lib/flash/boot-partition.js";
|
|
9
|
+
import { renderHostname } from "../lib/flash/bootstrap.js";
|
|
10
|
+
import { mintRegistrationToken, waitForEnrolment, } from "../lib/flash/platform.js";
|
|
11
|
+
import { makeFlashProfileCommand } from "./flash-profile.js";
|
|
12
|
+
function collect(value, previous = []) {
|
|
13
|
+
return [...previous, value];
|
|
14
|
+
}
|
|
15
|
+
export function makeFlashCommand() {
|
|
16
|
+
const cmd = new Command("flash").description("Provision an SD card that already carries a 2kw-os base image");
|
|
17
|
+
// The one-off flow (`iot flash --name ... --wifi-ssid ...`) lives on this
|
|
18
|
+
// hidden default subcommand rather than directly on `cmd`. `cmd` also hosts
|
|
19
|
+
// `profile`, whose `set` action needs its OWN --wifi-ssid/--ssh-key/etc to
|
|
20
|
+
// populate a stored profile (see flash-profile.ts). Commander resolves a
|
|
21
|
+
// repeated flag name by whichever ANCESTOR in the parse chain declares it
|
|
22
|
+
// first, regardless of `enablePositionalOptions`/`passThroughOptions` — so
|
|
23
|
+
// if these options lived on `cmd` itself, `iot flash profile set bench
|
|
24
|
+
// --wifi-ssid ...` would have `cmd` silently swallow `--wifi-ssid` before
|
|
25
|
+
// `profile set` ever saw it, discarding the value the user typed. Keeping
|
|
26
|
+
// the options off `cmd` and on this sibling-of-`profile` child instead
|
|
27
|
+
// avoids the collision entirely.
|
|
28
|
+
const run = new Command("run")
|
|
29
|
+
.description("Provision an SD card that already carries a 2kw-os base image")
|
|
30
|
+
.option("--name <deviceName>", "Device name to register")
|
|
31
|
+
.option("--target <mount>", "SD boot partition mount point (auto-detected if omitted)")
|
|
32
|
+
.option("--wifi-ssid <ssid>", "WiFi SSID (repeatable, priority order)", collect)
|
|
33
|
+
.option("--wifi-psk <psk>", "WiFi passphrase (repeatable, pairs with --wifi-ssid)", collect)
|
|
34
|
+
.option("--country <cc>", "WiFi regulatory domain")
|
|
35
|
+
.option("--ssh-key <path>", "Operator SSH public key")
|
|
36
|
+
.option("--hostname <name>", "Override the hostname pattern")
|
|
37
|
+
.option("--profile <name>", "Flash profile to use")
|
|
38
|
+
.option("--profile-id <uuid>", "DeviceProfile to apply at provision")
|
|
39
|
+
.option("--no-wait", "Exit after writing instead of waiting for enrolment")
|
|
40
|
+
.option("--timeout <sec>", "Enrolment wait timeout in seconds", "300")
|
|
41
|
+
.option("--non-interactive", "Never prompt; error on a missing value")
|
|
42
|
+
.option("--force", "Write even if the target looks wrong or already prepared")
|
|
43
|
+
.action(async (opts, command) => {
|
|
44
|
+
try {
|
|
45
|
+
const json = isJsonOutput(command);
|
|
46
|
+
const flags = opts;
|
|
47
|
+
// 1. Resolve inputs.
|
|
48
|
+
const resolved = await resolveFlashInputs(flags, {
|
|
49
|
+
promptText,
|
|
50
|
+
promptSecret,
|
|
51
|
+
});
|
|
52
|
+
// 1b. A bad --timeout is only relevant when we'll actually wait
|
|
53
|
+
// (--no-wait leaves it unused), but when we will, validate it here —
|
|
54
|
+
// before minting a token or writing the card — so a typo never
|
|
55
|
+
// leaves an orphaned pending device registration and a written SD
|
|
56
|
+
// card behind a validation error.
|
|
57
|
+
let timeoutSeconds = 0;
|
|
58
|
+
if (flags.wait) {
|
|
59
|
+
timeoutSeconds = Number(flags.timeout);
|
|
60
|
+
if (!Number.isFinite(timeoutSeconds) || timeoutSeconds <= 0) {
|
|
61
|
+
throw new Error("--timeout must be a positive number of seconds.");
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
// 2. Locate and check the card.
|
|
65
|
+
const target = flags.target ?? autoDetectTarget();
|
|
66
|
+
const check = inspectBootPartition(target);
|
|
67
|
+
if (!check.looksLikeRpiBoot && !flags.force) {
|
|
68
|
+
throw new Error(`${target} does not look like a Raspberry Pi boot partition:\n ` +
|
|
69
|
+
check.reasons.join("\n ") +
|
|
70
|
+
"\nRefusing to write. Pass --force to override.");
|
|
71
|
+
}
|
|
72
|
+
if (check.hasStaleBootstrap && !flags.force) {
|
|
73
|
+
throw new Error(`${target} already contains a bootstrap.toml, so this card may already ` +
|
|
74
|
+
"be prepared. Refusing to overwrite. Pass --force to replace it.\n" +
|
|
75
|
+
"Note: a device that has already enrolled IGNORES a new token and keeps " +
|
|
76
|
+
"its existing identity, so re-flashing it leaves an unused pending device " +
|
|
77
|
+
"registered on the platform. WiFi, SSH and hostname changes still apply.");
|
|
78
|
+
}
|
|
79
|
+
// 3. Mint.
|
|
80
|
+
const { client, config } = getClient(command);
|
|
81
|
+
const mint = await mintRegistrationToken(client, resolved.deviceName);
|
|
82
|
+
const hostname = resolved.hostnameOverride ??
|
|
83
|
+
renderHostname(resolved.hostnamePattern, mint.deviceId);
|
|
84
|
+
// 4. Write. provision_url is carried from the mint response so a card
|
|
85
|
+
// flashed against staging cannot silently enrol into production.
|
|
86
|
+
const written = writeBootstrapFile(target, {
|
|
87
|
+
hostname,
|
|
88
|
+
profileId: resolved.profileId,
|
|
89
|
+
token: mint.token,
|
|
90
|
+
wifiCountry: resolved.country,
|
|
91
|
+
wifiNetworks: resolved.wifiNetworks,
|
|
92
|
+
sshAuthorizedKey: resolved.sshAuthorizedKey,
|
|
93
|
+
provisionUrl: mint.provisionEndpoint,
|
|
94
|
+
});
|
|
95
|
+
if (!json) {
|
|
96
|
+
printSuccess(`Wrote ${written}`, "stderr");
|
|
97
|
+
process.stderr.write(`Token expires ${mint.expiresAt} — boot the device before then.\n` +
|
|
98
|
+
"Safely eject the card, insert it, and power the device on.\n");
|
|
99
|
+
}
|
|
100
|
+
// 5. Verify.
|
|
101
|
+
if (!flags.wait) {
|
|
102
|
+
emit(json, {
|
|
103
|
+
deviceId: mint.deviceId,
|
|
104
|
+
deviceName: mint.deviceName,
|
|
105
|
+
hostname,
|
|
106
|
+
written,
|
|
107
|
+
status: "not_checked",
|
|
108
|
+
});
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (!json)
|
|
112
|
+
process.stderr.write("Waiting for the device to enrol...\n");
|
|
113
|
+
const timeoutMs = timeoutSeconds * 1000;
|
|
114
|
+
const result = await waitForEnrolment(client, mint.deviceId, {
|
|
115
|
+
timeoutMs,
|
|
116
|
+
intervalMs: 5000,
|
|
117
|
+
});
|
|
118
|
+
if (!result.online) {
|
|
119
|
+
const dashboard = `${config.baseUrl.replace(/\/api\/?$/, "")}/devices/${mint.deviceId}`;
|
|
120
|
+
throw new Error(`Device has not enrolled within ${flags.timeout}s (last status: ${result.status}). ` +
|
|
121
|
+
`The card is written and the token is valid until ${mint.expiresAt}. ` +
|
|
122
|
+
`Check ${dashboard}`);
|
|
123
|
+
}
|
|
124
|
+
emit(json, {
|
|
125
|
+
deviceId: mint.deviceId,
|
|
126
|
+
deviceName: mint.deviceName,
|
|
127
|
+
hostname,
|
|
128
|
+
written,
|
|
129
|
+
status: result.status,
|
|
130
|
+
lastSeenAt: result.lastSeenAt,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
handleError(err, { json: isJsonOutput(command) });
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
cmd.addCommand(run, { isDefault: true, hidden: true });
|
|
138
|
+
cmd.addCommand(makeFlashProfileCommand());
|
|
139
|
+
// `run` is hidden (it's reached by default, not by name), so its options
|
|
140
|
+
// would otherwise be invisible to `iot flash --help`. Append them instead
|
|
141
|
+
// of duplicating the flag list as static text, so the two can't drift.
|
|
142
|
+
cmd.addHelpText("after", () => {
|
|
143
|
+
const help = run.helpInformation();
|
|
144
|
+
const i = help.indexOf("Options:");
|
|
145
|
+
if (i < 0)
|
|
146
|
+
return "";
|
|
147
|
+
const optionsBlock = help.slice(i);
|
|
148
|
+
return `\nRun without a subcommand for the one-off flow:\n iot flash [options]\n\n${optionsBlock}`;
|
|
149
|
+
});
|
|
150
|
+
return cmd;
|
|
151
|
+
}
|
|
152
|
+
function autoDetectTarget() {
|
|
153
|
+
const found = detectBootPartitions();
|
|
154
|
+
if (found.length === 1)
|
|
155
|
+
return found[0];
|
|
156
|
+
if (found.length === 0) {
|
|
157
|
+
throw new Error("No Raspberry Pi boot partition found. Insert the flashed SD card, " +
|
|
158
|
+
"or point at it explicitly with --target.");
|
|
159
|
+
}
|
|
160
|
+
throw new Error(`Multiple boot partitions found (${found.join(", ")}). ` +
|
|
161
|
+
"Pick one with --target.");
|
|
162
|
+
}
|
|
163
|
+
/** Secrets are deliberately absent from every emitted field. */
|
|
164
|
+
function emit(json, payload) {
|
|
165
|
+
if (json) {
|
|
166
|
+
printJson(payload);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
printKeyValue({
|
|
170
|
+
"Device ID": String(payload.deviceId),
|
|
171
|
+
"Device Name": String(payload.deviceName),
|
|
172
|
+
Hostname: String(payload.hostname),
|
|
173
|
+
Status: String(payload.status),
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
//# sourceMappingURL=flash.js.map
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ import { makeAuthCommand } from "./commands/auth.js";
|
|
|
5
5
|
import { makeContextCommand } from "./commands/context.js";
|
|
6
6
|
import { makeConfigCommand } from "./commands/config.js";
|
|
7
7
|
import { makeDocsCommand } from "./commands/docs.js";
|
|
8
|
+
import { makeFlashCommand } from "./commands/flash.js";
|
|
8
9
|
import { checkForUpdates } from "./lib/update-notifier.js";
|
|
9
10
|
const require = createRequire(import.meta.url);
|
|
10
11
|
const pkg = require("../package.json");
|
|
@@ -24,6 +25,7 @@ program.addCommand(makeAuthCommand());
|
|
|
24
25
|
program.addCommand(makeContextCommand());
|
|
25
26
|
program.addCommand(makeConfigCommand());
|
|
26
27
|
program.addCommand(makeDocsCommand());
|
|
28
|
+
program.addCommand(makeFlashCommand());
|
|
27
29
|
const json = process.argv.includes("--json");
|
|
28
30
|
const updater = checkForUpdates(pkg.version, { json });
|
|
29
31
|
program
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type BootstrapInput } from "./bootstrap.js";
|
|
2
|
+
export declare const BOOTSTRAP_FILENAME = "bootstrap.toml";
|
|
3
|
+
export interface BootPartitionCheck {
|
|
4
|
+
looksLikeRpiBoot: boolean;
|
|
5
|
+
hasStaleBootstrap: boolean;
|
|
6
|
+
reasons: string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Decide whether a mount point looks like a Raspberry Pi FAT boot partition.
|
|
10
|
+
* This is the guard that stops us writing onto the wrong volume.
|
|
11
|
+
*/
|
|
12
|
+
export declare function inspectBootPartition(mountPath: string): BootPartitionCheck;
|
|
13
|
+
/**
|
|
14
|
+
* Enumerate plausible boot partitions across platforms. Windows drive letters,
|
|
15
|
+
* macOS /Volumes, Linux /media and /run/media.
|
|
16
|
+
*/
|
|
17
|
+
export declare function detectBootPartitions(): string[];
|
|
18
|
+
/**
|
|
19
|
+
* Render, write, then read back and re-parse. A write is never reported as
|
|
20
|
+
* successful on the strength of the write call alone — re-parsing is what
|
|
21
|
+
* proves a PSK full of shell-hostile characters survived intact.
|
|
22
|
+
*
|
|
23
|
+
* Returns the path written.
|
|
24
|
+
*/
|
|
25
|
+
export declare function writeBootstrapFile(mountPath: string, input: BootstrapInput): string;
|
|
26
|
+
//# sourceMappingURL=boot-partition.d.ts.map
|