@whatalo/cli-kit 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ // src/output/format.ts
2
+ import chalk from "chalk";
3
+ function banner(title, version) {
4
+ console.log();
5
+ console.log(` ${chalk.bold.cyan(title)} ${chalk.dim(`v${version}`)}`);
6
+ console.log();
7
+ }
8
+ function success(message) {
9
+ console.log(` ${chalk.green("\u2713")} ${message}`);
10
+ }
11
+ function error(message) {
12
+ console.log(` ${chalk.red("\u2717")} ${message}`);
13
+ }
14
+ function warn(message) {
15
+ console.log(` ${chalk.yellow("\u26A0")} ${message}`);
16
+ }
17
+ function info(message) {
18
+ console.log(` ${chalk.blue("\u2139")} ${message}`);
19
+ }
20
+ function link(url) {
21
+ return chalk.underline.cyan(url);
22
+ }
23
+ function code(text) {
24
+ return chalk.dim("`") + chalk.bold(text) + chalk.dim("`");
25
+ }
26
+ function table(headers, rows) {
27
+ if (headers.length === 0) return;
28
+ const widths = headers.map(
29
+ (h, i) => Math.max(h.length, ...rows.map((r) => (r[i] ?? "").length))
30
+ );
31
+ const headerLine = headers.map((h, i) => h.padEnd(widths[i] ?? h.length)).join(" ");
32
+ console.log(` ${chalk.bold(headerLine)}`);
33
+ const separator = widths.map((w) => "\u2500".repeat(w)).join(" ");
34
+ console.log(` ${separator}`);
35
+ for (const row of rows) {
36
+ const line = row.map((cell, i) => cell.padEnd(widths[i] ?? cell.length)).join(" ");
37
+ console.log(` ${line}`);
38
+ }
39
+ }
40
+ var STATUS_ICONS = {
41
+ pending: chalk.dim("\u25CB"),
42
+ running: chalk.cyan("\u25C9"),
43
+ success: chalk.green("\u2713"),
44
+ error: chalk.red("\u2717"),
45
+ warning: chalk.yellow("\u26A0"),
46
+ skipped: chalk.dim("\u2013")
47
+ };
48
+ function renderTasks(tasks) {
49
+ for (const task of tasks) {
50
+ const icon = STATUS_ICONS[task.status];
51
+ const label = task.status === "error" ? chalk.red(task.label) : task.status === "warning" ? chalk.yellow(task.label) : task.status === "skipped" ? chalk.dim(task.label) : task.label;
52
+ console.log(` ${icon} ${label}`);
53
+ if (task.detail) {
54
+ console.log(` ${chalk.dim(task.detail)}`);
55
+ }
56
+ }
57
+ }
58
+ function renderInfoPanel(title, sections) {
59
+ console.log();
60
+ console.log(` ${chalk.bold(title)}`);
61
+ console.log(` ${"\u2550".repeat(title.length)}`);
62
+ for (const section of sections) {
63
+ console.log();
64
+ console.log(` ${chalk.bold.dim(section.heading)}`);
65
+ const maxKeyLen = Math.max(...section.rows.map((r) => r.key.length));
66
+ for (const row of section.rows) {
67
+ console.log(
68
+ ` ${chalk.dim(row.key.padEnd(maxKeyLen))} ${row.value}`
69
+ );
70
+ }
71
+ }
72
+ console.log();
73
+ }
74
+ function renderTable(options) {
75
+ table(options.headers, options.rows);
76
+ }
77
+
78
+ // src/output/spinner.ts
79
+ import chalk2 from "chalk";
80
+ var SPINNER_FRAMES = ["\u25D2", "\u25D0", "\u25D3", "\u25D1"];
81
+ var FRAME_INTERVAL_MS = 100;
82
+ function createSpinner(message) {
83
+ let frameIndex = 0;
84
+ let stopped = false;
85
+ const timer = setInterval(() => {
86
+ if (stopped) return;
87
+ const frame = SPINNER_FRAMES[frameIndex % SPINNER_FRAMES.length];
88
+ process.stdout.write(`\x1B[2K\r ${chalk2.magenta(frame)} ${message}`);
89
+ frameIndex++;
90
+ }, FRAME_INTERVAL_MS);
91
+ const firstFrame = SPINNER_FRAMES[0];
92
+ process.stdout.write(` ${chalk2.magenta(firstFrame)} ${message}`);
93
+ return {
94
+ stop(finalMessage) {
95
+ if (stopped) return;
96
+ stopped = true;
97
+ clearInterval(timer);
98
+ process.stdout.write(`\x1B[2K\r`);
99
+ if (finalMessage) {
100
+ console.log(` ${chalk2.green("\u2713")} ${finalMessage}`);
101
+ }
102
+ }
103
+ };
104
+ }
105
+
106
+ // src/output/errors.ts
107
+ var WhataloAuthError = class extends Error {
108
+ constructor(message = "Authentication required") {
109
+ super(message);
110
+ this.name = "WhataloAuthError";
111
+ }
112
+ };
113
+ var WhataloConfigError = class extends Error {
114
+ suggestion;
115
+ constructor(message, suggestion) {
116
+ super(message);
117
+ this.name = "WhataloConfigError";
118
+ this.suggestion = suggestion;
119
+ }
120
+ };
121
+ var WhataloNetworkError = class extends Error {
122
+ statusCode;
123
+ constructor(message, statusCode) {
124
+ super(message);
125
+ this.name = "WhataloNetworkError";
126
+ this.statusCode = statusCode;
127
+ }
128
+ };
129
+ var WhataloValidationError = class extends Error {
130
+ field;
131
+ constructor(message, field) {
132
+ super(message);
133
+ this.name = "WhataloValidationError";
134
+ this.field = field;
135
+ }
136
+ };
137
+
138
+ // src/output/error-handler.ts
139
+ function withErrorHandler(fn) {
140
+ return async (...args) => {
141
+ try {
142
+ await fn(...args);
143
+ } catch (err) {
144
+ handleCliError(err);
145
+ }
146
+ };
147
+ }
148
+ function handleCliError(error2) {
149
+ if (error2 instanceof WhataloAuthError) {
150
+ error(error2.message);
151
+ info("Run `whatalo login` to re-authenticate.");
152
+ process.exit(1);
153
+ }
154
+ if (error2 instanceof WhataloConfigError) {
155
+ error(error2.message);
156
+ if (error2.suggestion) {
157
+ info(`Fix: ${error2.suggestion}`);
158
+ }
159
+ process.exit(2);
160
+ }
161
+ if (error2 instanceof WhataloNetworkError) {
162
+ error("Could not connect to Whatalo API.");
163
+ if (error2.statusCode === 429) {
164
+ warn("Rate limit reached. Wait a moment and try again.");
165
+ } else {
166
+ info("Check your internet connection and try again.");
167
+ }
168
+ if (error2.message && error2.message !== "Could not connect to Whatalo API.") {
169
+ info(`Details: ${error2.message}`);
170
+ }
171
+ process.exit(1);
172
+ }
173
+ if (error2 instanceof WhataloValidationError) {
174
+ error(error2.message);
175
+ if (error2.field) {
176
+ info(`Field: ${error2.field}`);
177
+ }
178
+ process.exit(1);
179
+ }
180
+ error("An unexpected error occurred.");
181
+ if (error2 instanceof Error) {
182
+ info(`Details: ${error2.message}`);
183
+ }
184
+ info(
185
+ "If this persists, run `whatalo info --json` and report the issue."
186
+ );
187
+ process.exit(1);
188
+ }
189
+
190
+ // src/output/non-tty.ts
191
+ function failMissingNonTTYFlags(requiredFlags, options) {
192
+ if (process.stdout.isTTY) return;
193
+ const missing = requiredFlags.filter((flag) => !options[flag]);
194
+ if (missing.length > 0) {
195
+ throw new WhataloValidationError(
196
+ `${missing.map((f) => `--${f}`).join(", ")} required in non-interactive environments (CI/CD).`,
197
+ missing[0]
198
+ );
199
+ }
200
+ }
201
+ export {
202
+ WhataloAuthError,
203
+ WhataloConfigError,
204
+ WhataloNetworkError,
205
+ WhataloValidationError,
206
+ banner,
207
+ code,
208
+ createSpinner,
209
+ error,
210
+ failMissingNonTTYFlags,
211
+ handleCliError,
212
+ info,
213
+ link,
214
+ renderInfoPanel,
215
+ renderTable,
216
+ renderTasks,
217
+ success,
218
+ table,
219
+ warn,
220
+ withErrorHandler
221
+ };
@@ -0,0 +1,184 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/session/index.ts
31
+ var session_exports = {};
32
+ __export(session_exports, {
33
+ POLL_STATUS: () => POLL_STATUS,
34
+ clearSession: () => clearSession,
35
+ getSession: () => getSession,
36
+ getSessionDir: () => getSessionDir,
37
+ isSessionValid: () => isSessionValid,
38
+ pollForToken: () => pollForToken,
39
+ refreshAccessToken: () => refreshAccessToken,
40
+ requestDeviceCode: () => requestDeviceCode,
41
+ saveSession: () => saveSession
42
+ });
43
+ module.exports = __toCommonJS(session_exports);
44
+
45
+ // src/session/store.ts
46
+ var import_promises = __toESM(require("fs/promises"), 1);
47
+ var import_node_os = __toESM(require("os"), 1);
48
+ var import_node_path = __toESM(require("path"), 1);
49
+ function getSessionDir() {
50
+ return import_node_path.default.join(import_node_os.default.homedir(), ".whatalo");
51
+ }
52
+ function getSessionPath() {
53
+ return import_node_path.default.join(getSessionDir(), "session.json");
54
+ }
55
+ async function saveSession(session) {
56
+ const dir = getSessionDir();
57
+ const filePath = getSessionPath();
58
+ await import_promises.default.mkdir(dir, { recursive: true, mode: 448 });
59
+ await import_promises.default.writeFile(filePath, JSON.stringify(session, null, 2), {
60
+ encoding: "utf-8",
61
+ mode: 384
62
+ });
63
+ await import_promises.default.chmod(filePath, 384);
64
+ }
65
+ async function getSession() {
66
+ try {
67
+ const raw = await import_promises.default.readFile(getSessionPath(), { encoding: "utf-8" });
68
+ return JSON.parse(raw);
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+ async function clearSession() {
74
+ try {
75
+ await import_promises.default.unlink(getSessionPath());
76
+ } catch (err) {
77
+ const error = err;
78
+ if (error.code !== "ENOENT") throw err;
79
+ }
80
+ }
81
+ function isSessionValid(session) {
82
+ const expiresAt = new Date(session.expiresAt).getTime();
83
+ const now = Date.now();
84
+ const SKEW_BUFFER_MS = 6e4;
85
+ return expiresAt - SKEW_BUFFER_MS > now;
86
+ }
87
+
88
+ // src/session/types.ts
89
+ var POLL_STATUS = {
90
+ PENDING: "pending",
91
+ AUTHORIZED: "authorized",
92
+ EXPIRED: "expired",
93
+ DENIED: "denied"
94
+ };
95
+
96
+ // src/session/device-flow.ts
97
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
98
+ async function requestDeviceCode(portalUrl) {
99
+ const res = await fetch(`${portalUrl}/api/auth/device-code`, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json" },
102
+ body: JSON.stringify({ clientId: "whatalo-cli" }),
103
+ signal: AbortSignal.timeout(3e4)
104
+ });
105
+ if (!res.ok) {
106
+ const body = await res.text().catch(() => "Unknown error");
107
+ throw new Error(`Failed to request device code (${res.status}): ${body}`);
108
+ }
109
+ return await res.json();
110
+ }
111
+ async function* pollForToken(portalUrl, deviceCode, initialInterval) {
112
+ let interval = Math.max(initialInterval, 5);
113
+ while (true) {
114
+ await sleep(interval * 1e3);
115
+ const res = await fetch(`${portalUrl}/api/auth/device-token`, {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({
119
+ deviceCode,
120
+ grantType: "urn:ietf:params:oauth:grant-type:device_code"
121
+ }),
122
+ signal: AbortSignal.timeout(3e4)
123
+ });
124
+ if (res.ok) {
125
+ const data = await res.json();
126
+ yield { status: POLL_STATUS.AUTHORIZED, token: data };
127
+ return;
128
+ }
129
+ let errorCode = "unknown";
130
+ try {
131
+ const errorBody = await res.json();
132
+ errorCode = errorBody.error ?? "unknown";
133
+ } catch {
134
+ yield { status: POLL_STATUS.EXPIRED };
135
+ return;
136
+ }
137
+ switch (errorCode) {
138
+ case "authorization_pending":
139
+ yield { status: POLL_STATUS.PENDING };
140
+ break;
141
+ case "slow_down":
142
+ interval += 5;
143
+ yield { status: POLL_STATUS.PENDING };
144
+ break;
145
+ case "expired_token":
146
+ yield { status: POLL_STATUS.EXPIRED };
147
+ return;
148
+ case "access_denied":
149
+ yield { status: POLL_STATUS.DENIED };
150
+ return;
151
+ default:
152
+ yield { status: POLL_STATUS.EXPIRED };
153
+ return;
154
+ }
155
+ }
156
+ }
157
+ async function refreshAccessToken(portalUrl, refreshToken) {
158
+ const res = await fetch(`${portalUrl}/api/auth/refresh`, {
159
+ method: "POST",
160
+ headers: { "Content-Type": "application/json" },
161
+ body: JSON.stringify({
162
+ refreshToken,
163
+ grantType: "refresh_token"
164
+ }),
165
+ signal: AbortSignal.timeout(3e4)
166
+ });
167
+ if (!res.ok) {
168
+ const body = await res.text().catch(() => "Unknown error");
169
+ throw new Error(`Failed to refresh token (${res.status}): ${body}`);
170
+ }
171
+ return await res.json();
172
+ }
173
+ // Annotate the CommonJS export names for ESM import in node:
174
+ 0 && (module.exports = {
175
+ POLL_STATUS,
176
+ clearSession,
177
+ getSession,
178
+ getSessionDir,
179
+ isSessionValid,
180
+ pollForToken,
181
+ refreshAccessToken,
182
+ requestDeviceCode,
183
+ saveSession
184
+ });
@@ -0,0 +1,82 @@
1
+ import { W as WhataloSession, D as DeviceCodeResponse, P as PollResult, T as TokenResponse } from '../types-DunvRQ0f.cjs';
2
+ export { b as POLL_STATUS, a as PollStatus } from '../types-DunvRQ0f.cjs';
3
+
4
+ /**
5
+ * Persistent session store.
6
+ * Reads and writes ~/.whatalo/session.json with strict file-system permissions.
7
+ *
8
+ * Security contract:
9
+ * - Directory: chmod 700 (owner read/write/execute only)
10
+ * - File: chmod 600 (owner read/write only)
11
+ */
12
+
13
+ /** Returns the path to the ~/.whatalo config directory */
14
+ declare function getSessionDir(): string;
15
+ /**
16
+ * Persists a session to disk.
17
+ * Creates the ~/.whatalo directory if it does not exist, then writes the session
18
+ * file with restricted permissions so no other OS user can read the tokens.
19
+ */
20
+ declare function saveSession(session: WhataloSession): Promise<void>;
21
+ /**
22
+ * Reads the persisted session from disk.
23
+ * Returns null if the file is missing, unreadable, or contains invalid JSON —
24
+ * never throws, so callers can simply check for null.
25
+ */
26
+ declare function getSession(): Promise<WhataloSession | null>;
27
+ /**
28
+ * Removes the session file from disk.
29
+ * Silently ignores ENOENT so callers can call this unconditionally.
30
+ */
31
+ declare function clearSession(): Promise<void>;
32
+ /**
33
+ * Returns true if the session exists and the access token has not expired.
34
+ * A 60-second clock skew buffer is applied to avoid edge-case failures.
35
+ */
36
+ declare function isSessionValid(session: WhataloSession): boolean;
37
+
38
+ /**
39
+ * Client-side implementation of RFC 8628 — OAuth 2.0 Device Authorization Grant.
40
+ *
41
+ * This module only performs the CLIENT half of the flow:
42
+ * 1. Request a device code from the portal's /api/auth/device-code endpoint.
43
+ * 2. Poll /api/auth/device-token as an AsyncGenerator until the user authorizes
44
+ * or the code expires.
45
+ * 3. Refresh an expired access token using /api/auth/refresh.
46
+ *
47
+ * The server-side implementation lives in the Developer Portal app.
48
+ */
49
+
50
+ /**
51
+ * Initiates the Device Authorization Grant by requesting a device code.
52
+ *
53
+ * @param portalUrl - Base URL of the Developer Portal (e.g. https://developers.whatalo.com)
54
+ * @returns DeviceCodeResponse containing the user code and verification URI to display
55
+ */
56
+ declare function requestDeviceCode(portalUrl: string): Promise<DeviceCodeResponse>;
57
+ /**
58
+ * Polls the token endpoint until the user authorizes, denies, or the code expires.
59
+ *
60
+ * This is an AsyncGenerator — the caller drives the loop and can cancel at any time
61
+ * by breaking out. Each yielded value is a PollResult discriminated union.
62
+ *
63
+ * RFC 8628 compliance:
64
+ * - Respects the server-mandated `interval` (minimum 5 seconds)
65
+ * - Handles `slow_down` by increasing interval by 5 seconds
66
+ * - Stops and returns on terminal states (authorized / expired / denied)
67
+ *
68
+ * @param portalUrl - Base URL of the Developer Portal
69
+ * @param deviceCode - Device code obtained from requestDeviceCode()
70
+ * @param initialInterval - Polling interval in seconds (from DeviceCodeResponse)
71
+ */
72
+ declare function pollForToken(portalUrl: string, deviceCode: string, initialInterval: number): AsyncGenerator<PollResult>;
73
+ /**
74
+ * Exchanges a refresh token for a new access token.
75
+ *
76
+ * @param portalUrl - Base URL of the Developer Portal
77
+ * @param refreshToken - Refresh token from the current session
78
+ * @returns New TokenResponse with a fresh access token
79
+ */
80
+ declare function refreshAccessToken(portalUrl: string, refreshToken: string): Promise<TokenResponse>;
81
+
82
+ export { DeviceCodeResponse, PollResult, TokenResponse, WhataloSession, clearSession, getSession, getSessionDir, isSessionValid, pollForToken, refreshAccessToken, requestDeviceCode, saveSession };
@@ -0,0 +1,82 @@
1
+ import { W as WhataloSession, D as DeviceCodeResponse, P as PollResult, T as TokenResponse } from '../types-DunvRQ0f.js';
2
+ export { b as POLL_STATUS, a as PollStatus } from '../types-DunvRQ0f.js';
3
+
4
+ /**
5
+ * Persistent session store.
6
+ * Reads and writes ~/.whatalo/session.json with strict file-system permissions.
7
+ *
8
+ * Security contract:
9
+ * - Directory: chmod 700 (owner read/write/execute only)
10
+ * - File: chmod 600 (owner read/write only)
11
+ */
12
+
13
+ /** Returns the path to the ~/.whatalo config directory */
14
+ declare function getSessionDir(): string;
15
+ /**
16
+ * Persists a session to disk.
17
+ * Creates the ~/.whatalo directory if it does not exist, then writes the session
18
+ * file with restricted permissions so no other OS user can read the tokens.
19
+ */
20
+ declare function saveSession(session: WhataloSession): Promise<void>;
21
+ /**
22
+ * Reads the persisted session from disk.
23
+ * Returns null if the file is missing, unreadable, or contains invalid JSON —
24
+ * never throws, so callers can simply check for null.
25
+ */
26
+ declare function getSession(): Promise<WhataloSession | null>;
27
+ /**
28
+ * Removes the session file from disk.
29
+ * Silently ignores ENOENT so callers can call this unconditionally.
30
+ */
31
+ declare function clearSession(): Promise<void>;
32
+ /**
33
+ * Returns true if the session exists and the access token has not expired.
34
+ * A 60-second clock skew buffer is applied to avoid edge-case failures.
35
+ */
36
+ declare function isSessionValid(session: WhataloSession): boolean;
37
+
38
+ /**
39
+ * Client-side implementation of RFC 8628 — OAuth 2.0 Device Authorization Grant.
40
+ *
41
+ * This module only performs the CLIENT half of the flow:
42
+ * 1. Request a device code from the portal's /api/auth/device-code endpoint.
43
+ * 2. Poll /api/auth/device-token as an AsyncGenerator until the user authorizes
44
+ * or the code expires.
45
+ * 3. Refresh an expired access token using /api/auth/refresh.
46
+ *
47
+ * The server-side implementation lives in the Developer Portal app.
48
+ */
49
+
50
+ /**
51
+ * Initiates the Device Authorization Grant by requesting a device code.
52
+ *
53
+ * @param portalUrl - Base URL of the Developer Portal (e.g. https://developers.whatalo.com)
54
+ * @returns DeviceCodeResponse containing the user code and verification URI to display
55
+ */
56
+ declare function requestDeviceCode(portalUrl: string): Promise<DeviceCodeResponse>;
57
+ /**
58
+ * Polls the token endpoint until the user authorizes, denies, or the code expires.
59
+ *
60
+ * This is an AsyncGenerator — the caller drives the loop and can cancel at any time
61
+ * by breaking out. Each yielded value is a PollResult discriminated union.
62
+ *
63
+ * RFC 8628 compliance:
64
+ * - Respects the server-mandated `interval` (minimum 5 seconds)
65
+ * - Handles `slow_down` by increasing interval by 5 seconds
66
+ * - Stops and returns on terminal states (authorized / expired / denied)
67
+ *
68
+ * @param portalUrl - Base URL of the Developer Portal
69
+ * @param deviceCode - Device code obtained from requestDeviceCode()
70
+ * @param initialInterval - Polling interval in seconds (from DeviceCodeResponse)
71
+ */
72
+ declare function pollForToken(portalUrl: string, deviceCode: string, initialInterval: number): AsyncGenerator<PollResult>;
73
+ /**
74
+ * Exchanges a refresh token for a new access token.
75
+ *
76
+ * @param portalUrl - Base URL of the Developer Portal
77
+ * @param refreshToken - Refresh token from the current session
78
+ * @returns New TokenResponse with a fresh access token
79
+ */
80
+ declare function refreshAccessToken(portalUrl: string, refreshToken: string): Promise<TokenResponse>;
81
+
82
+ export { DeviceCodeResponse, PollResult, TokenResponse, WhataloSession, clearSession, getSession, getSessionDir, isSessionValid, pollForToken, refreshAccessToken, requestDeviceCode, saveSession };