@jango-blockchained/hoox-cli 0.4.0 → 0.5.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,452 @@
1
+ /**
2
+ * `hoox logs` command group — real-time log tailing for Cloudflare Workers.
3
+ *
4
+ * Subcommands:
5
+ * worker <name> — Tail logs for a specific worker
6
+ * all — Tail logs from all enabled workers concurrently
7
+ *
8
+ * Uses Bun.spawn to run `wrangler tail` with streaming output.
9
+ * Supports --level (log/info/warn/error), --follow/--no-follow, and --json flags.
10
+ */
11
+
12
+ import { Command } from "commander";
13
+ import { ConfigService } from "../../services/config/index.js";
14
+ import { CLIError, ExitCode } from "../../utils/errors.js";
15
+ import {
16
+ formatError,
17
+ formatSuccess,
18
+ getFormatOptions,
19
+ } from "../../utils/formatters.js";
20
+ import { theme } from "../../utils/theme.js";
21
+ import type { FormatOptions } from "../../utils/formatters.js";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ /** Valid log levels for filtering */
28
+ const VALID_LEVELS = ["log", "info", "warn", "error", "debug", "all"] as const;
29
+ type LogLevel = (typeof VALID_LEVELS)[number];
30
+
31
+ interface WorkerLogOptions {
32
+ level: LogLevel;
33
+ follow: boolean;
34
+ json: boolean;
35
+ }
36
+
37
+ interface WranglerLogEntry {
38
+ outcome: string;
39
+ scriptName?: string;
40
+ logs?: Array<{
41
+ message: string[];
42
+ level: string;
43
+ timestamp: number;
44
+ }>;
45
+ exceptions?: Array<{
46
+ name: string;
47
+ message: string;
48
+ }>;
49
+ eventTimestamp?: number;
50
+ event?: unknown;
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // Helpers
55
+ // ---------------------------------------------------------------------------
56
+
57
+ /**
58
+ * Validate and normalize the log level string.
59
+ * Defaults to "all" for unknown values.
60
+ */
61
+ function normalizeLevel(raw: string): LogLevel {
62
+ const lower = raw.toLowerCase();
63
+ if (VALID_LEVELS.includes(lower as LogLevel)) {
64
+ return lower as LogLevel;
65
+ }
66
+ return "all";
67
+ }
68
+
69
+ /**
70
+ * Check whether a log entry matches the requested level filter.
71
+ * "all" matches everything; otherwise, the entry's level must equal the filter.
72
+ */
73
+ function matchesLevel(entry: WranglerLogEntry, level: LogLevel): boolean {
74
+ if (level === "all") return true;
75
+ const entryLogs = entry.logs ?? [];
76
+ return entryLogs.some((l) => l.level === level);
77
+ }
78
+
79
+ /**
80
+ * Format a single JSON log line from wrangler for human-readable output.
81
+ * Extracts timestamp, level, and message fields.
82
+ */
83
+ function formatLogLine(entry: WranglerLogEntry): string {
84
+ const timestamp = entry.eventTimestamp
85
+ ? new Date(entry.eventTimestamp).toISOString()
86
+ : new Date().toISOString();
87
+
88
+ const logs = entry.logs ?? [];
89
+ if (logs.length === 0) {
90
+ // Non-log events (e.g. request outcome)
91
+ return theme.dim(`[${timestamp}]`) + ` ${entry.outcome}`;
92
+ }
93
+
94
+ const parts = logs.map((l) => {
95
+ const levelColor =
96
+ l.level === "error"
97
+ ? theme.error
98
+ : l.level === "warn"
99
+ ? theme.warning
100
+ : l.level === "info"
101
+ ? theme.info
102
+ : theme.dim;
103
+ const msg = l.message.join(" ");
104
+ return `${theme.dim(`[${timestamp}]`)} ${levelColor(`[${l.level.toUpperCase()}]`)} ${msg}`;
105
+ });
106
+
107
+ return parts.join("\n");
108
+ }
109
+
110
+ /**
111
+ * Parse a line from wrangler tail --format json output.
112
+ * Returns null if the line is not valid JSON.
113
+ */
114
+ function parseLogLine(line: string): WranglerLogEntry | null {
115
+ const trimmed = line.trim();
116
+ if (!trimmed) return null;
117
+ try {
118
+ return JSON.parse(trimmed) as WranglerLogEntry;
119
+ } catch {
120
+ // Non-JSON lines are passed through as-is (e.g. wrangler status messages)
121
+ return null;
122
+ }
123
+ }
124
+
125
+ // ---------------------------------------------------------------------------
126
+ // Tail implementation
127
+ // ---------------------------------------------------------------------------
128
+
129
+ /**
130
+ * Tail logs for a single worker using wrangler.
131
+ *
132
+ * Spawns `wrangler tail <workerName>` and streams stdout to the terminal.
133
+ * With --follow (default), runs until interrupted (Ctrl+C).
134
+ * With --no-follow, captures output for 5 seconds then exits.
135
+ * With --level, filters JSON log entries by log level.
136
+ * With --json, passes through wrangler's JSON output directly.
137
+ */
138
+ async function tailWorker(
139
+ workerName: string,
140
+ opts: WorkerLogOptions,
141
+ fmt: FormatOptions
142
+ ): Promise<void> {
143
+ const wranglerArgs = ["wrangler", "tail", workerName];
144
+
145
+ // Use JSON format when level filtering or --json output is requested
146
+ if (opts.level !== "all" || opts.json) {
147
+ wranglerArgs.push("--format", "json");
148
+ }
149
+
150
+ let proc: Bun.Subprocess | null = null;
151
+
152
+ try {
153
+ proc = Bun.spawn(wranglerArgs, {
154
+ stdout: "pipe",
155
+ stderr: "pipe",
156
+ });
157
+
158
+ if (!proc.stdout || typeof proc.stdout === "number") {
159
+ throw new CLIError(
160
+ "Failed to get stdout stream from wrangler tail",
161
+ ExitCode.ERROR
162
+ );
163
+ }
164
+
165
+ const reader = (proc.stdout as ReadableStream<Uint8Array>).getReader();
166
+ const decoder = new TextDecoder();
167
+ let buffer = "";
168
+
169
+ const readLoop = async () => {
170
+ while (true) {
171
+ const { done, value } = await reader.read();
172
+ if (done) break;
173
+
174
+ buffer += decoder.decode(value, { stream: true });
175
+
176
+ // Process complete lines
177
+ const lines = buffer.split("\n");
178
+ buffer = lines.pop() ?? ""; // Keep incomplete line in buffer
179
+
180
+ for (const line of lines) {
181
+ if (!line.trim()) continue;
182
+
183
+ if (opts.level !== "all" || opts.json) {
184
+ const entry = parseLogLine(line);
185
+
186
+ if (entry !== null) {
187
+ // JSON parse succeeded
188
+ if (!matchesLevel(entry, opts.level)) continue;
189
+
190
+ if (opts.json) {
191
+ process.stdout.write(JSON.stringify(entry) + "\n");
192
+ } else {
193
+ process.stdout.write(formatLogLine(entry) + "\n");
194
+ }
195
+ } else {
196
+ // Non-JSON line (wrangler status message) — pass through
197
+ if (opts.level === "all") {
198
+ process.stdout.write(line + "\n");
199
+ }
200
+ }
201
+ } else {
202
+ // Raw pass-through mode
203
+ process.stdout.write(line + "\n");
204
+ }
205
+ }
206
+ }
207
+
208
+ // Flush remaining buffer
209
+ if (buffer.trim()) {
210
+ process.stdout.write(buffer + "\n");
211
+ }
212
+ };
213
+
214
+ if (opts.follow) {
215
+ // Continuous streaming — run until interrupted
216
+ await readLoop();
217
+ } else {
218
+ // Show recent and exit after a short timeout
219
+ const timeout = 5000;
220
+ const timeoutPromise = new Promise<void>((resolve) =>
221
+ setTimeout(resolve, timeout)
222
+ );
223
+
224
+ await Promise.race([readLoop(), timeoutPromise]);
225
+
226
+ // Kill the wrangler process after timeout
227
+ if (proc && !proc.killed) {
228
+ proc.kill();
229
+ }
230
+ }
231
+ } catch (err) {
232
+ const message = err instanceof Error ? err.message : String(err);
233
+ formatError(
234
+ new CLIError(
235
+ `Failed to tail logs for "${workerName}": ${message}`,
236
+ ExitCode.ERROR
237
+ ),
238
+ fmt
239
+ );
240
+ process.exitCode = ExitCode.ERROR;
241
+ } finally {
242
+ // Ensure the process is killed on cleanup
243
+ if (proc && !proc.killed) {
244
+ proc.kill();
245
+ }
246
+ }
247
+ }
248
+
249
+ /**
250
+ * Tail logs for all enabled workers concurrently.
251
+ *
252
+ * Loads ConfigService.listEnabledWorkers(), spawns a tail process for each,
253
+ * and prefixes output with the worker name.
254
+ */
255
+ async function tailAllWorkers(
256
+ opts: WorkerLogOptions,
257
+ fmt: FormatOptions
258
+ ): Promise<void> {
259
+ const configService = new ConfigService();
260
+ await configService.load();
261
+ const workers = configService.listEnabledWorkers();
262
+
263
+ if (workers.length === 0) {
264
+ formatSuccess("No enabled workers found to tail", fmt);
265
+ return;
266
+ }
267
+
268
+ const wranglerArgsBase = ["wrangler", "tail"];
269
+ if (opts.level !== "all" || opts.json) {
270
+ wranglerArgsBase.push("--format", "json");
271
+ }
272
+
273
+ const processes: Array<{
274
+ name: string;
275
+ proc: Bun.Subprocess;
276
+ reader: ReadableStreamDefaultReader<Uint8Array>;
277
+ }> = [];
278
+
279
+ try {
280
+ // Spawn a tail process for each worker
281
+ for (const workerName of workers) {
282
+ const proc = Bun.spawn(
283
+ ["wrangler", "tail", workerName, ...wranglerArgsBase.slice(2)],
284
+ {
285
+ stdout: "pipe",
286
+ stderr: "pipe",
287
+ }
288
+ );
289
+ processes.push({
290
+ name: workerName,
291
+ proc,
292
+ reader: proc.stdout.getReader(),
293
+ });
294
+ }
295
+
296
+ // Read from all workers concurrently, prefixing with worker name
297
+ const decoder = new TextDecoder();
298
+ const buffers = new Map<string, string>();
299
+
300
+ const readFromWorker = async (p: (typeof processes)[0]) => {
301
+ let buffer = buffers.get(p.name) ?? "";
302
+ while (true) {
303
+ const { done, value } = await p.reader.read();
304
+ if (done) break;
305
+
306
+ buffer += decoder.decode(value, { stream: true });
307
+ const lines = buffer.split("\n");
308
+ buffer = lines.pop() ?? "";
309
+
310
+ for (const line of lines) {
311
+ if (!line.trim()) continue;
312
+
313
+ const prefix = theme.bold(`[${p.name}]`);
314
+
315
+ if (opts.level !== "all" || opts.json) {
316
+ const entry = parseLogLine(line);
317
+ if (entry !== null) {
318
+ if (!matchesLevel(entry, opts.level)) continue;
319
+ if (opts.json) {
320
+ process.stdout.write(
321
+ JSON.stringify({ worker: p.name, ...entry }) + "\n"
322
+ );
323
+ } else {
324
+ process.stdout.write(`${prefix} ${formatLogLine(entry)}\n`);
325
+ }
326
+ } else if (opts.level === "all") {
327
+ process.stdout.write(`${prefix} ${line}\n`);
328
+ }
329
+ } else {
330
+ process.stdout.write(`${prefix} ${line}\n`);
331
+ }
332
+ }
333
+ }
334
+
335
+ // Flush remaining buffer
336
+ if (buffer.trim()) {
337
+ process.stdout.write(`${theme.bold(`[${p.name}]`)} ${buffer}\n`);
338
+ }
339
+
340
+ buffers.set(p.name, buffer);
341
+ };
342
+
343
+ if (opts.follow) {
344
+ // Run all readers concurrently until interrupted
345
+ await Promise.all(processes.map((p) => readFromWorker(p)));
346
+ } else {
347
+ // Show recent and exit after timeout
348
+ const timeout = 5000;
349
+ const timeoutPromise = new Promise<void>((resolve) =>
350
+ setTimeout(resolve, timeout)
351
+ );
352
+
353
+ await Promise.race([
354
+ Promise.all(processes.map((p) => readFromWorker(p))),
355
+ timeoutPromise,
356
+ ]);
357
+ }
358
+ } catch (err) {
359
+ const message = err instanceof Error ? err.message : String(err);
360
+ formatError(
361
+ new CLIError(`Failed to tail workers: ${message}`, ExitCode.ERROR),
362
+ fmt
363
+ );
364
+ process.exitCode = ExitCode.ERROR;
365
+ } finally {
366
+ // Kill all processes on cleanup
367
+ for (const p of processes) {
368
+ if (!p.proc.killed) {
369
+ p.proc.kill();
370
+ }
371
+ }
372
+ }
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Command registration
377
+ // ---------------------------------------------------------------------------
378
+
379
+ /**
380
+ * Register the `hoox logs` command group with subcommands:
381
+ * worker <name> and all.
382
+ */
383
+ export function registerLogsCommand(program: Command): void {
384
+ const logsCmd = program
385
+ .command("logs")
386
+ .description("Tail and view Cloudflare Worker logs in real-time");
387
+
388
+ // -- logs worker <name> ---------------------------------------------------
389
+ logsCmd
390
+ .command("worker <name>")
391
+ .description("Tail logs for a specific Cloudflare Worker")
392
+ .option(
393
+ "--level <level>",
394
+ "Filter by log level (log, info, warn, error, debug, all)",
395
+ "all"
396
+ )
397
+ .option("--follow", "Continuously stream logs (default)", true)
398
+ .option("--no-follow", "Show recent logs and exit after 5 seconds")
399
+ .option("--json", "Output logs in JSON format")
400
+ .action(
401
+ async (
402
+ name: string,
403
+ options: { level?: string; follow?: boolean; json?: boolean }
404
+ ) => {
405
+ const fmt = getFormatOptions(program);
406
+ const workerOpts: WorkerLogOptions = {
407
+ level: normalizeLevel(options.level ?? "all"),
408
+ follow: options.follow !== false, // --no-follow sets follow to false
409
+ json: Boolean(options.json),
410
+ };
411
+
412
+ try {
413
+ await tailWorker(name, workerOpts, fmt);
414
+ } catch (err) {
415
+ const message = err instanceof Error ? err.message : String(err);
416
+ formatError(message, fmt);
417
+ process.exitCode = ExitCode.ERROR;
418
+ }
419
+ }
420
+ );
421
+
422
+ // -- logs all -------------------------------------------------------------
423
+ logsCmd
424
+ .command("all")
425
+ .description("Tail logs from all enabled workers concurrently")
426
+ .option(
427
+ "--level <level>",
428
+ "Filter by log level (log, info, warn, error, debug, all)",
429
+ "all"
430
+ )
431
+ .option("--follow", "Continuously stream logs (default)", true)
432
+ .option("--no-follow", "Show recent logs and exit after 5 seconds")
433
+ .option("--json", "Output logs in JSON format")
434
+ .action(
435
+ async (options: { level?: string; follow?: boolean; json?: boolean }) => {
436
+ const fmt = getFormatOptions(program);
437
+ const workerOpts: WorkerLogOptions = {
438
+ level: normalizeLevel(options.level ?? "all"),
439
+ follow: options.follow !== false,
440
+ json: Boolean(options.json),
441
+ };
442
+
443
+ try {
444
+ await tailAllWorkers(workerOpts, fmt);
445
+ } catch (err) {
446
+ const message = err instanceof Error ? err.message : String(err);
447
+ formatError(message, fmt);
448
+ process.exitCode = ExitCode.ERROR;
449
+ }
450
+ }
451
+ );
452
+ }
@@ -1,46 +1,163 @@
1
1
  import { Command } from "commander";
2
+ import { spinner } from "@clack/prompts";
3
+ import { ConfigService } from "../../services/config/index.js";
2
4
  import { UpdateService } from "../../services/update/index.js";
3
- import {
4
- formatSuccess,
5
- formatError,
6
- getFormatOptions,
7
- } from "../../utils/formatters.js";
5
+ import { formatError, getFormatOptions } from "../../utils/formatters.js";
8
6
  import { CLIError, ExitCode } from "../../utils/errors.js";
7
+ import { theme } from "../../utils/theme.js";
8
+
9
+ async function gitPull(cwd: string): Promise<string> {
10
+ const proc = Bun.spawn(["git", "pull", "--ff-only"], {
11
+ cwd,
12
+ stdout: "pipe",
13
+ stderr: "pipe",
14
+ });
15
+ const exitCode = await proc.exited;
16
+ const stdout = await new Response(proc.stdout).text();
17
+ const stderr = await new Response(proc.stderr).text();
18
+
19
+ if (exitCode !== 0) {
20
+ throw new Error(stderr.trim() || `git pull failed (exit ${exitCode})`);
21
+ }
22
+ return stdout.trim();
23
+ }
24
+
25
+ async function gitSubmoduleUpdate(
26
+ cwd: string,
27
+ submodulePath: string
28
+ ): Promise<string> {
29
+ const proc = Bun.spawn(
30
+ ["git", "submodule", "update", "--remote", "--init", "--", submodulePath],
31
+ {
32
+ cwd,
33
+ stdout: "pipe",
34
+ stderr: "pipe",
35
+ }
36
+ );
37
+ const exitCode = await proc.exited;
38
+ const stdout = await new Response(proc.stdout).text();
39
+ const stderr = await new Response(proc.stderr).text();
40
+
41
+ if (exitCode !== 0) {
42
+ throw new Error(
43
+ stderr.trim() || `git submodule update failed (exit ${exitCode})`
44
+ );
45
+ }
46
+ return stdout.trim();
47
+ }
48
+
49
+ async function isGitRepo(cwd: string): Promise<boolean> {
50
+ const proc = Bun.spawn(["git", "rev-parse", "--is-inside-work-tree"], {
51
+ cwd,
52
+ stdout: "pipe",
53
+ stderr: "pipe",
54
+ });
55
+ const exitCode = await proc.exited;
56
+ return exitCode === 0;
57
+ }
58
+
59
+ async function isSubmodule(
60
+ cwd: string,
61
+ submodulePath: string
62
+ ): Promise<boolean> {
63
+ const proc = Bun.spawn(["git", "submodule", "status", "--", submodulePath], {
64
+ cwd,
65
+ stdout: "pipe",
66
+ stderr: "pipe",
67
+ });
68
+ const exitCode = await proc.exited;
69
+ if (exitCode !== 0) return false;
70
+ const out = (await new Response(proc.stdout).text()).trim();
71
+ return out.length > 0;
72
+ }
9
73
 
10
74
  export function registerUpdateCommand(program: Command): void {
11
75
  program
12
76
  .command("update")
13
- .summary("Update project dependencies (default: wrangler)")
77
+ .summary("Sync repo from GitHub or update wrangler")
14
78
  .description(
15
- `Update wrangler to the latest available version.
79
+ `Update your local project from GitHub or update wrangler.
16
80
 
17
- Updates wrangler in the project's package.json
18
- via \`bun update wrangler\`.
81
+ Without arguments, pulls latest changes for the main repo.
82
+ With a worker name, updates that worker's submodule from its remote.
83
+ With "wrangler", updates wrangler to the latest version.
19
84
 
20
85
  EXAMPLES:
21
- hoox update Check and update wrangler`
86
+ hoox update Pull latest for main repo
87
+ hoox update d1-worker Update the d1-worker submodule
88
+ hoox update wrangler Update wrangler to latest version`
22
89
  )
23
- .action(async () => {
90
+ .argument(
91
+ "[target]",
92
+ 'What to update: worker name (e.g. d1-worker) or "wrangler"'
93
+ )
94
+ .action(async (target?: string) => {
24
95
  const fmt = getFormatOptions(program);
96
+ const cwd = process.cwd();
97
+
25
98
  try {
26
- const service = new UpdateService();
27
- const result = await service.updateWrangler();
28
-
29
- if (result.updated) {
30
- formatSuccess(
31
- result.newVersion
32
- ? `Wrangler updated to ${result.newVersion}`
33
- : "Wrangler updated",
34
- fmt
99
+ if (target === "wrangler") {
100
+ const service = new UpdateService();
101
+ const result = await service.updateWrangler();
102
+
103
+ if (result.error) {
104
+ formatError(
105
+ new CLIError(`Update failed: ${result.error}`, ExitCode.ERROR),
106
+ fmt
107
+ );
108
+ process.exitCode = ExitCode.ERROR;
109
+ }
110
+ return;
111
+ }
112
+
113
+ if (!(await isGitRepo(cwd))) {
114
+ throw new CLIError(
115
+ "Not a git repository — run this command from the project root",
116
+ ExitCode.INVALID_USAGE
35
117
  );
36
- } else if (result.error) {
37
- formatError(
38
- new CLIError(`Update failed: ${result.error}`, ExitCode.ERROR),
39
- fmt
118
+ }
119
+
120
+ if (target) {
121
+ const configService = new ConfigService();
122
+ await configService.load();
123
+ const worker = configService.getWorker(target);
124
+
125
+ if (!worker) {
126
+ throw new CLIError(
127
+ `Unknown worker "${target}" — not found in wrangler.jsonc`,
128
+ ExitCode.INVALID_USAGE
129
+ );
130
+ }
131
+
132
+ const submodulePath = worker.path;
133
+ if (!(await isSubmodule(cwd, submodulePath))) {
134
+ throw new CLIError(
135
+ `"${submodulePath}" is not a git submodule — nothing to update`,
136
+ ExitCode.INVALID_USAGE
137
+ );
138
+ }
139
+
140
+ const s = spinner();
141
+ s.start(`Updating ${target}...`);
142
+
143
+ const output = await gitSubmoduleUpdate(cwd, submodulePath);
144
+
145
+ s.stop(
146
+ output
147
+ ? theme.success(`Updated ${target}`)
148
+ : theme.muted(`${target} already up to date`)
40
149
  );
41
- process.exitCode = ExitCode.ERROR;
42
150
  } else {
43
- formatSuccess("Wrangler is already up to date", fmt);
151
+ const s = spinner();
152
+ s.start("Pulling latest from remote...");
153
+
154
+ const output = await gitPull(cwd);
155
+
156
+ s.stop(
157
+ output
158
+ ? theme.success("Repository up to date")
159
+ : theme.muted("Already up to date")
160
+ );
44
161
  }
45
162
  } catch (err) {
46
163
  const message = err instanceof Error ? err.message : String(err);
package/src/index.ts CHANGED
@@ -131,6 +131,7 @@ process.on("unhandledRejection", (reason) => {
131
131
  // ---------------------------------------------------------------------------
132
132
 
133
133
  import { registerInitCommand } from "./commands/init/index.js";
134
+ import { WIZARD_STATE_PATH } from "@jango-blockchained/hoox-shared";
134
135
  import { registerDevCommand } from "./commands/dev/index.js";
135
136
  import { registerDeployCommand } from "./commands/deploy/index.js";
136
137
  import { registerInfraCommand } from "./commands/infra/index.js";
@@ -222,6 +223,25 @@ export async function main(): Promise<void> {
222
223
  if (hasArgs) {
223
224
  program.parse();
224
225
  } else {
226
+ // Auto-launch the setup wizard if not yet initialized
227
+ const configFile = Bun.file("wrangler.jsonc");
228
+ const hasConfig = await configFile.exists();
229
+ const wizardStateFile = Bun.file(WIZARD_STATE_PATH);
230
+ const hasWizardState = await wizardStateFile.exists();
231
+
232
+ if (!hasConfig) {
233
+ // No wrangler.jsonc → project is uninitialized → run the wizard
234
+ // If there's a partial wizard state file, auto-resume
235
+ const args = hasWizardState ? ["init", "--resume"] : ["init"];
236
+ await program.parseAsync(args, { from: "user" });
237
+ process.exit(ExitCode.SUCCESS);
238
+ }
239
+
240
+ // Clean up stale wizard state if project is already initialized
241
+ if (hasWizardState) {
242
+ await Bun.write(WIZARD_STATE_PATH, "");
243
+ }
244
+
225
245
  // Interactive TUI mode — launches when hoox is called with no arguments
226
246
  await runInteractiveTUI(program);
227
247
  process.exit(ExitCode.SUCCESS);