@manfred-kunze-dev/iot-cli 3.0.0-dev.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,160 @@
1
+ import chalk from "chalk";
2
+ /**
3
+ * Process exit codes. Stable contract for scripts piping into the CLI.
4
+ * Update README.md when these change.
5
+ */
6
+ export const EXIT = {
7
+ OK: 0,
8
+ GENERIC: 1,
9
+ UNAUTHORIZED: 2,
10
+ FORBIDDEN: 3,
11
+ NOT_FOUND: 4,
12
+ CONFLICT: 5,
13
+ VALIDATION: 6,
14
+ SERVER: 7,
15
+ NETWORK: 8,
16
+ };
17
+ /**
18
+ * Anything the CLI throws on purpose. Carries the exit code we want the
19
+ * process to terminate with and an optional one-line suggestion the user
20
+ * can act on ("Run iot auth login").
21
+ */
22
+ export class CLIError extends Error {
23
+ exitCode;
24
+ suggestion;
25
+ constructor(message, exitCode = EXIT.GENERIC, suggestion) {
26
+ super(message);
27
+ this.name = "CLIError";
28
+ this.exitCode = exitCode;
29
+ this.suggestion = suggestion;
30
+ }
31
+ }
32
+ /**
33
+ * Thrown by the openapi-fetch error middleware when the server returns a
34
+ * non-2xx response. Carries the parsed response body and request info.
35
+ */
36
+ export class ApiError extends Error {
37
+ status;
38
+ code;
39
+ errors;
40
+ method;
41
+ path;
42
+ constructor(payload) {
43
+ super(payload.message);
44
+ this.name = "ApiError";
45
+ this.status = payload.status;
46
+ this.code = payload.code;
47
+ this.errors = payload.errors;
48
+ this.method = payload.method;
49
+ this.path = payload.path;
50
+ }
51
+ }
52
+ /**
53
+ * Map an HTTP status to a CLIError with the spec-mandated exit code.
54
+ * Pulled out so test fixtures can assert the mapping without spinning a server.
55
+ */
56
+ export function apiErrorToCliError(err) {
57
+ switch (err.status) {
58
+ case 401:
59
+ return new CLIError(err.message || "Not authenticated.", EXIT.UNAUTHORIZED, 'Run "iot auth login".');
60
+ case 403:
61
+ return new CLIError(err.message || "Forbidden.", EXIT.FORBIDDEN, "Your API key lacks permission for this resource.");
62
+ case 404: {
63
+ const path = err.method && err.path ? `${err.method} ${err.path}` : err.path ?? "resource";
64
+ return new CLIError(`Not found: ${path}.`, EXIT.NOT_FOUND);
65
+ }
66
+ case 409:
67
+ return new CLIError(err.message, EXIT.CONFLICT);
68
+ case 422: {
69
+ const details = formatValidationErrors(err.errors);
70
+ return new CLIError(details ? `${err.message}\n${details}` : err.message, EXIT.VALIDATION);
71
+ }
72
+ default:
73
+ if (err.status >= 500 && err.status < 600) {
74
+ return new CLIError(`Server error (${err.status}): ${err.message}`, EXIT.SERVER, "Try again or report at https://gitlab.com/manfred-kunze-dev/iot/-/issues.");
75
+ }
76
+ return new CLIError(err.message || `HTTP ${err.status}`, EXIT.GENERIC);
77
+ }
78
+ }
79
+ function formatValidationErrors(errors) {
80
+ if (!errors)
81
+ return undefined;
82
+ if (Array.isArray(errors)) {
83
+ return errors
84
+ .map((e) => (typeof e === "string" ? ` - ${e}` : ` - ${JSON.stringify(e)}`))
85
+ .join("\n");
86
+ }
87
+ if (typeof errors === "object") {
88
+ return Object.entries(errors)
89
+ .map(([field, msg]) => ` - ${field}: ${typeof msg === "string" ? msg : JSON.stringify(msg)}`)
90
+ .join("\n");
91
+ }
92
+ return String(errors);
93
+ }
94
+ /**
95
+ * Detect a generic node-fetch network failure (DNS, refused, timeout).
96
+ * openapi-fetch surfaces these as raw `TypeError` / `Error` instances.
97
+ */
98
+ export function isNetworkError(err) {
99
+ if (!(err instanceof Error))
100
+ return false;
101
+ if (err.name === "AbortError")
102
+ return true;
103
+ const msg = err.message.toLowerCase();
104
+ return (msg.includes("fetch failed") ||
105
+ msg.includes("econnrefused") ||
106
+ msg.includes("enotfound") ||
107
+ msg.includes("eai_again") ||
108
+ msg.includes("etimedout") ||
109
+ msg.includes("network"));
110
+ }
111
+ /**
112
+ * Convert any thrown value into a CLIError. Pure function for testability.
113
+ */
114
+ export function toCliError(err, baseUrl) {
115
+ if (err instanceof CLIError)
116
+ return err;
117
+ if (err instanceof ApiError)
118
+ return apiErrorToCliError(err);
119
+ if (isNetworkError(err)) {
120
+ return new CLIError(`Cannot reach ${baseUrl ?? "the server"}. Check the URL and your connection.`, EXIT.NETWORK);
121
+ }
122
+ if (err instanceof Error) {
123
+ return new CLIError(err.message, EXIT.GENERIC);
124
+ }
125
+ return new CLIError(String(err), EXIT.GENERIC);
126
+ }
127
+ /**
128
+ * Single top-level error handler. Writes to stderr (never stdout) so
129
+ * pipelines like `iot foo --json | jq` keep working even on failure.
130
+ *
131
+ * In JSON mode the error is serialised as
132
+ * { "error": { "code": "...", "message": "...", "status": 401 } }
133
+ */
134
+ export function handleError(err, opts) {
135
+ const cli = toCliError(err, opts.baseUrl);
136
+ if (opts.json) {
137
+ const apiInfo = {};
138
+ if (err instanceof ApiError) {
139
+ apiInfo.status = err.status;
140
+ if (err.code !== undefined)
141
+ apiInfo.code = err.code;
142
+ }
143
+ const payload = {
144
+ error: {
145
+ message: cli.message,
146
+ ...apiInfo,
147
+ ...(cli.suggestion ? { suggestion: cli.suggestion } : {}),
148
+ },
149
+ };
150
+ process.stderr.write(JSON.stringify(payload) + "\n");
151
+ }
152
+ else {
153
+ process.stderr.write(chalk.red(cli.message) + "\n");
154
+ if (cli.suggestion) {
155
+ process.stderr.write(chalk.dim(cli.suggestion) + "\n");
156
+ }
157
+ }
158
+ process.exit(cli.exitCode);
159
+ }
160
+ //# sourceMappingURL=errors.js.map
@@ -0,0 +1,45 @@
1
+ export interface Row {
2
+ [key: string]: unknown;
3
+ }
4
+ export interface Column {
5
+ /** Key into the row to read the value from. */
6
+ key: string;
7
+ /** Display header. Defaults to `key`. */
8
+ header?: string;
9
+ /** Optional formatter for the cell. */
10
+ format?: (value: unknown, row: Row) => string;
11
+ }
12
+ /**
13
+ * Whether interactive output (spinners, colors, prompts) should be active.
14
+ * False under --json, when stdout isn't a TTY, or in CI.
15
+ */
16
+ export declare function isInteractive(opts?: {
17
+ json?: boolean;
18
+ }): boolean;
19
+ export declare function printTable(rows: Row[], columns: Column[]): void;
20
+ /**
21
+ * Render a single record as a two-column key/value table — for "status"
22
+ * style detail screens.
23
+ */
24
+ export declare function printKeyValue(record: Record<string, unknown>): void;
25
+ export declare function printJson(value: unknown): void;
26
+ /**
27
+ * Write a success/info line. Always to stdout when it carries useful info,
28
+ * stderr when it's purely a UX banner. Callers pick.
29
+ */
30
+ export declare function printSuccess(message: string, target?: "stdout" | "stderr"): void;
31
+ export declare function printWarning(message: string): void;
32
+ export declare function printInfo(message: string, target?: "stdout" | "stderr"): void;
33
+ /**
34
+ * Run an async action with an `ora` spinner. The spinner is suppressed
35
+ * automatically when the output isn't interactive (--json, non-TTY, CI) —
36
+ * the action still runs and its result is still returned, just without UI.
37
+ */
38
+ export declare function withSpinner<T>(text: string, action: () => Promise<T>, opts?: {
39
+ json?: boolean;
40
+ }): Promise<T>;
41
+ /** Render a value sensibly: null/undefined → em dash, objects → JSON, rest → string. */
42
+ export declare function formatValue(value: unknown): string;
43
+ /** Last 4 + first 5 chars of an API key, with ellipsis between. */
44
+ export declare function maskKey(key: string): string;
45
+ //# sourceMappingURL=output.d.ts.map
@@ -0,0 +1,104 @@
1
+ import chalk from "chalk";
2
+ import Table from "cli-table3";
3
+ import ora from "ora";
4
+ /**
5
+ * Whether interactive output (spinners, colors, prompts) should be active.
6
+ * False under --json, when stdout isn't a TTY, or in CI.
7
+ */
8
+ export function isInteractive(opts = {}) {
9
+ if (opts.json)
10
+ return false;
11
+ if (process.env.CI)
12
+ return false;
13
+ return process.stdout.isTTY === true;
14
+ }
15
+ /** Standard cli-table3 instance with consistent border style. */
16
+ function newTable(head) {
17
+ return new Table({
18
+ head: head.map((h) => chalk.cyan(h)),
19
+ wordWrap: true,
20
+ style: { head: [], border: ["grey"] },
21
+ });
22
+ }
23
+ export function printTable(rows, columns) {
24
+ if (rows.length === 0) {
25
+ process.stdout.write(chalk.dim("(no rows)\n"));
26
+ return;
27
+ }
28
+ const table = newTable(columns.map((c) => c.header ?? c.key));
29
+ for (const row of rows) {
30
+ table.push(columns.map((c) => {
31
+ const v = row[c.key];
32
+ return c.format ? c.format(v, row) : formatValue(v);
33
+ }));
34
+ }
35
+ process.stdout.write(table.toString() + "\n");
36
+ }
37
+ /**
38
+ * Render a single record as a two-column key/value table — for "status"
39
+ * style detail screens.
40
+ */
41
+ export function printKeyValue(record) {
42
+ const table = new Table({
43
+ style: { head: [], border: ["grey"] },
44
+ });
45
+ for (const [key, value] of Object.entries(record)) {
46
+ table.push({ [chalk.cyan(key)]: formatValue(value) });
47
+ }
48
+ process.stdout.write(table.toString() + "\n");
49
+ }
50
+ export function printJson(value) {
51
+ process.stdout.write(JSON.stringify(value, null, 2) + "\n");
52
+ }
53
+ /**
54
+ * Write a success/info line. Always to stdout when it carries useful info,
55
+ * stderr when it's purely a UX banner. Callers pick.
56
+ */
57
+ export function printSuccess(message, target = "stdout") {
58
+ const stream = target === "stdout" ? process.stdout : process.stderr;
59
+ stream.write(chalk.green(message) + "\n");
60
+ }
61
+ export function printWarning(message) {
62
+ process.stderr.write(chalk.yellow(message) + "\n");
63
+ }
64
+ export function printInfo(message, target = "stderr") {
65
+ const stream = target === "stdout" ? process.stdout : process.stderr;
66
+ stream.write(message + "\n");
67
+ }
68
+ /**
69
+ * Run an async action with an `ora` spinner. The spinner is suppressed
70
+ * automatically when the output isn't interactive (--json, non-TTY, CI) —
71
+ * the action still runs and its result is still returned, just without UI.
72
+ */
73
+ export async function withSpinner(text, action, opts = {}) {
74
+ if (!isInteractive(opts)) {
75
+ return action();
76
+ }
77
+ const spinner = ora({ text, stream: process.stderr }).start();
78
+ try {
79
+ const result = await action();
80
+ spinner.succeed();
81
+ return result;
82
+ }
83
+ catch (err) {
84
+ spinner.fail();
85
+ throw err;
86
+ }
87
+ }
88
+ /** Render a value sensibly: null/undefined → em dash, objects → JSON, rest → string. */
89
+ export function formatValue(value) {
90
+ if (value === null || value === undefined)
91
+ return chalk.dim("—");
92
+ if (value === "")
93
+ return chalk.dim("(empty)");
94
+ if (typeof value === "object")
95
+ return JSON.stringify(value);
96
+ return String(value);
97
+ }
98
+ /** Last 4 + first 5 chars of an API key, with ellipsis between. */
99
+ export function maskKey(key) {
100
+ if (key.length <= 12)
101
+ return key.replace(/./g, "*");
102
+ return `${key.slice(0, 5)}...${key.slice(-4)}`;
103
+ }
104
+ //# sourceMappingURL=output.js.map
@@ -0,0 +1,18 @@
1
+ export interface Updater {
2
+ notify: () => Promise<void> | void;
3
+ }
4
+ /**
5
+ * Check for updates in the background. Fire early so the fetch overlaps
6
+ * with the command itself, then call `notify()` once the command is done
7
+ * to print the (optional) banner.
8
+ *
9
+ * Always silent when:
10
+ * - the version string is a stub/placeholder (no dots)
11
+ * - `--json` mode is set (handled at call site via `opts.json`)
12
+ * - `CI` env is set
13
+ * - `IOT_DISABLE_UPDATE_NOTIFIER` env is set
14
+ */
15
+ export declare function checkForUpdates(currentVersion: string, opts?: {
16
+ json?: boolean;
17
+ }): Updater;
18
+ //# sourceMappingURL=update-notifier.d.ts.map
@@ -0,0 +1,118 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
4
+ import chalk from "chalk";
5
+ const PKG_NAME = "@manfred-kunze-dev/iot-cli";
6
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000;
7
+ const CACHE_DIR = process.env.IOT_CLI_CONFIG_DIR ?? join(homedir(), ".config", "iot-cli");
8
+ const CACHE_FILE = join(CACHE_DIR, "update-check.json");
9
+ function readCache() {
10
+ try {
11
+ return JSON.parse(readFileSync(CACHE_FILE, "utf-8"));
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ }
17
+ function writeCache(data) {
18
+ try {
19
+ mkdirSync(CACHE_DIR, { recursive: true });
20
+ writeFileSync(CACHE_FILE, JSON.stringify(data));
21
+ }
22
+ catch {
23
+ // Cache write failures are non-fatal; we'll just re-check on next run.
24
+ }
25
+ }
26
+ async function fetchLatestVersion(distTag) {
27
+ try {
28
+ const controller = new AbortController();
29
+ const timeout = setTimeout(() => controller.abort(), 3000);
30
+ const res = await fetch(`https://registry.npmjs.org/${PKG_NAME}/${distTag}`, {
31
+ signal: controller.signal,
32
+ });
33
+ clearTimeout(timeout);
34
+ if (!res.ok)
35
+ return null;
36
+ const data = (await res.json());
37
+ return data.version ?? null;
38
+ }
39
+ catch {
40
+ return null;
41
+ }
42
+ }
43
+ function compareVersions(current, latest) {
44
+ const parse = (v) => v.replace(/^v/, "").split(/[.-]/).map((x) => Number(x) || 0);
45
+ const [cMaj = 0, cMin = 0, cPat = 0] = parse(current);
46
+ const [lMaj = 0, lMin = 0, lPat = 0] = parse(latest);
47
+ if (lMaj !== cMaj)
48
+ return lMaj > cMaj;
49
+ if (lMin !== cMin)
50
+ return lMin > cMin;
51
+ return lPat > cPat;
52
+ }
53
+ /**
54
+ * Pick which npm dist-tag to compare against. Pre-release installs
55
+ * (`1.2.3-dev.4`) follow the dev channel; stable installs follow latest.
56
+ */
57
+ function distTagFor(current) {
58
+ return current.includes("-") ? "dev" : "latest";
59
+ }
60
+ /**
61
+ * Check for updates in the background. Fire early so the fetch overlaps
62
+ * with the command itself, then call `notify()` once the command is done
63
+ * to print the (optional) banner.
64
+ *
65
+ * Always silent when:
66
+ * - the version string is a stub/placeholder (no dots)
67
+ * - `--json` mode is set (handled at call site via `opts.json`)
68
+ * - `CI` env is set
69
+ * - `IOT_DISABLE_UPDATE_NOTIFIER` env is set
70
+ */
71
+ export function checkForUpdates(currentVersion, opts = {}) {
72
+ if (opts.json || process.env.CI || process.env.IOT_DISABLE_UPDATE_NOTIFIER) {
73
+ return { notify: () => undefined };
74
+ }
75
+ if (!currentVersion.includes(".")) {
76
+ return { notify: () => undefined };
77
+ }
78
+ const tag = distTagFor(currentVersion);
79
+ let message = null;
80
+ const cache = readCache();
81
+ const now = Date.now();
82
+ if (cache && cache.distTag === tag && now - cache.lastCheck < CHECK_INTERVAL_MS) {
83
+ if (compareVersions(currentVersion, cache.latestVersion)) {
84
+ message = formatMessage(currentVersion, cache.latestVersion, tag);
85
+ }
86
+ return {
87
+ notify: () => {
88
+ if (message)
89
+ process.stderr.write(message + "\n");
90
+ },
91
+ };
92
+ }
93
+ const pending = fetchLatestVersion(tag).then((latest) => {
94
+ if (latest) {
95
+ writeCache({ lastCheck: now, latestVersion: latest, distTag: tag });
96
+ if (compareVersions(currentVersion, latest)) {
97
+ message = formatMessage(currentVersion, latest, tag);
98
+ }
99
+ }
100
+ });
101
+ return {
102
+ notify: async () => {
103
+ await pending;
104
+ if (message)
105
+ process.stderr.write(message + "\n");
106
+ },
107
+ };
108
+ }
109
+ function formatMessage(current, latest, tag) {
110
+ const installCmd = tag === "latest" ? `npm i -g ${PKG_NAME}` : `npm i -g ${PKG_NAME}@${tag}`;
111
+ return [
112
+ "",
113
+ chalk.yellow(`Update available: ${chalk.dim(current)} → ${chalk.green(latest)}`),
114
+ chalk.yellow(`Run ${chalk.cyan(installCmd)} to update`),
115
+ "",
116
+ ].join("\n");
117
+ }
118
+ //# sourceMappingURL=update-notifier.js.map
@@ -0,0 +1,65 @@
1
+ {
2
+ "openapi": "3.0.1",
3
+ "info": {
4
+ "title": "iot platform API (placeholder)",
5
+ "description": "Placeholder OpenAPI spec for the iot CLI. Replace by running `npm run generate` against a backend started with SPRING_PROFILES_ACTIVE=spec.",
6
+ "version": "0.0.0-placeholder"
7
+ },
8
+ "servers": [
9
+ {
10
+ "url": "/",
11
+ "description": "Same-origin"
12
+ }
13
+ ],
14
+ "tags": [
15
+ {
16
+ "name": "devices",
17
+ "description": "Device fleet"
18
+ }
19
+ ],
20
+ "paths": {
21
+ "/v1/devices/summary": {
22
+ "get": {
23
+ "tags": ["devices"],
24
+ "operationId": "getDevicesSummary",
25
+ "summary": "Aggregate counts of devices in the caller's organization. Used by the CLI as a lightweight auth probe.",
26
+ "responses": {
27
+ "200": {
28
+ "description": "OK",
29
+ "content": {
30
+ "application/json": {
31
+ "schema": {
32
+ "$ref": "#/components/schemas/DeviceSummary"
33
+ }
34
+ }
35
+ }
36
+ },
37
+ "401": {
38
+ "description": "Unauthorized"
39
+ }
40
+ }
41
+ }
42
+ }
43
+ },
44
+ "components": {
45
+ "schemas": {
46
+ "DeviceSummary": {
47
+ "type": "object",
48
+ "properties": {
49
+ "total": {
50
+ "type": "integer",
51
+ "format": "int64"
52
+ },
53
+ "online": {
54
+ "type": "integer",
55
+ "format": "int64"
56
+ },
57
+ "offline": {
58
+ "type": "integer",
59
+ "format": "int64"
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ }
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@manfred-kunze-dev/iot-cli",
3
+ "version": "3.0.0-dev.1",
4
+ "description": "Command-line interface for the iot platform by Manfred Kunze Development",
5
+ "type": "module",
6
+ "main": "dist/index.js",
7
+ "bin": {
8
+ "iot": "dist/index.js",
9
+ "mkd-iot": "dist/index.js",
10
+ "mki": "dist/index.js"
11
+ },
12
+ "scripts": {
13
+ "build": "tsc",
14
+ "dev": "tsc --watch",
15
+ "start": "node dist/index.js",
16
+ "generate:spec": "tsx --env-file-if-exists=.env openapi/scripts/fetch-spec.ts",
17
+ "generate:types": "openapi-typescript openapi/openapi.json -o src/generated/openapi.d.ts",
18
+ "generate": "npm run generate:spec && npm run generate:types",
19
+ "check:spec": "tsx --env-file-if-exists=.env openapi/scripts/check-drift.ts",
20
+ "typecheck": "tsc --noEmit",
21
+ "test": "vitest run",
22
+ "test:watch": "vitest"
23
+ },
24
+ "dependencies": {
25
+ "chalk": "^5.6.2",
26
+ "cli-table3": "^0.6.5",
27
+ "commander": "^13.1.0",
28
+ "conf": "^13.1.0",
29
+ "openapi-fetch": "^0.13.5",
30
+ "ora": "^8.2.0"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^22.19.17",
34
+ "msw": "^2.7.0",
35
+ "openapi-typescript": "^7.13.0",
36
+ "tsx": "^4.21.0",
37
+ "typescript": "^5.9.3",
38
+ "vitest": "^3.0.0"
39
+ },
40
+ "engines": {
41
+ "node": ">=20"
42
+ },
43
+ "license": "SEE LICENSE IN LICENSE",
44
+ "publishConfig": {
45
+ "registry": "https://registry.npmjs.org",
46
+ "access": "public"
47
+ },
48
+ "homepage": "https://iot.manfred-kunze.dev",
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://gitlab.com/manfred-kunze-dev/iot.git",
52
+ "directory": "cli"
53
+ },
54
+ "files": [
55
+ "dist/**/*.js",
56
+ "dist/**/*.d.ts",
57
+ "openapi/openapi.json",
58
+ "README.md"
59
+ ]
60
+ }