@manfred-kunze-dev/iot-cli 3.3.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
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { renderBootstrapToml, parseBootstrapToml, } from "./bootstrap.js";
|
|
4
|
+
export const BOOTSTRAP_FILENAME = "bootstrap.toml";
|
|
5
|
+
/**
|
|
6
|
+
* Decide whether a mount point looks like a Raspberry Pi FAT boot partition.
|
|
7
|
+
* This is the guard that stops us writing onto the wrong volume.
|
|
8
|
+
*/
|
|
9
|
+
export function inspectBootPartition(mountPath) {
|
|
10
|
+
const reasons = [];
|
|
11
|
+
if (!existsSync(mountPath)) {
|
|
12
|
+
return {
|
|
13
|
+
looksLikeRpiBoot: false,
|
|
14
|
+
hasStaleBootstrap: false,
|
|
15
|
+
reasons: [`${mountPath} does not exist or is not mounted.`],
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
let entries = [];
|
|
19
|
+
try {
|
|
20
|
+
entries = readdirSync(mountPath);
|
|
21
|
+
}
|
|
22
|
+
catch (err) {
|
|
23
|
+
return {
|
|
24
|
+
looksLikeRpiBoot: false,
|
|
25
|
+
hasStaleBootstrap: false,
|
|
26
|
+
reasons: [`${mountPath} is not readable: ${err.message}`],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const lower = entries.map((e) => e.toLowerCase());
|
|
30
|
+
if (!lower.includes("config.txt"))
|
|
31
|
+
reasons.push("config.txt is missing.");
|
|
32
|
+
if (!lower.includes("cmdline.txt"))
|
|
33
|
+
reasons.push("cmdline.txt is missing.");
|
|
34
|
+
if (!lower.some((e) => e.endsWith(".dtb")))
|
|
35
|
+
reasons.push("no .dtb files found.");
|
|
36
|
+
return {
|
|
37
|
+
looksLikeRpiBoot: reasons.length === 0,
|
|
38
|
+
hasStaleBootstrap: lower.includes(BOOTSTRAP_FILENAME),
|
|
39
|
+
reasons,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Enumerate plausible boot partitions across platforms. Windows drive letters,
|
|
44
|
+
* macOS /Volumes, Linux /media and /run/media.
|
|
45
|
+
*/
|
|
46
|
+
export function detectBootPartitions() {
|
|
47
|
+
const candidates = [];
|
|
48
|
+
if (process.platform === "win32") {
|
|
49
|
+
for (let c = "D".charCodeAt(0); c <= "Z".charCodeAt(0); c++) {
|
|
50
|
+
candidates.push(`${String.fromCharCode(c)}:\\`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
const roots = ["/Volumes", "/media", "/run/media"];
|
|
55
|
+
for (const root of roots) {
|
|
56
|
+
if (!existsSync(root))
|
|
57
|
+
continue;
|
|
58
|
+
for (const entry of safeReaddir(root)) {
|
|
59
|
+
const path = join(root, entry);
|
|
60
|
+
candidates.push(path);
|
|
61
|
+
// /media/<user>/<label> nesting
|
|
62
|
+
for (const nested of safeReaddir(path)) {
|
|
63
|
+
candidates.push(join(path, nested));
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return candidates.filter((p) => inspectBootPartition(p).looksLikeRpiBoot);
|
|
69
|
+
}
|
|
70
|
+
function safeReaddir(path) {
|
|
71
|
+
try {
|
|
72
|
+
return readdirSync(path);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return [];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Render, write, then read back and re-parse. A write is never reported as
|
|
80
|
+
* successful on the strength of the write call alone — re-parsing is what
|
|
81
|
+
* proves a PSK full of shell-hostile characters survived intact.
|
|
82
|
+
*
|
|
83
|
+
* Returns the path written.
|
|
84
|
+
*/
|
|
85
|
+
export function writeBootstrapFile(mountPath, input) {
|
|
86
|
+
const target = join(mountPath, BOOTSTRAP_FILENAME);
|
|
87
|
+
const content = renderBootstrapToml(input);
|
|
88
|
+
// Explicit utf-8, no BOM. Node never adds one; this is belt-and-braces
|
|
89
|
+
// against a future refactor reaching for utf16 or a BOM-writing helper.
|
|
90
|
+
writeFileSync(target, content, { encoding: "utf-8" });
|
|
91
|
+
const raw = readFileSync(target);
|
|
92
|
+
if (raw.subarray(0, 3).toString("hex") === "efbbbf") {
|
|
93
|
+
throw new Error(`${target} was written with a BOM; the gateway cannot parse it.`);
|
|
94
|
+
}
|
|
95
|
+
const roundTripped = parseBootstrapToml(raw.toString("utf-8"));
|
|
96
|
+
assertRoundTrip(input, roundTripped, target);
|
|
97
|
+
return target;
|
|
98
|
+
}
|
|
99
|
+
function assertRoundTrip(expected, actual, target) {
|
|
100
|
+
const mismatch = (field) => {
|
|
101
|
+
throw new Error(`${target} did not round-trip correctly (${field} differs). ` +
|
|
102
|
+
"The card may be faulty or full — do not boot this device.");
|
|
103
|
+
};
|
|
104
|
+
if (actual.token !== expected.token)
|
|
105
|
+
mismatch("provision.token");
|
|
106
|
+
if (actual.hostname !== expected.hostname)
|
|
107
|
+
mismatch("device.hostname");
|
|
108
|
+
if (actual.profileId !== expected.profileId)
|
|
109
|
+
mismatch("device.profile_id");
|
|
110
|
+
if (actual.wifiCountry !== expected.wifiCountry)
|
|
111
|
+
mismatch("wifi.country");
|
|
112
|
+
if (actual.sshAuthorizedKey !== expected.sshAuthorizedKey)
|
|
113
|
+
mismatch("ssh.authorized_key");
|
|
114
|
+
if (actual.provisionUrl !== expected.provisionUrl)
|
|
115
|
+
mismatch("backend.provision_url");
|
|
116
|
+
if (actual.brokerUri !== expected.brokerUri)
|
|
117
|
+
mismatch("backend.broker_uri");
|
|
118
|
+
if (actual.wifiNetworks.length !== expected.wifiNetworks.length)
|
|
119
|
+
mismatch("wifi.networks");
|
|
120
|
+
expected.wifiNetworks.forEach((net, i) => {
|
|
121
|
+
const got = actual.wifiNetworks[i];
|
|
122
|
+
if (got.ssid !== net.ssid)
|
|
123
|
+
mismatch(`wifi.networks[${i}].ssid`);
|
|
124
|
+
if (got.psk !== net.psk)
|
|
125
|
+
mismatch(`wifi.networks[${i}].psk`);
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
//# sourceMappingURL=boot-partition.js.map
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export interface WifiNetwork {
|
|
2
|
+
ssid: string;
|
|
3
|
+
/** Omitted entirely for an open network. */
|
|
4
|
+
psk?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface BootstrapInput {
|
|
7
|
+
hostname?: string;
|
|
8
|
+
profileId?: string;
|
|
9
|
+
token: string;
|
|
10
|
+
wifiCountry: string;
|
|
11
|
+
/** Priority order — first entry is preferred. */
|
|
12
|
+
wifiNetworks: WifiNetwork[];
|
|
13
|
+
sshAuthorizedKey: string;
|
|
14
|
+
provisionUrl?: string;
|
|
15
|
+
brokerUri?: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Render bootstrap.toml matching the gateway's parser at
|
|
19
|
+
* embedded/products/gateway-rpi/src/mkd_gateway/bootstrap.py.
|
|
20
|
+
*
|
|
21
|
+
* Always UTF-8 with no BOM — the Pi's tomllib rejects a BOM.
|
|
22
|
+
*/
|
|
23
|
+
export declare function renderBootstrapToml(input: BootstrapInput): string;
|
|
24
|
+
/**
|
|
25
|
+
* Parse bootstrap.toml back into the input shape. Used to prove a written
|
|
26
|
+
* file round-trips — byte-comparing our own output against itself would
|
|
27
|
+
* not catch an escaping bug.
|
|
28
|
+
*/
|
|
29
|
+
export declare function parseBootstrapToml(text: string): BootstrapInput;
|
|
30
|
+
/**
|
|
31
|
+
* Render a hostname from a pattern. Hostnames must be unique per device,
|
|
32
|
+
* so patterns are expected to reference the device id.
|
|
33
|
+
*/
|
|
34
|
+
export declare function renderHostname(pattern: string, deviceId: string): string;
|
|
35
|
+
//# sourceMappingURL=bootstrap.d.ts.map
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { parse as parseToml } from "smol-toml";
|
|
2
|
+
/** RFC-1123 label rules, which is what hostnamectl will accept. */
|
|
3
|
+
const HOSTNAME_RE = /^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
|
4
|
+
/**
|
|
5
|
+
* Serialize a TOML basic string. We escape rather than rely on literal
|
|
6
|
+
* strings because PSKs routinely contain quotes and backslashes.
|
|
7
|
+
*/
|
|
8
|
+
function tomlString(value) {
|
|
9
|
+
const escaped = value
|
|
10
|
+
.replace(/\\/g, "\\\\")
|
|
11
|
+
.replace(/"/g, '\\"')
|
|
12
|
+
.replace(/\n/g, "\\n")
|
|
13
|
+
.replace(/\r/g, "\\r")
|
|
14
|
+
.replace(/\t/g, "\\t");
|
|
15
|
+
return `"${escaped}"`;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Render bootstrap.toml matching the gateway's parser at
|
|
19
|
+
* embedded/products/gateway-rpi/src/mkd_gateway/bootstrap.py.
|
|
20
|
+
*
|
|
21
|
+
* Always UTF-8 with no BOM — the Pi's tomllib rejects a BOM.
|
|
22
|
+
*/
|
|
23
|
+
export function renderBootstrapToml(input) {
|
|
24
|
+
if (input.wifiNetworks.length === 0) {
|
|
25
|
+
throw new Error("At least one WiFi network is required.");
|
|
26
|
+
}
|
|
27
|
+
const lines = [];
|
|
28
|
+
lines.push("# Generated by `iot flash`. Consumed and deleted on first boot.");
|
|
29
|
+
lines.push("");
|
|
30
|
+
if (input.hostname || input.profileId) {
|
|
31
|
+
lines.push("[device]");
|
|
32
|
+
if (input.hostname)
|
|
33
|
+
lines.push(`hostname = ${tomlString(input.hostname)}`);
|
|
34
|
+
if (input.profileId)
|
|
35
|
+
lines.push(`profile_id = ${tomlString(input.profileId)}`);
|
|
36
|
+
lines.push("");
|
|
37
|
+
}
|
|
38
|
+
lines.push("[provision]");
|
|
39
|
+
lines.push(`token = ${tomlString(input.token)}`);
|
|
40
|
+
lines.push("");
|
|
41
|
+
lines.push("[wifi]");
|
|
42
|
+
lines.push(`country = ${tomlString(input.wifiCountry)}`);
|
|
43
|
+
lines.push("");
|
|
44
|
+
for (const net of input.wifiNetworks) {
|
|
45
|
+
lines.push("[[wifi.networks]]");
|
|
46
|
+
lines.push(`ssid = ${tomlString(net.ssid)}`);
|
|
47
|
+
if (net.psk !== undefined)
|
|
48
|
+
lines.push(`psk = ${tomlString(net.psk)}`);
|
|
49
|
+
lines.push("");
|
|
50
|
+
}
|
|
51
|
+
lines.push("[ssh]");
|
|
52
|
+
lines.push(`authorized_key = ${tomlString(input.sshAuthorizedKey)}`);
|
|
53
|
+
lines.push("");
|
|
54
|
+
if (input.provisionUrl || input.brokerUri) {
|
|
55
|
+
lines.push("[backend]");
|
|
56
|
+
if (input.provisionUrl)
|
|
57
|
+
lines.push(`provision_url = ${tomlString(input.provisionUrl)}`);
|
|
58
|
+
if (input.brokerUri)
|
|
59
|
+
lines.push(`broker_uri = ${tomlString(input.brokerUri)}`);
|
|
60
|
+
lines.push("");
|
|
61
|
+
}
|
|
62
|
+
return lines.join("\n");
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Parse bootstrap.toml back into the input shape. Used to prove a written
|
|
66
|
+
* file round-trips — byte-comparing our own output against itself would
|
|
67
|
+
* not catch an escaping bug.
|
|
68
|
+
*/
|
|
69
|
+
export function parseBootstrapToml(text) {
|
|
70
|
+
const raw = parseToml(text);
|
|
71
|
+
const rawNets = raw.wifi?.networks;
|
|
72
|
+
const networks = Array.isArray(rawNets)
|
|
73
|
+
? rawNets
|
|
74
|
+
.filter((n) => typeof n?.ssid === "string")
|
|
75
|
+
.map((n) => (n.psk === undefined ? { ssid: n.ssid } : { ssid: n.ssid, psk: n.psk }))
|
|
76
|
+
: raw.wifi?.ssid
|
|
77
|
+
? [raw.wifi.psk === undefined ? { ssid: raw.wifi.ssid } : { ssid: raw.wifi.ssid, psk: raw.wifi.psk }]
|
|
78
|
+
: [];
|
|
79
|
+
return {
|
|
80
|
+
hostname: raw.device?.hostname,
|
|
81
|
+
profileId: raw.device?.profile_id,
|
|
82
|
+
token: raw.provision?.token ?? "",
|
|
83
|
+
wifiCountry: raw.wifi?.country ?? "",
|
|
84
|
+
wifiNetworks: networks,
|
|
85
|
+
sshAuthorizedKey: raw.ssh?.authorized_key ?? "",
|
|
86
|
+
provisionUrl: raw.backend?.provision_url,
|
|
87
|
+
brokerUri: raw.backend?.broker_uri,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Render a hostname from a pattern. Hostnames must be unique per device,
|
|
92
|
+
* so patterns are expected to reference the device id.
|
|
93
|
+
*/
|
|
94
|
+
export function renderHostname(pattern, deviceId) {
|
|
95
|
+
const short = deviceId.split("-")[0] ?? deviceId;
|
|
96
|
+
const out = pattern
|
|
97
|
+
.replace(/\{deviceIdShort\}/g, short)
|
|
98
|
+
.replace(/\{deviceId\}/g, deviceId);
|
|
99
|
+
if (!HOSTNAME_RE.test(out)) {
|
|
100
|
+
throw new Error(`Pattern "${pattern}" produced "${out}", which is not a valid hostname ` +
|
|
101
|
+
"(letters, digits and hyphens only; must start and end alphanumeric).");
|
|
102
|
+
}
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
//# sourceMappingURL=bootstrap.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { IotClient } from "../client.js";
|
|
2
|
+
export interface MintResult {
|
|
3
|
+
token: string;
|
|
4
|
+
deviceId: string;
|
|
5
|
+
deviceName: string;
|
|
6
|
+
provisionEndpoint: string;
|
|
7
|
+
expiresAt: string;
|
|
8
|
+
}
|
|
9
|
+
export interface EnrolmentResult {
|
|
10
|
+
online: boolean;
|
|
11
|
+
status: string;
|
|
12
|
+
lastSeenAt: string | null;
|
|
13
|
+
timedOut: boolean;
|
|
14
|
+
}
|
|
15
|
+
export declare function mintRegistrationToken(client: IotClient, deviceName: string): Promise<MintResult>;
|
|
16
|
+
export declare function waitForEnrolment(client: IotClient, deviceId: string, opts: {
|
|
17
|
+
timeoutMs: number;
|
|
18
|
+
intervalMs: number;
|
|
19
|
+
sleep?: (ms: number) => Promise<void>;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
onPoll?: (status: string) => void;
|
|
22
|
+
}): Promise<EnrolmentResult>;
|
|
23
|
+
//# sourceMappingURL=platform.d.ts.map
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { ApiError, CLIError, EXIT } from "../errors.js";
|
|
2
|
+
/**
|
|
3
|
+
* A device that has never checked in has no lastSeenAt. Treat "has a
|
|
4
|
+
* lastSeenAt at all" as the enrolment signal — a freshly booted device
|
|
5
|
+
* reporting in is exactly the success criterion, and this avoids
|
|
6
|
+
* hard-coding a status vocabulary the platform may extend.
|
|
7
|
+
*/
|
|
8
|
+
function isOnline(status, lastSeenAt) {
|
|
9
|
+
if (!lastSeenAt)
|
|
10
|
+
return false;
|
|
11
|
+
return status !== "PENDING";
|
|
12
|
+
}
|
|
13
|
+
export async function mintRegistrationToken(client, deviceName) {
|
|
14
|
+
let data;
|
|
15
|
+
// The client middleware THROWS ApiError on non-2xx, so the 404 case must be
|
|
16
|
+
// caught, not read off a returned `error` field.
|
|
17
|
+
try {
|
|
18
|
+
const res = await client.POST("/v1/devices/registration/tokens", {
|
|
19
|
+
body: { deviceName },
|
|
20
|
+
});
|
|
21
|
+
data = res.data;
|
|
22
|
+
}
|
|
23
|
+
catch (err) {
|
|
24
|
+
if (err instanceof ApiError && err.status === 404) {
|
|
25
|
+
throw new CLIError("The registration endpoint returned 404. API keys are organization-scoped " +
|
|
26
|
+
"under row-level security, so a key belonging to a different organization " +
|
|
27
|
+
"looks identical to a missing endpoint.", EXIT.NOT_FOUND, 'Check `iot context current` and that the key belongs to the target organization.');
|
|
28
|
+
}
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
if (!data) {
|
|
32
|
+
throw new CLIError("The platform returned an empty registration response.");
|
|
33
|
+
}
|
|
34
|
+
const result = data;
|
|
35
|
+
if (!result.token || result.token.split(".").length !== 3) {
|
|
36
|
+
throw new CLIError("The platform returned a malformed registration token.");
|
|
37
|
+
}
|
|
38
|
+
return result;
|
|
39
|
+
}
|
|
40
|
+
const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
41
|
+
export async function waitForEnrolment(client, deviceId, opts) {
|
|
42
|
+
const sleep = opts.sleep ?? defaultSleep;
|
|
43
|
+
const now = opts.now ?? (() => Date.now());
|
|
44
|
+
const started = now();
|
|
45
|
+
let status = "UNKNOWN";
|
|
46
|
+
let lastSeenAt = null;
|
|
47
|
+
while (now() - started < opts.timeoutMs) {
|
|
48
|
+
try {
|
|
49
|
+
const { data } = await client.GET("/v1/devices/{id}", {
|
|
50
|
+
params: { path: { id: deviceId } },
|
|
51
|
+
});
|
|
52
|
+
if (data) {
|
|
53
|
+
const d = data;
|
|
54
|
+
status = d.status ?? "UNKNOWN";
|
|
55
|
+
lastSeenAt = d.lastSeenAt ?? null;
|
|
56
|
+
opts.onPoll?.(status);
|
|
57
|
+
if (isOnline(status, lastSeenAt)) {
|
|
58
|
+
return { online: true, status, lastSeenAt, timedOut: false };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
// The card is already written by the time we poll, so a transient blip
|
|
64
|
+
// must not abort the wait. Bad credentials, however, will never recover
|
|
65
|
+
// and would otherwise burn the full timeout in silence.
|
|
66
|
+
if (err instanceof ApiError && (err.status === 401 || err.status === 403)) {
|
|
67
|
+
throw err;
|
|
68
|
+
}
|
|
69
|
+
status = "UNREACHABLE";
|
|
70
|
+
}
|
|
71
|
+
await sleep(opts.intervalMs);
|
|
72
|
+
}
|
|
73
|
+
return { online: false, status, lastSeenAt, timedOut: true };
|
|
74
|
+
}
|
|
75
|
+
//# sourceMappingURL=platform.js.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { WifiNetwork } from "./bootstrap.js";
|
|
2
|
+
export declare const DEFAULT_COUNTRY = "DE";
|
|
3
|
+
export declare const DEFAULT_HOSTNAME_PATTERN = "gw-{deviceIdShort}";
|
|
4
|
+
export interface FlashProfile {
|
|
5
|
+
sshKeyPath?: string;
|
|
6
|
+
country: string;
|
|
7
|
+
hostnamePattern: string;
|
|
8
|
+
/**
|
|
9
|
+
* `prompt-always` means a customer's WiFi credentials are used for one
|
|
10
|
+
* flash and never written to disk.
|
|
11
|
+
*/
|
|
12
|
+
wifiMode: "store" | "prompt-always";
|
|
13
|
+
wifiNetworks?: WifiNetwork[];
|
|
14
|
+
context?: string;
|
|
15
|
+
profileId?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function getAllFlashProfiles(): Record<string, FlashProfile>;
|
|
18
|
+
export declare function getFlashProfile(name: string): FlashProfile | undefined;
|
|
19
|
+
export declare function setFlashProfile(name: string, profile: FlashProfile): void;
|
|
20
|
+
export declare function deleteFlashProfile(name: string): void;
|
|
21
|
+
export declare function getActiveFlashProfileName(): string | undefined;
|
|
22
|
+
export declare function setActiveFlashProfile(name: string): void;
|
|
23
|
+
//# sourceMappingURL=profile.d.ts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { store } from "../config.js";
|
|
2
|
+
export const DEFAULT_COUNTRY = "DE";
|
|
3
|
+
export const DEFAULT_HOSTNAME_PATTERN = "gw-{deviceIdShort}";
|
|
4
|
+
const PROFILES_KEY = "flashProfiles";
|
|
5
|
+
const ACTIVE_KEY = "activeFlashProfile";
|
|
6
|
+
export function getAllFlashProfiles() {
|
|
7
|
+
return (store.get(PROFILES_KEY) ?? {});
|
|
8
|
+
}
|
|
9
|
+
export function getFlashProfile(name) {
|
|
10
|
+
return getAllFlashProfiles()[name];
|
|
11
|
+
}
|
|
12
|
+
export function setFlashProfile(name, profile) {
|
|
13
|
+
const profiles = getAllFlashProfiles();
|
|
14
|
+
// Enforce the privacy promise at the storage boundary rather than trusting
|
|
15
|
+
// every call site to strip credentials first.
|
|
16
|
+
const toStore = profile.wifiMode === "prompt-always"
|
|
17
|
+
? { ...profile, wifiNetworks: undefined }
|
|
18
|
+
: profile;
|
|
19
|
+
profiles[name] = toStore;
|
|
20
|
+
store.set(PROFILES_KEY, profiles);
|
|
21
|
+
}
|
|
22
|
+
export function deleteFlashProfile(name) {
|
|
23
|
+
const profiles = getAllFlashProfiles();
|
|
24
|
+
if (!profiles[name]) {
|
|
25
|
+
throw new Error(`Flash profile "${name}" does not exist.`);
|
|
26
|
+
}
|
|
27
|
+
delete profiles[name];
|
|
28
|
+
store.set(PROFILES_KEY, profiles);
|
|
29
|
+
if (getActiveFlashProfileName() === name) {
|
|
30
|
+
// conf's `set` rejects `undefined` ("Use `delete()` to clear values") —
|
|
31
|
+
// `delete()` is the correct way to unset a key.
|
|
32
|
+
store.delete(ACTIVE_KEY);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export function getActiveFlashProfileName() {
|
|
36
|
+
return store.get(ACTIVE_KEY);
|
|
37
|
+
}
|
|
38
|
+
export function setActiveFlashProfile(name) {
|
|
39
|
+
if (!getFlashProfile(name)) {
|
|
40
|
+
const available = Object.keys(getAllFlashProfiles()).join(", ") || "(none)";
|
|
41
|
+
throw new Error(`Flash profile "${name}" does not exist. Available profiles: ${available}`);
|
|
42
|
+
}
|
|
43
|
+
store.set(ACTIVE_KEY, name);
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=profile.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { WifiNetwork } from "./bootstrap.js";
|
|
2
|
+
export interface FlashFlags {
|
|
3
|
+
name?: string;
|
|
4
|
+
target?: string;
|
|
5
|
+
wifiSsid?: string[];
|
|
6
|
+
wifiPsk?: string[];
|
|
7
|
+
country?: string;
|
|
8
|
+
sshKey?: string;
|
|
9
|
+
hostname?: string;
|
|
10
|
+
profile?: string;
|
|
11
|
+
profileId?: string;
|
|
12
|
+
nonInteractive?: boolean;
|
|
13
|
+
}
|
|
14
|
+
export interface ResolveDeps {
|
|
15
|
+
promptText: (question: string, opts?: {
|
|
16
|
+
default?: string;
|
|
17
|
+
}) => Promise<string>;
|
|
18
|
+
promptSecret: (question: string) => Promise<string>;
|
|
19
|
+
isInteractive?: () => boolean;
|
|
20
|
+
}
|
|
21
|
+
export interface ResolvedFlashInputs {
|
|
22
|
+
deviceName: string;
|
|
23
|
+
wifiNetworks: WifiNetwork[];
|
|
24
|
+
country: string;
|
|
25
|
+
sshAuthorizedKey: string;
|
|
26
|
+
hostnamePattern: string;
|
|
27
|
+
hostnameOverride?: string;
|
|
28
|
+
profileId?: string;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Resolve every field with priority:
|
|
32
|
+
* flag > env > profile > interactive prompt > error
|
|
33
|
+
*
|
|
34
|
+
* Prompting happens only when stdin is a TTY and --non-interactive is absent,
|
|
35
|
+
* so CI fails fast with a named flag instead of hanging on a prompt.
|
|
36
|
+
*/
|
|
37
|
+
export declare function resolveFlashInputs(flags: FlashFlags, deps: ResolveDeps): Promise<ResolvedFlashInputs>;
|
|
38
|
+
/**
|
|
39
|
+
* --wifi-ssid and --wifi-psk are repeatable and positionally paired, so
|
|
40
|
+
* `--wifi-ssid A --wifi-psk a --wifi-ssid B --wifi-psk b` yields A/a then B/b.
|
|
41
|
+
* A network with no matching psk is treated as open. Shared with
|
|
42
|
+
* `flash profile set`, which pairs the same two flags into stored networks.
|
|
43
|
+
*/
|
|
44
|
+
export declare function pairWifiNetworks(ssids: string[], psks: string[]): WifiNetwork[];
|
|
45
|
+
//# sourceMappingURL=resolve.d.ts.map
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { getFlashProfile, getActiveFlashProfileName, DEFAULT_COUNTRY, DEFAULT_HOSTNAME_PATTERN, } from "./profile.js";
|
|
3
|
+
function required(value, flag, interactive) {
|
|
4
|
+
if (value)
|
|
5
|
+
return value;
|
|
6
|
+
throw new Error(interactive
|
|
7
|
+
? `Missing required value; pass ${flag}.`
|
|
8
|
+
: `Missing required value in non-interactive mode; pass ${flag}.`);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Resolve every field with priority:
|
|
12
|
+
* flag > env > profile > interactive prompt > error
|
|
13
|
+
*
|
|
14
|
+
* Prompting happens only when stdin is a TTY and --non-interactive is absent,
|
|
15
|
+
* so CI fails fast with a named flag instead of hanging on a prompt.
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveFlashInputs(flags, deps) {
|
|
18
|
+
const isTty = deps.isInteractive
|
|
19
|
+
? deps.isInteractive()
|
|
20
|
+
: process.stdin.isTTY === true;
|
|
21
|
+
const interactive = !flags.nonInteractive && isTty;
|
|
22
|
+
const profileName = flags.profile ?? getActiveFlashProfileName();
|
|
23
|
+
const profile = profileName
|
|
24
|
+
? getFlashProfile(profileName)
|
|
25
|
+
: undefined;
|
|
26
|
+
if (flags.profile && !profile) {
|
|
27
|
+
throw new Error(`Flash profile "${flags.profile}" does not exist.`);
|
|
28
|
+
}
|
|
29
|
+
// --- device name ---
|
|
30
|
+
let deviceName = flags.name ?? process.env.IOT_FLASH_DEVICE_NAME;
|
|
31
|
+
if (!deviceName && interactive) {
|
|
32
|
+
deviceName = await deps.promptText("Device name");
|
|
33
|
+
}
|
|
34
|
+
deviceName = required(deviceName, "--name", interactive);
|
|
35
|
+
// --- country ---
|
|
36
|
+
const country = flags.country ??
|
|
37
|
+
process.env.IOT_FLASH_COUNTRY ??
|
|
38
|
+
profile?.country ??
|
|
39
|
+
DEFAULT_COUNTRY;
|
|
40
|
+
// --- ssh key ---
|
|
41
|
+
let sshKeyPath = flags.sshKey ?? process.env.IOT_FLASH_SSH_KEY ?? profile?.sshKeyPath;
|
|
42
|
+
if (!sshKeyPath && interactive) {
|
|
43
|
+
sshKeyPath = await deps.promptText("Path to operator SSH public key");
|
|
44
|
+
}
|
|
45
|
+
sshKeyPath = required(sshKeyPath, "--ssh-key", interactive);
|
|
46
|
+
if (!existsSync(sshKeyPath)) {
|
|
47
|
+
throw new Error(`SSH public key not found: ${sshKeyPath}`);
|
|
48
|
+
}
|
|
49
|
+
const sshAuthorizedKey = readFileSync(sshKeyPath, "utf-8").trim();
|
|
50
|
+
if (!sshAuthorizedKey.startsWith("ssh-")) {
|
|
51
|
+
throw new Error(`${sshKeyPath} does not look like an SSH public key ` +
|
|
52
|
+
'(expected it to start with "ssh-"). Did you point at the private key?');
|
|
53
|
+
}
|
|
54
|
+
// --- wifi ---
|
|
55
|
+
let wifiNetworks = pairWifiFlags(flags);
|
|
56
|
+
if (wifiNetworks.length === 0 && profile?.wifiMode === "store") {
|
|
57
|
+
wifiNetworks = profile.wifiNetworks ?? [];
|
|
58
|
+
}
|
|
59
|
+
if (wifiNetworks.length === 0) {
|
|
60
|
+
if (!interactive) {
|
|
61
|
+
throw new Error("No WiFi network configured. Pass --wifi-ssid and --wifi-psk " +
|
|
62
|
+
"(repeat both, in priority order), or store them in a flash profile.");
|
|
63
|
+
}
|
|
64
|
+
const ssid = await deps.promptText("WiFi SSID");
|
|
65
|
+
if (!ssid)
|
|
66
|
+
throw new Error("A WiFi SSID is required.");
|
|
67
|
+
const psk = await deps.promptSecret("WiFi passphrase (blank for an open network)");
|
|
68
|
+
wifiNetworks = [psk ? { ssid, psk } : { ssid }];
|
|
69
|
+
}
|
|
70
|
+
return {
|
|
71
|
+
deviceName,
|
|
72
|
+
wifiNetworks,
|
|
73
|
+
country,
|
|
74
|
+
sshAuthorizedKey,
|
|
75
|
+
hostnamePattern: profile?.hostnamePattern ?? DEFAULT_HOSTNAME_PATTERN,
|
|
76
|
+
hostnameOverride: flags.hostname,
|
|
77
|
+
profileId: flags.profileId ?? profile?.profileId,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* --wifi-ssid and --wifi-psk are repeatable and positionally paired, so
|
|
82
|
+
* `--wifi-ssid A --wifi-psk a --wifi-ssid B --wifi-psk b` yields A/a then B/b.
|
|
83
|
+
* A network with no matching psk is treated as open. Shared with
|
|
84
|
+
* `flash profile set`, which pairs the same two flags into stored networks.
|
|
85
|
+
*/
|
|
86
|
+
export function pairWifiNetworks(ssids, psks) {
|
|
87
|
+
return ssids.map((ssid, i) => {
|
|
88
|
+
const psk = psks[i];
|
|
89
|
+
return psk ? { ssid, psk } : { ssid };
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
function pairWifiFlags(flags) {
|
|
93
|
+
return pairWifiNetworks(flags.wifiSsid ?? [], flags.wifiPsk ?? []);
|
|
94
|
+
}
|
|
95
|
+
//# sourceMappingURL=resolve.js.map
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Prompts always render on stderr so stdout stays machine-readable. */
|
|
2
|
+
export declare function promptText(question: string, opts?: {
|
|
3
|
+
default?: string;
|
|
4
|
+
}): Promise<string>;
|
|
5
|
+
/** Hidden input for secrets (WiFi PSKs, API keys). */
|
|
6
|
+
export declare function promptSecret(question: string): Promise<string>;
|
|
7
|
+
/**
|
|
8
|
+
* Tiny hidden-input helper for API keys. readline.question doesn't natively
|
|
9
|
+
* support hiding input on Windows/POSIX consistently, so we mute the muxed
|
|
10
|
+
* output stream while the user types.
|
|
11
|
+
*/
|
|
12
|
+
export declare function promptHidden(rl: import("node:readline/promises").Interface, prompt: string): Promise<string>;
|
|
13
|
+
//# sourceMappingURL=prompt.d.ts.map
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
/** Prompts always render on stderr so stdout stays machine-readable. */
|
|
3
|
+
export async function promptText(question, opts = {}) {
|
|
4
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
5
|
+
try {
|
|
6
|
+
const suffix = opts.default ? ` [${opts.default}]` : "";
|
|
7
|
+
const answer = (await rl.question(`${question}${suffix}: `)).trim();
|
|
8
|
+
return answer || opts.default || "";
|
|
9
|
+
}
|
|
10
|
+
finally {
|
|
11
|
+
rl.close();
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
/** Hidden input for secrets (WiFi PSKs, API keys). */
|
|
15
|
+
export async function promptSecret(question) {
|
|
16
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
17
|
+
try {
|
|
18
|
+
return (await promptHidden(rl, `${question}: `)).trim();
|
|
19
|
+
}
|
|
20
|
+
finally {
|
|
21
|
+
rl.close();
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Tiny hidden-input helper for API keys. readline.question doesn't natively
|
|
26
|
+
* support hiding input on Windows/POSIX consistently, so we mute the muxed
|
|
27
|
+
* output stream while the user types.
|
|
28
|
+
*/
|
|
29
|
+
export function promptHidden(rl, prompt) {
|
|
30
|
+
return new Promise((resolveAnswer, rejectAnswer) => {
|
|
31
|
+
const mutableStdout = rl.output;
|
|
32
|
+
const original = mutableStdout.write.bind(mutableStdout);
|
|
33
|
+
let muted = false;
|
|
34
|
+
mutableStdout.write = ((chunk, enc, cb) => {
|
|
35
|
+
if (muted && typeof chunk === "string") {
|
|
36
|
+
return original("", enc, cb);
|
|
37
|
+
}
|
|
38
|
+
return original(chunk, enc, cb);
|
|
39
|
+
});
|
|
40
|
+
process.stderr.write(prompt);
|
|
41
|
+
muted = true;
|
|
42
|
+
rl.question("")
|
|
43
|
+
.then((answer) => {
|
|
44
|
+
mutableStdout.write = original;
|
|
45
|
+
process.stderr.write("\n");
|
|
46
|
+
resolveAnswer(answer);
|
|
47
|
+
})
|
|
48
|
+
.catch((err) => {
|
|
49
|
+
mutableStdout.write = original;
|
|
50
|
+
rejectAnswer(err);
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=prompt.js.map
|