@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,139 @@
1
+ // src/session/store.ts
2
+ import fs from "fs/promises";
3
+ import os from "os";
4
+ import path from "path";
5
+ function getSessionDir() {
6
+ return path.join(os.homedir(), ".whatalo");
7
+ }
8
+ function getSessionPath() {
9
+ return path.join(getSessionDir(), "session.json");
10
+ }
11
+ async function saveSession(session) {
12
+ const dir = getSessionDir();
13
+ const filePath = getSessionPath();
14
+ await fs.mkdir(dir, { recursive: true, mode: 448 });
15
+ await fs.writeFile(filePath, JSON.stringify(session, null, 2), {
16
+ encoding: "utf-8",
17
+ mode: 384
18
+ });
19
+ await fs.chmod(filePath, 384);
20
+ }
21
+ async function getSession() {
22
+ try {
23
+ const raw = await fs.readFile(getSessionPath(), { encoding: "utf-8" });
24
+ return JSON.parse(raw);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+ async function clearSession() {
30
+ try {
31
+ await fs.unlink(getSessionPath());
32
+ } catch (err) {
33
+ const error = err;
34
+ if (error.code !== "ENOENT") throw err;
35
+ }
36
+ }
37
+ function isSessionValid(session) {
38
+ const expiresAt = new Date(session.expiresAt).getTime();
39
+ const now = Date.now();
40
+ const SKEW_BUFFER_MS = 6e4;
41
+ return expiresAt - SKEW_BUFFER_MS > now;
42
+ }
43
+
44
+ // src/session/types.ts
45
+ var POLL_STATUS = {
46
+ PENDING: "pending",
47
+ AUTHORIZED: "authorized",
48
+ EXPIRED: "expired",
49
+ DENIED: "denied"
50
+ };
51
+
52
+ // src/session/device-flow.ts
53
+ var sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
54
+ async function requestDeviceCode(portalUrl) {
55
+ const res = await fetch(`${portalUrl}/api/auth/device-code`, {
56
+ method: "POST",
57
+ headers: { "Content-Type": "application/json" },
58
+ body: JSON.stringify({ clientId: "whatalo-cli" }),
59
+ signal: AbortSignal.timeout(3e4)
60
+ });
61
+ if (!res.ok) {
62
+ const body = await res.text().catch(() => "Unknown error");
63
+ throw new Error(`Failed to request device code (${res.status}): ${body}`);
64
+ }
65
+ return await res.json();
66
+ }
67
+ async function* pollForToken(portalUrl, deviceCode, initialInterval) {
68
+ let interval = Math.max(initialInterval, 5);
69
+ while (true) {
70
+ await sleep(interval * 1e3);
71
+ const res = await fetch(`${portalUrl}/api/auth/device-token`, {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({
75
+ deviceCode,
76
+ grantType: "urn:ietf:params:oauth:grant-type:device_code"
77
+ }),
78
+ signal: AbortSignal.timeout(3e4)
79
+ });
80
+ if (res.ok) {
81
+ const data = await res.json();
82
+ yield { status: POLL_STATUS.AUTHORIZED, token: data };
83
+ return;
84
+ }
85
+ let errorCode = "unknown";
86
+ try {
87
+ const errorBody = await res.json();
88
+ errorCode = errorBody.error ?? "unknown";
89
+ } catch {
90
+ yield { status: POLL_STATUS.EXPIRED };
91
+ return;
92
+ }
93
+ switch (errorCode) {
94
+ case "authorization_pending":
95
+ yield { status: POLL_STATUS.PENDING };
96
+ break;
97
+ case "slow_down":
98
+ interval += 5;
99
+ yield { status: POLL_STATUS.PENDING };
100
+ break;
101
+ case "expired_token":
102
+ yield { status: POLL_STATUS.EXPIRED };
103
+ return;
104
+ case "access_denied":
105
+ yield { status: POLL_STATUS.DENIED };
106
+ return;
107
+ default:
108
+ yield { status: POLL_STATUS.EXPIRED };
109
+ return;
110
+ }
111
+ }
112
+ }
113
+ async function refreshAccessToken(portalUrl, refreshToken) {
114
+ const res = await fetch(`${portalUrl}/api/auth/refresh`, {
115
+ method: "POST",
116
+ headers: { "Content-Type": "application/json" },
117
+ body: JSON.stringify({
118
+ refreshToken,
119
+ grantType: "refresh_token"
120
+ }),
121
+ signal: AbortSignal.timeout(3e4)
122
+ });
123
+ if (!res.ok) {
124
+ const body = await res.text().catch(() => "Unknown error");
125
+ throw new Error(`Failed to refresh token (${res.status}): ${body}`);
126
+ }
127
+ return await res.json();
128
+ }
129
+ export {
130
+ POLL_STATUS,
131
+ clearSession,
132
+ getSession,
133
+ getSessionDir,
134
+ isSessionValid,
135
+ pollForToken,
136
+ refreshAccessToken,
137
+ requestDeviceCode,
138
+ saveSession
139
+ };
@@ -0,0 +1,252 @@
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/tunnel/index.ts
31
+ var tunnel_exports = {};
32
+ __export(tunnel_exports, {
33
+ createTunnel: () => createTunnel,
34
+ ensureCloudflared: () => ensureCloudflared
35
+ });
36
+ module.exports = __toCommonJS(tunnel_exports);
37
+
38
+ // src/tunnel/cloudflared.ts
39
+ var import_node_child_process = require("child_process");
40
+ var import_node_fs = require("fs");
41
+ var import_promises = require("fs/promises");
42
+ var import_node_path = __toESM(require("path"), 1);
43
+ var import_node_os = __toESM(require("os"), 1);
44
+ var import_node_https = __toESM(require("https"), 1);
45
+
46
+ // src/output/format.ts
47
+ var import_chalk = __toESM(require("chalk"), 1);
48
+ function info(message) {
49
+ console.log(` ${import_chalk.default.blue("\u2139")} ${message}`);
50
+ }
51
+ var STATUS_ICONS = {
52
+ pending: import_chalk.default.dim("\u25CB"),
53
+ running: import_chalk.default.cyan("\u25C9"),
54
+ success: import_chalk.default.green("\u2713"),
55
+ error: import_chalk.default.red("\u2717"),
56
+ warning: import_chalk.default.yellow("\u26A0"),
57
+ skipped: import_chalk.default.dim("\u2013")
58
+ };
59
+
60
+ // src/tunnel/cloudflared.ts
61
+ var TUNNEL_START_TIMEOUT_MS = 15e3;
62
+ var TUNNEL_URL_REGEX = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
63
+ function getBinDir() {
64
+ return import_node_path.default.join(import_node_os.default.homedir(), ".whatalo", "bin");
65
+ }
66
+ function getManagedBinaryPath() {
67
+ return import_node_path.default.join(getBinDir(), "cloudflared");
68
+ }
69
+ function findOnSystemPath() {
70
+ const cmd = process.platform === "win32" ? "where" : "which";
71
+ const result = (0, import_node_child_process.spawnSync)(cmd, ["cloudflared"], { encoding: "utf-8" });
72
+ if (result.status === 0 && result.stdout.trim()) {
73
+ return result.stdout.trim().split("\n")[0] ?? null;
74
+ }
75
+ return null;
76
+ }
77
+ async function ensureCloudflared() {
78
+ const systemPath = findOnSystemPath();
79
+ if (systemPath) return systemPath;
80
+ const managedPath = getManagedBinaryPath();
81
+ if ((0, import_node_fs.existsSync)(managedPath)) return managedPath;
82
+ return downloadCloudflared(managedPath);
83
+ }
84
+ function resolvePlatformInfo() {
85
+ const platform = process.platform;
86
+ const arch = process.arch === "x64" ? "amd64" : process.arch;
87
+ if (platform === "darwin") {
88
+ return { os: "darwin", arch, ext: "tgz" };
89
+ }
90
+ if (platform === "linux") {
91
+ return { os: "linux", arch, ext: "binary" };
92
+ }
93
+ throw new Error(
94
+ `Unsupported platform: ${platform}. Install cloudflared manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/`
95
+ );
96
+ }
97
+ function buildDownloadUrl(osName, arch, ext) {
98
+ const base = "https://github.com/cloudflare/cloudflared/releases/latest/download";
99
+ if (ext === "tgz") {
100
+ return `${base}/cloudflared-${osName}-${arch}.tgz`;
101
+ }
102
+ return `${base}/cloudflared-${osName}-${arch}`;
103
+ }
104
+ async function downloadFile(url, dest) {
105
+ return new Promise((resolve, reject) => {
106
+ const follow = (targetUrl) => {
107
+ import_node_https.default.get(targetUrl, (res) => {
108
+ if ((res.statusCode === 301 || res.statusCode === 302) && res.headers.location) {
109
+ follow(res.headers.location);
110
+ return;
111
+ }
112
+ if (res.statusCode !== 200) {
113
+ reject(
114
+ new Error(
115
+ `Download failed with HTTP ${res.statusCode}: ${targetUrl}`
116
+ )
117
+ );
118
+ return;
119
+ }
120
+ const file = (0, import_node_fs.createWriteStream)(dest);
121
+ res.pipe(file);
122
+ file.on("finish", () => file.close(() => resolve()));
123
+ file.on("error", (err) => {
124
+ file.close();
125
+ reject(err);
126
+ });
127
+ }).on("error", reject);
128
+ };
129
+ follow(url);
130
+ });
131
+ }
132
+ async function downloadCloudflared(targetPath) {
133
+ const { os: osName, arch, ext } = resolvePlatformInfo();
134
+ const downloadUrl = buildDownloadUrl(osName, arch, ext);
135
+ (0, import_node_fs.mkdirSync)(import_node_path.default.dirname(targetPath), { recursive: true });
136
+ info(`Downloading cloudflared for ${osName}/${arch}\u2026`);
137
+ info(`Source: ${downloadUrl}`);
138
+ const tmpPath = `${targetPath}.tmp`;
139
+ try {
140
+ await downloadFile(downloadUrl, tmpPath);
141
+ if (ext === "tgz") {
142
+ const tarResult = (0, import_node_child_process.spawnSync)(
143
+ "tar",
144
+ ["xzf", tmpPath, "-C", import_node_path.default.dirname(targetPath), "cloudflared"],
145
+ { encoding: "utf-8" }
146
+ );
147
+ if (tarResult.status !== 0) {
148
+ throw new Error(
149
+ `tar extraction failed: ${tarResult.stderr || (tarResult.error?.message ?? "unknown error")}`
150
+ );
151
+ }
152
+ await (0, import_promises.unlink)(tmpPath).catch(() => void 0);
153
+ } else {
154
+ await (0, import_promises.rename)(tmpPath, targetPath);
155
+ }
156
+ if (!(0, import_node_fs.existsSync)(targetPath)) {
157
+ throw new Error(
158
+ "Binary extraction completed but cloudflared was not found at the expected path."
159
+ );
160
+ }
161
+ (0, import_node_fs.chmodSync)(targetPath, 493);
162
+ } catch (err) {
163
+ await (0, import_promises.unlink)(tmpPath).catch(() => void 0);
164
+ throw new Error(
165
+ `Could not download cloudflared. Install manually: https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/
166
+ Original error: ${err.message}`
167
+ );
168
+ }
169
+ info(`cloudflared saved to ${targetPath}`);
170
+ return targetPath;
171
+ }
172
+ async function createTunnel(options) {
173
+ const { localPort, protocol = "http" } = options;
174
+ const binaryPath = await ensureCloudflared();
175
+ return new Promise((resolve, reject) => {
176
+ const child = (0, import_node_child_process.spawn)(
177
+ binaryPath,
178
+ [
179
+ "tunnel",
180
+ "--url",
181
+ `${protocol}://localhost:${localPort}`,
182
+ "--no-autoupdate"
183
+ ],
184
+ {
185
+ // Inherit environment so cloudflared can read system certificates
186
+ env: { ...process.env },
187
+ // stdout flows to the terminal; stderr is captured for URL extraction
188
+ stdio: ["ignore", "inherit", "pipe"]
189
+ }
190
+ );
191
+ let urlFound = false;
192
+ let lineBuffer = "";
193
+ const timeout = setTimeout(() => {
194
+ if (!urlFound) {
195
+ child.kill("SIGTERM");
196
+ reject(
197
+ new Error(
198
+ `Tunnel failed to start. Check if port ${localPort} is accessible.`
199
+ )
200
+ );
201
+ }
202
+ }, TUNNEL_START_TIMEOUT_MS);
203
+ child.stderr?.on("data", (chunk) => {
204
+ lineBuffer += chunk.toString("utf-8");
205
+ const lines = lineBuffer.split("\n");
206
+ lineBuffer = lines.pop() ?? "";
207
+ for (const line of lines) {
208
+ const match = TUNNEL_URL_REGEX.exec(line);
209
+ if (match && !urlFound) {
210
+ urlFound = true;
211
+ clearTimeout(timeout);
212
+ const tunnelUrl = match[0];
213
+ const kill = () => new Promise((res) => {
214
+ if (child.exitCode !== null) {
215
+ res();
216
+ return;
217
+ }
218
+ child.once("exit", () => res());
219
+ child.kill("SIGTERM");
220
+ });
221
+ resolve({ url: tunnelUrl, process: child, kill });
222
+ }
223
+ }
224
+ });
225
+ child.on("error", (err) => {
226
+ clearTimeout(timeout);
227
+ if (!urlFound) {
228
+ reject(
229
+ new Error(
230
+ `Tunnel failed to start. Check if port ${localPort} is accessible.
231
+ Original error: ${err.message}`
232
+ )
233
+ );
234
+ }
235
+ });
236
+ child.on("exit", (code) => {
237
+ clearTimeout(timeout);
238
+ if (!urlFound) {
239
+ reject(
240
+ new Error(
241
+ code != null && code !== 0 ? `Could not extract tunnel URL. Try with \`--tunnel-url\` flag.` : `Tunnel failed to start. Check if port ${localPort} is accessible.`
242
+ )
243
+ );
244
+ }
245
+ });
246
+ });
247
+ }
248
+ // Annotate the CommonJS export names for ESM import in node:
249
+ 0 && (module.exports = {
250
+ createTunnel,
251
+ ensureCloudflared
252
+ });
@@ -0,0 +1,70 @@
1
+ import { ChildProcess } from 'node:child_process';
2
+
3
+ /** Options for creating a local tunnel */
4
+ interface TunnelOptions {
5
+ /** Local port to expose */
6
+ localPort: number;
7
+ /** Protocol to use for the tunnel connection (default: "http") */
8
+ protocol?: "http" | "https";
9
+ }
10
+ /**
11
+ * Represents a running tunnel process.
12
+ * Consumers must call kill() when done to avoid orphaned processes.
13
+ */
14
+ interface TunnelProcess {
15
+ /** The public HTTPS URL assigned by the tunnel provider */
16
+ url: string;
17
+ /** Reference to the underlying child process */
18
+ process: ChildProcess;
19
+ /** Sends SIGTERM to the process and waits for it to exit */
20
+ kill(): Promise<void>;
21
+ }
22
+ /** Progress info emitted while downloading the tunnel binary */
23
+ interface TunnelDownloadProgress {
24
+ platform: string;
25
+ arch: string;
26
+ downloadUrl: string;
27
+ targetPath: string;
28
+ }
29
+
30
+ /**
31
+ * Cloudflare tunnel integration for local development.
32
+ *
33
+ * Provides two main functions:
34
+ * - ensureCloudflared(): resolves or downloads the cloudflared binary
35
+ * - createTunnel(): spawns a tunnel and resolves the public URL
36
+ *
37
+ * Binary resolution order:
38
+ * 1. System PATH (user's global install, resolved via `which`/`where`)
39
+ * 2. ~/.whatalo/bin/cloudflared (previously downloaded by the CLI)
40
+ * 3. Download from GitHub releases → ~/.whatalo/bin/cloudflared
41
+ *
42
+ * Note: process-spawning helpers use spawnSync / spawn (not exec/execSync)
43
+ * to avoid shell interpolation entirely — all arguments are passed as an
44
+ * array and never concatenated into a shell string.
45
+ */
46
+
47
+ /**
48
+ * Resolves the path to a usable cloudflared binary.
49
+ *
50
+ * 1. Checks the system PATH
51
+ * 2. Checks ~/.whatalo/bin/cloudflared
52
+ * 3. Downloads the latest release binary for the current platform/arch
53
+ *
54
+ * @returns Absolute path to the cloudflared executable
55
+ * @throws If the binary cannot be found or downloaded
56
+ */
57
+ declare function ensureCloudflared(): Promise<string>;
58
+ /**
59
+ * Spawns a cloudflared quick tunnel targeting the given local port.
60
+ *
61
+ * Streams stderr line-by-line to detect the assigned public URL.
62
+ * Resolves once the URL is found or rejects after TUNNEL_START_TIMEOUT_MS.
63
+ *
64
+ * @param options - localPort is required; protocol defaults to "http"
65
+ * @returns A TunnelProcess containing the public URL and a kill() helper
66
+ * @throws If the process exits before printing a URL, or if startup times out
67
+ */
68
+ declare function createTunnel(options: TunnelOptions): Promise<TunnelProcess>;
69
+
70
+ export { type TunnelDownloadProgress, type TunnelOptions, type TunnelProcess, createTunnel, ensureCloudflared };
@@ -0,0 +1,70 @@
1
+ import { ChildProcess } from 'node:child_process';
2
+
3
+ /** Options for creating a local tunnel */
4
+ interface TunnelOptions {
5
+ /** Local port to expose */
6
+ localPort: number;
7
+ /** Protocol to use for the tunnel connection (default: "http") */
8
+ protocol?: "http" | "https";
9
+ }
10
+ /**
11
+ * Represents a running tunnel process.
12
+ * Consumers must call kill() when done to avoid orphaned processes.
13
+ */
14
+ interface TunnelProcess {
15
+ /** The public HTTPS URL assigned by the tunnel provider */
16
+ url: string;
17
+ /** Reference to the underlying child process */
18
+ process: ChildProcess;
19
+ /** Sends SIGTERM to the process and waits for it to exit */
20
+ kill(): Promise<void>;
21
+ }
22
+ /** Progress info emitted while downloading the tunnel binary */
23
+ interface TunnelDownloadProgress {
24
+ platform: string;
25
+ arch: string;
26
+ downloadUrl: string;
27
+ targetPath: string;
28
+ }
29
+
30
+ /**
31
+ * Cloudflare tunnel integration for local development.
32
+ *
33
+ * Provides two main functions:
34
+ * - ensureCloudflared(): resolves or downloads the cloudflared binary
35
+ * - createTunnel(): spawns a tunnel and resolves the public URL
36
+ *
37
+ * Binary resolution order:
38
+ * 1. System PATH (user's global install, resolved via `which`/`where`)
39
+ * 2. ~/.whatalo/bin/cloudflared (previously downloaded by the CLI)
40
+ * 3. Download from GitHub releases → ~/.whatalo/bin/cloudflared
41
+ *
42
+ * Note: process-spawning helpers use spawnSync / spawn (not exec/execSync)
43
+ * to avoid shell interpolation entirely — all arguments are passed as an
44
+ * array and never concatenated into a shell string.
45
+ */
46
+
47
+ /**
48
+ * Resolves the path to a usable cloudflared binary.
49
+ *
50
+ * 1. Checks the system PATH
51
+ * 2. Checks ~/.whatalo/bin/cloudflared
52
+ * 3. Downloads the latest release binary for the current platform/arch
53
+ *
54
+ * @returns Absolute path to the cloudflared executable
55
+ * @throws If the binary cannot be found or downloaded
56
+ */
57
+ declare function ensureCloudflared(): Promise<string>;
58
+ /**
59
+ * Spawns a cloudflared quick tunnel targeting the given local port.
60
+ *
61
+ * Streams stderr line-by-line to detect the assigned public URL.
62
+ * Resolves once the URL is found or rejects after TUNNEL_START_TIMEOUT_MS.
63
+ *
64
+ * @param options - localPort is required; protocol defaults to "http"
65
+ * @returns A TunnelProcess containing the public URL and a kill() helper
66
+ * @throws If the process exits before printing a URL, or if startup times out
67
+ */
68
+ declare function createTunnel(options: TunnelOptions): Promise<TunnelProcess>;
69
+
70
+ export { type TunnelDownloadProgress, type TunnelOptions, type TunnelProcess, createTunnel, ensureCloudflared };