@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 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 | 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`) |
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 | Description |
76
- |---------|-------------|
77
- | `auth` | Login, logout, and check auth status |
78
- | `config` | Get, set, and list global configuration values |
79
- | `context` | Manage CLI contexts for multiple environments |
80
- | `docs` | Browse API documentation from the terminal |
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 | Description |
87
- |------|-------------|
88
- | `--api-key <key>` | Override the API 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` | Output raw JSON instead of formatted tables |
92
- | `--no-color` | Disable colored output |
93
- | `--verbose` | Print resolved base URL, masked key, and request method+path on stderr before each call |
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 | Success |
104
- | 1 | Generic error |
105
- | 2 | Not authenticated (401) |
106
- | 3 | Forbidden (403) |
107
- | 4 | Not found (404) |
108
- | 5 | Conflict (409) |
109
- | 6 | Validation error (422) |
110
- | 7 | Server error (5xx) |
111
- | 8 | Network error |
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 -pl backend spring-boot:run # in backend/
133
- npm run generate # in cli/
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.
@@ -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({ input: process.stdin, output: process.stderr });
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?.total ?? null,
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?.total ?? 0;
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, { apiKey: "", baseUrl: ctx?.baseUrl ?? DEFAULT_BASE_URL });
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,3 @@
1
+ import { Command } from "commander";
2
+ export declare function makeFlashProfileCommand(): Command;
3
+ //# sourceMappingURL=flash-profile.d.ts.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,3 @@
1
+ import { Command } from "commander";
2
+ export declare function makeFlashCommand(): Command;
3
+ //# sourceMappingURL=flash.d.ts.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