@jango-blockchained/hoox-cli 0.4.1 → 0.5.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.
- package/package.json +1 -1
- package/src/commands/dashboard/dashboard-command.ts +3 -2
- package/src/commands/deploy/deploy-command.ts +3 -2
- package/src/commands/init/__tests__/cli-provisioner.test.ts +17 -0
- package/src/commands/init/cli-provisioner.ts +71 -0
- package/src/commands/init/init-command.test.ts +109 -60
- package/src/commands/init/init-command.ts +330 -364
- package/src/commands/init/types.ts +6 -168
- package/src/commands/logs/index.ts +1 -0
- package/src/commands/logs/logs-command.test.ts +294 -0
- package/src/commands/logs/logs-command.ts +452 -0
- package/src/commands/update/update-command.ts +143 -26
- package/src/index.ts +20 -0
- package/src/ui/banner.ts +117 -28
- package/src/ui/menu.ts +15 -3
|
@@ -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("
|
|
77
|
+
.summary("Sync repo from GitHub or update wrangler")
|
|
14
78
|
.description(
|
|
15
|
-
`Update
|
|
79
|
+
`Update your local project from GitHub or update wrangler.
|
|
16
80
|
|
|
17
|
-
|
|
18
|
-
|
|
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
|
|
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
|
-
.
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
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);
|