laracrew 0.1.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/dist/index.js ADDED
@@ -0,0 +1,3524 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli/program.ts
4
+ import { Command } from "commander";
5
+
6
+ // src/core/config/errors.ts
7
+ var ConfigError = class extends Error {
8
+ file;
9
+ details;
10
+ constructor(message, opts = {}) {
11
+ super(message, opts.cause !== void 0 ? { cause: opts.cause } : void 0);
12
+ this.name = "ConfigError";
13
+ this.file = opts.file;
14
+ this.details = opts.details ?? [];
15
+ }
16
+ /** Multi-line, terminal-ready rendering. */
17
+ format() {
18
+ const head = this.file ? `${this.file}
19
+ ${this.message}` : this.message;
20
+ if (this.details.length === 0) return head;
21
+ return `${head}
22
+ ${this.details.map((d) => ` - ${d}`).join("\n")}`;
23
+ }
24
+ };
25
+ var levenshtein = (a, b) => {
26
+ const rows = Array.from({ length: a.length + 1 }, (_, i) => [i, ...Array(b.length).fill(0)]);
27
+ for (let j = 0; j <= b.length; j++) rows[0][j] = j;
28
+ for (let i = 1; i <= a.length; i++) {
29
+ for (let j = 1; j <= b.length; j++) {
30
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
31
+ rows[i][j] = Math.min(rows[i - 1][j] + 1, rows[i][j - 1] + 1, rows[i - 1][j - 1] + cost);
32
+ }
33
+ }
34
+ return rows[a.length][b.length];
35
+ };
36
+ var didYouMean = (input, candidates) => {
37
+ let best;
38
+ for (const candidate of candidates) {
39
+ const distance = levenshtein(input.toLowerCase(), candidate.toLowerCase());
40
+ if (!best || distance < best.distance) best = { word: candidate, distance };
41
+ }
42
+ if (!best) return void 0;
43
+ const threshold = Math.max(2, Math.floor(input.length / 3));
44
+ return best.distance <= threshold ? best.word : void 0;
45
+ };
46
+ var pathOf = (issue) => issue.path.length === 0 ? "(root)" : issue.path.map((segment, index) => typeof segment === "number" ? `[${segment}]` : index === 0 ? segment : `.${segment}`).join("");
47
+ var formatZodIssues = (error, knownKeys = []) => error.issues.map((issue) => {
48
+ if (issue.code === "unrecognized_keys") {
49
+ return issue.keys.map((key) => {
50
+ const suggestion = didYouMean(key, knownKeys);
51
+ const at = pathOf(issue);
52
+ const where = at === "(root)" ? "" : ` at ${at}`;
53
+ return suggestion ? `unknown field "${key}"${where} \u2014 did you mean "${suggestion}"?` : `unknown field "${key}"${where}`;
54
+ }).join("; ");
55
+ }
56
+ return `${pathOf(issue)}: ${issue.message}`;
57
+ });
58
+ var KNOWN_SERVICE_KEYS = [
59
+ "name",
60
+ "project",
61
+ "cmd",
62
+ "cwd",
63
+ "env",
64
+ "groups",
65
+ "needs",
66
+ "ready",
67
+ "restart",
68
+ "backoff",
69
+ "stop",
70
+ "watch",
71
+ "metrics",
72
+ "url",
73
+ "external",
74
+ "autostart",
75
+ "enabled",
76
+ "color"
77
+ ];
78
+ var KNOWN_STACK_KEYS = [
79
+ "name",
80
+ "description",
81
+ "command",
82
+ "use",
83
+ "projects",
84
+ "defaults",
85
+ "services",
86
+ "profiles",
87
+ "hooks"
88
+ ];
89
+ var ALL_KNOWN_KEYS = [...KNOWN_SERVICE_KEYS, ...KNOWN_STACK_KEYS];
90
+
91
+ // src/core/config/load.ts
92
+ import { readFileSync, readdirSync, existsSync, statSync } from "fs";
93
+ import path2 from "path";
94
+ import YAML from "yaml";
95
+
96
+ // src/core/config/paths.ts
97
+ import { homedir } from "os";
98
+ import path from "path";
99
+ var home = (env = process.env) => env.LARACREW_HOME ? path.resolve(env.LARACREW_HOME) : path.join(homedir(), ".laracrew");
100
+ var paths = (env = process.env) => {
101
+ const root = home(env);
102
+ return {
103
+ root,
104
+ configFile: path.join(root, "config.yaml"),
105
+ projectsFile: path.join(root, "projects.yaml"),
106
+ stacksDir: path.join(root, "stacks"),
107
+ tasksDir: path.join(root, "tasks"),
108
+ fragmentsDir: path.join(root, "fragments"),
109
+ logsDir: path.join(root, "logs"),
110
+ runDir: path.join(root, "run")
111
+ };
112
+ };
113
+ var stackDir = (name, env = process.env) => path.join(paths(env).stacksDir, name);
114
+ var stackFile = (name, env = process.env) => path.join(stackDir(name, env), "stack.yaml");
115
+
116
+ // src/core/config/schema.ts
117
+ import { z } from "zod";
118
+ var restartPolicy = z.enum(["never", "on-failure", "always"]);
119
+ var watchStrategy = z.enum(["restart", "artisan-queue-restart", "none"]);
120
+ var readyProbeSchema = z.object({
121
+ tcp: z.string().optional(),
122
+ // "127.0.0.1:6379"
123
+ http: z.string().url().optional(),
124
+ logMatch: z.string().optional(),
125
+ // regex source
126
+ delayMs: z.number().int().nonnegative().optional(),
127
+ timeoutMs: z.number().int().positive().default(3e4),
128
+ intervalMs: z.number().int().positive().default(250)
129
+ }).strict().refine((p) => Boolean(p.tcp ?? p.http ?? p.logMatch ?? p.delayMs !== void 0), {
130
+ message: "a `ready` probe needs one of: tcp, http, logMatch, delayMs"
131
+ });
132
+ var backoffSchema = z.object({
133
+ initialMs: z.number().int().positive().default(1e3),
134
+ maxMs: z.number().int().positive().default(3e4),
135
+ factor: z.number().positive().default(2),
136
+ maxRestarts: z.number().int().nonnegative().default(10),
137
+ /** Stay up this long and the attempt counter resets, so a slow leak can't exhaust it. */
138
+ resetAfterMs: z.number().int().positive().default(6e4)
139
+ }).strict();
140
+ var stopPolicySchema = z.object({
141
+ /** Laravel-native graceful shutdown, e.g. "queue:restart" or "horizon:terminate". */
142
+ artisan: z.string().optional(),
143
+ signal: z.string().default("SIGTERM"),
144
+ graceMs: z.number().int().positive().default(1e4)
145
+ }).strict();
146
+ var watchSchema = z.object({
147
+ paths: z.array(z.string()).min(1),
148
+ ignore: z.array(z.string()).default([]),
149
+ strategy: watchStrategy.default("restart"),
150
+ debounceMs: z.number().int().nonnegative().default(800)
151
+ }).strict();
152
+ var metricsSchema = z.object({
153
+ queues: z.array(z.string()).default([]),
154
+ streams: z.array(z.object({ key: z.string(), group: z.string() }).strict()).default([])
155
+ }).strict();
156
+ var commandSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
157
+ var serviceSchema = z.object({
158
+ name: z.string().min(1),
159
+ project: z.string().optional(),
160
+ cmd: commandSchema.optional(),
161
+ cwd: z.string().optional(),
162
+ env: z.record(z.string()).default({}),
163
+ groups: z.array(z.string()).default([]),
164
+ needs: z.array(z.string()).default([]),
165
+ ready: readyProbeSchema.optional(),
166
+ restart: restartPolicy.optional(),
167
+ backoff: backoffSchema.partial().optional(),
168
+ stop: stopPolicySchema.partial().optional(),
169
+ watch: watchSchema.optional(),
170
+ metrics: metricsSchema.optional(),
171
+ url: z.string().optional(),
172
+ /** Health-checked but never spawned: Redis, MySQL, a Docker service. */
173
+ external: z.boolean().default(false),
174
+ /** false = defined but not launched; press `s` in the TUI to start it when you need it. */
175
+ autostart: z.boolean().default(true),
176
+ enabled: z.boolean().default(true),
177
+ color: z.string().optional()
178
+ }).strict().refine((s) => s.external || s.cmd !== void 0, {
179
+ message: "a service needs a `cmd` unless it is marked `external: true`"
180
+ });
181
+ var projectSchema = z.object({
182
+ path: z.string().min(1),
183
+ php: z.string().default("php"),
184
+ envFile: z.string().default(".env"),
185
+ color: z.string().optional()
186
+ }).strict();
187
+ var projectsFileSchema = z.object({ projects: z.record(projectSchema).default({}) }).strict();
188
+ var profileSchema = z.object({
189
+ only: z.array(z.string()).default([]),
190
+ except: z.array(z.string()).default([])
191
+ }).strict();
192
+ var stackDefaultsSchema = z.object({
193
+ restart: restartPolicy.default("on-failure"),
194
+ backoff: backoffSchema.partial().default({}),
195
+ stop: stopPolicySchema.partial().default({}),
196
+ logs: z.object({
197
+ /** Lines kept in memory per service, for the live log view. */
198
+ maxLines: z.number().int().positive().default(5e3),
199
+ /**
200
+ * On by default: an in-memory buffer holds minutes, and losing the exception you
201
+ * just watched scroll past is the fastest way to stop trusting the tool.
202
+ */
203
+ toFile: z.boolean().default(true),
204
+ /** Rotate a service's log once it passes this size. */
205
+ maxFileBytes: z.number().int().positive().default(5e6),
206
+ /** How many rotated files to keep beside the current one. */
207
+ keepFiles: z.number().int().nonnegative().default(1)
208
+ }).strict().default({})
209
+ }).strict();
210
+ var stackSchema = z.object({
211
+ name: z.string().min(1),
212
+ description: z.string().optional(),
213
+ /** The global command name `laracrew link` installs for this stack. Defaults to `name`. */
214
+ command: z.string().min(1).optional(),
215
+ /** Project keys pulled in from ~/.laracrew/projects.yaml. */
216
+ use: z.array(z.string()).default([]),
217
+ /** Projects declared inline; merged over the shared ones. */
218
+ projects: z.record(projectSchema).default({}),
219
+ defaults: stackDefaultsSchema.default({}),
220
+ services: z.array(serviceSchema).min(1),
221
+ profiles: z.record(profileSchema).default({}),
222
+ hooks: z.object({ preUp: z.string().nullish(), postDown: z.string().nullish() }).strict().default({})
223
+ }).strict();
224
+ var globalConfigSchema = z.object({
225
+ theme: z.enum(["auto", "dark", "light", "mono", "high-contrast"]).default("auto"),
226
+ editor: z.string().optional(),
227
+ defaultStack: z.string().optional(),
228
+ metricsIntervalMs: z.number().int().positive().default(2e3)
229
+ }).strict();
230
+ var taskStepSchema = z.lazy(
231
+ () => z.union([
232
+ z.object({
233
+ project: z.string().optional(),
234
+ cwd: z.string().optional(),
235
+ cmd: commandSchema,
236
+ continueOnError: z.boolean().default(false)
237
+ }).strict(),
238
+ z.object({ parallel: z.array(taskStepSchema).min(1) }).strict()
239
+ ])
240
+ );
241
+ var taskSchema = z.object({
242
+ name: z.string().min(1),
243
+ description: z.string().optional(),
244
+ pauseServices: z.array(z.string()).default([]),
245
+ steps: z.array(taskStepSchema).min(1)
246
+ }).strict();
247
+
248
+ // src/core/config/load.ts
249
+ var parseYaml = (source, file) => {
250
+ try {
251
+ return YAML.parse(source, { prettyErrors: true }) ?? {};
252
+ } catch (error) {
253
+ const message = error instanceof Error ? error.message : String(error);
254
+ throw new ConfigError(`invalid YAML: ${message}`, { file, cause: error });
255
+ }
256
+ };
257
+ var readYamlFile = (file) => {
258
+ let source;
259
+ try {
260
+ source = readFileSync(file, "utf8");
261
+ } catch (error) {
262
+ if (error.code === "ENOENT") {
263
+ throw new ConfigError(`file not found`, { file, cause: error });
264
+ }
265
+ throw error;
266
+ }
267
+ return parseYaml(source, file);
268
+ };
269
+ var validate = (schema, value, file) => {
270
+ const result = schema.safeParse(value);
271
+ if (result.success) return result.data;
272
+ throw new ConfigError("this file does not match the laracrew schema", {
273
+ file,
274
+ details: formatZodIssues(result.error, ALL_KNOWN_KEYS)
275
+ });
276
+ };
277
+ var loadGlobalConfig = (env = process.env) => {
278
+ const file = paths(env).configFile;
279
+ if (!existsSync(file)) return globalConfigSchema.parse({});
280
+ return validate(globalConfigSchema, readYamlFile(file), file);
281
+ };
282
+ var loadSharedProjects = (env = process.env) => {
283
+ const file = paths(env).projectsFile;
284
+ if (!existsSync(file)) return {};
285
+ return validate(projectsFileSchema, readYamlFile(file), file).projects;
286
+ };
287
+ var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
288
+ var mergeServiceFragments = (document, dir) => {
289
+ const servicesDir = path2.join(dir, "services");
290
+ if (!existsSync(servicesDir) || !statSync(servicesDir).isDirectory()) return document;
291
+ if (!isRecord(document)) return document;
292
+ const extra = [];
293
+ for (const entry of readdirSync(servicesDir).sort()) {
294
+ if (!/\.ya?ml$/i.test(entry)) continue;
295
+ const file = path2.join(servicesDir, entry);
296
+ const fragment = readYamlFile(file);
297
+ if (Array.isArray(fragment)) {
298
+ extra.push(...fragment);
299
+ } else if (isRecord(fragment) && Array.isArray(fragment.services)) {
300
+ extra.push(...fragment.services);
301
+ } else {
302
+ throw new ConfigError("a service fragment must be a list of services, or { services: [...] }", { file });
303
+ }
304
+ }
305
+ if (extra.length === 0) return document;
306
+ const existing = Array.isArray(document.services) ? document.services : [];
307
+ return { ...document, services: [...existing, ...extra] };
308
+ };
309
+ var loadStack = (name, env = process.env) => {
310
+ const dir = stackDir(name, env);
311
+ const file = stackFile(name, env);
312
+ if (!existsSync(file)) {
313
+ const available = listStacks(env);
314
+ const hint = available.length > 0 ? ` Available: ${available.join(", ")}.` : " Run `laracrew init` first.";
315
+ throw new ConfigError(`no stack named "${name}".${hint}`, { file });
316
+ }
317
+ const document = mergeServiceFragments(readYamlFile(file), dir);
318
+ const config = validate(stackSchema, document, file);
319
+ if (config.name !== name) {
320
+ throw new ConfigError(
321
+ `stack folder is "${name}" but \`name:\` says "${config.name}" \u2014 they must match`,
322
+ { file }
323
+ );
324
+ }
325
+ return { config, file, dir };
326
+ };
327
+ var listStacks = (env = process.env) => {
328
+ const dir = paths(env).stacksDir;
329
+ if (!existsSync(dir)) return [];
330
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(path2.join(dir, entry.name, "stack.yaml"))).map((entry) => entry.name).sort();
331
+ };
332
+ var listTasks = (env = process.env) => {
333
+ const dir = paths(env).tasksDir;
334
+ if (!existsSync(dir)) return [];
335
+ return readdirSync(dir).filter((entry) => /\.ya?ml$/i.test(entry)).map((entry) => entry.replace(/\.ya?ml$/i, "")).sort();
336
+ };
337
+
338
+ // src/cli/render/colors.ts
339
+ var CODES = {
340
+ reset: 0,
341
+ bold: 1,
342
+ dim: 2,
343
+ italic: 3,
344
+ underline: 4,
345
+ black: 30,
346
+ red: 31,
347
+ green: 32,
348
+ yellow: 33,
349
+ blue: 34,
350
+ magenta: 35,
351
+ cyan: 36,
352
+ white: 37,
353
+ gray: 90
354
+ };
355
+ var supportsColor = (stream = process.stdout, env = process.env) => {
356
+ if (env.NO_COLOR !== void 0 && env.NO_COLOR !== "") return false;
357
+ if (env.FORCE_COLOR !== void 0 && env.FORCE_COLOR !== "0") return true;
358
+ if (env.TERM === "dumb") return false;
359
+ return Boolean(stream.isTTY);
360
+ };
361
+ var createPainter = (enabled) => {
362
+ const paint2 = ((name, text) => enabled ? `\x1B[${CODES[name]}m${text}\x1B[${CODES.reset}m` : text);
363
+ paint2.enabled = enabled;
364
+ return paint2;
365
+ };
366
+ var paint = createPainter(supportsColor());
367
+ var asColorName = (value) => value && value in CODES ? value : "cyan";
368
+ var stripAnsi = (text) => text.replace(/\[[0-9;]*m/g, "");
369
+ var padEnd = (text, width) => {
370
+ const visible = stripAnsi(text).length;
371
+ return visible >= width ? text : text + " ".repeat(width - visible);
372
+ };
373
+
374
+ // src/cli/commands/doctor.ts
375
+ import { existsSync as existsSync4 } from "fs";
376
+ import path7 from "path";
377
+
378
+ // src/core/health/probes.ts
379
+ import net from "net";
380
+ var tcpProbe = (target, timeoutMs = 2e3) => new Promise((resolve) => {
381
+ const lastColon = target.lastIndexOf(":");
382
+ const host = lastColon === -1 ? "127.0.0.1" : target.slice(0, lastColon) || "127.0.0.1";
383
+ const port = Number(lastColon === -1 ? target : target.slice(lastColon + 1));
384
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
385
+ resolve({ ok: false, detail: `"${target}" is not host:port` });
386
+ return;
387
+ }
388
+ const socket = net.connect({ host, port });
389
+ const finish = (result) => {
390
+ socket.removeAllListeners();
391
+ socket.destroy();
392
+ resolve(result);
393
+ };
394
+ socket.setTimeout(timeoutMs);
395
+ socket.once("connect", () => finish({ ok: true }));
396
+ socket.once("timeout", () => finish({ ok: false, detail: "connect timed out" }));
397
+ socket.once("error", (error) => finish({ ok: false, detail: error.code ?? error.message }));
398
+ });
399
+ var httpProbe = async (url, timeoutMs = 2e3) => {
400
+ try {
401
+ const response = await fetch(url, {
402
+ signal: AbortSignal.timeout(timeoutMs),
403
+ redirect: "manual",
404
+ headers: { "user-agent": "laracrew" }
405
+ });
406
+ if (response.status >= 500) return { ok: false, detail: `HTTP ${response.status}` };
407
+ return { ok: true, detail: `HTTP ${response.status}` };
408
+ } catch (error) {
409
+ return { ok: false, detail: error instanceof Error ? error.message : String(error) };
410
+ }
411
+ };
412
+ var isPortFree = (port, host = "127.0.0.1") => new Promise((resolve) => {
413
+ const server = net.createServer();
414
+ server.once("error", () => resolve(false));
415
+ server.once("listening", () => server.close(() => resolve(true)));
416
+ server.listen(port, host);
417
+ });
418
+ var waitFor = async (probe, options) => {
419
+ const deadline = Date.now() + options.timeoutMs;
420
+ let last = { ok: false, detail: "not attempted" };
421
+ while (Date.now() < deadline) {
422
+ if (options.signal?.aborted) return { ok: false, detail: "aborted" };
423
+ last = await probe();
424
+ if (last.ok) return last;
425
+ const remaining = deadline - Date.now();
426
+ if (remaining <= 0) break;
427
+ await sleep(Math.min(options.intervalMs, remaining), options.signal);
428
+ }
429
+ return { ok: false, detail: last.detail ?? "timed out" };
430
+ };
431
+ var sleep = (ms, signal) => new Promise((resolve) => {
432
+ if (signal?.aborted) {
433
+ resolve();
434
+ return;
435
+ }
436
+ const timer = setTimeout(() => {
437
+ signal?.removeEventListener("abort", onAbort);
438
+ resolve();
439
+ }, ms);
440
+ const onAbort = () => {
441
+ clearTimeout(timer);
442
+ resolve();
443
+ };
444
+ signal?.addEventListener("abort", onAbort, { once: true });
445
+ });
446
+ var describeProbe = (probe) => {
447
+ if (probe.http) return `http ${probe.http}`;
448
+ if (probe.tcp) return `tcp ${probe.tcp}`;
449
+ if (probe.logMatch) return `log /${probe.logMatch}/`;
450
+ if (probe.delayMs !== void 0) return `wait ${probe.delayMs}ms`;
451
+ return "spawn";
452
+ };
453
+
454
+ // src/core/process/spawn.ts
455
+ import { spawn } from "child_process";
456
+ var IS_WINDOWS = process.platform === "win32";
457
+ var SHIM_NAMES = /* @__PURE__ */ new Set(["npm", "npx", "yarn", "pnpm", "bun", "composer"]);
458
+ var needsShell = (file) => {
459
+ if (!IS_WINDOWS) return false;
460
+ const base = file.replace(/\\/g, "/").split("/").pop() ?? file;
461
+ if (/\.(cmd|bat)$/i.test(base)) return true;
462
+ return SHIM_NAMES.has(base.replace(/\.[^.]+$/, "").toLowerCase());
463
+ };
464
+ var describeCommand = (command) => command.kind === "shell" ? command.line : [command.file, ...command.args].map((part) => part.includes(" ") ? `"${part}"` : part).join(" ");
465
+ var buildEnv = (service, base = process.env) => ({
466
+ ...base,
467
+ ...service.env,
468
+ LARACREW: "1",
469
+ LARACREW_SERVICE: service.name,
470
+ ...service.project ? { LARACREW_PROJECT: service.project.key } : {}
471
+ });
472
+ var spawnService = (service, base = process.env) => {
473
+ const command = service.command;
474
+ if (!command) throw new Error(`service "${service.name}" has no command to run`);
475
+ const options = {
476
+ cwd: service.cwd,
477
+ env: buildEnv(service, base),
478
+ stdio: ["ignore", "pipe", "pipe"],
479
+ windowsHide: true,
480
+ // POSIX: a new process group means one kill(-pid) takes the whole tree down.
481
+ // Windows has no equivalent, so stop.ts uses `taskkill /T` there instead.
482
+ detached: !IS_WINDOWS
483
+ };
484
+ const child = command.kind === "shell" ? spawn(command.line, { ...options, shell: true }) : spawn(command.file, command.args, { ...options, shell: needsShell(command.file) });
485
+ return { child, description: describeCommand(command) };
486
+ };
487
+ var runOnce = (command, options) => new Promise((resolve, reject) => {
488
+ const spawnOptions = {
489
+ cwd: options.cwd,
490
+ env: options.env ?? process.env,
491
+ stdio: ["ignore", "pipe", "pipe"],
492
+ windowsHide: true
493
+ };
494
+ const child = command.kind === "shell" ? spawn(command.line, { ...spawnOptions, shell: true }) : spawn(command.file, command.args, { ...spawnOptions, shell: needsShell(command.file) });
495
+ let stdout = "";
496
+ let stderr = "";
497
+ child.stdout?.on("data", (chunk) => {
498
+ stdout += chunk.toString("utf8");
499
+ });
500
+ child.stderr?.on("data", (chunk) => {
501
+ stderr += chunk.toString("utf8");
502
+ });
503
+ const timer = options.timeoutMs ? setTimeout(() => {
504
+ child.kill("SIGKILL");
505
+ }, options.timeoutMs) : void 0;
506
+ timer?.unref?.();
507
+ child.once("error", (error) => {
508
+ if (timer) clearTimeout(timer);
509
+ reject(error);
510
+ });
511
+ child.once("close", (code) => {
512
+ if (timer) clearTimeout(timer);
513
+ resolve({ code, stdout, stderr });
514
+ });
515
+ });
516
+
517
+ // src/core/events/bus.ts
518
+ var EventBus = class {
519
+ #listeners = /* @__PURE__ */ new Set();
520
+ subscribe(listener) {
521
+ this.#listeners.add(listener);
522
+ return () => {
523
+ this.#listeners.delete(listener);
524
+ };
525
+ }
526
+ emit(event) {
527
+ for (const listener of this.#listeners) {
528
+ try {
529
+ listener(event);
530
+ } catch (error) {
531
+ process.emitWarning(
532
+ `laracrew: event listener threw: ${error instanceof Error ? error.message : String(error)}`
533
+ );
534
+ }
535
+ }
536
+ }
537
+ /** Resolves on the first event matching `predicate`, or rejects on timeout. */
538
+ next(predicate, timeoutMs = 1e4) {
539
+ return new Promise((resolve, reject) => {
540
+ const timer = setTimeout(() => {
541
+ unsubscribe();
542
+ reject(new Error("timed out waiting for event"));
543
+ }, timeoutMs);
544
+ timer.unref?.();
545
+ const unsubscribe = this.subscribe((event) => {
546
+ if (!predicate(event)) return;
547
+ clearTimeout(timer);
548
+ unsubscribe();
549
+ resolve(event);
550
+ });
551
+ });
552
+ }
553
+ get size() {
554
+ return this.#listeners.size;
555
+ }
556
+ };
557
+
558
+ // src/core/config/resolve.ts
559
+ import path4 from "path";
560
+ import { existsSync as existsSync2 } from "fs";
561
+
562
+ // src/core/laravel/env.ts
563
+ import { readFileSync as readFileSync2 } from "fs";
564
+ import path3 from "path";
565
+ var parseEnv = (content) => {
566
+ const out = {};
567
+ for (const rawLine of content.split(/\r?\n/)) {
568
+ const line = rawLine.trim();
569
+ if (line === "" || line.startsWith("#")) continue;
570
+ const withoutExport = line.startsWith("export ") ? line.slice("export ".length).trim() : line;
571
+ const eq = withoutExport.indexOf("=");
572
+ if (eq === -1) continue;
573
+ const key = withoutExport.slice(0, eq).trim();
574
+ if (!/^[A-Za-z_][A-Za-z0-9_.]*$/.test(key)) continue;
575
+ let value = withoutExport.slice(eq + 1).trim();
576
+ if (value.startsWith('"') && value.endsWith('"') && value.length >= 2 || value.startsWith("'") && value.endsWith("'") && value.length >= 2) {
577
+ const quote = value[0];
578
+ value = value.slice(1, -1);
579
+ if (quote === '"') value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r").replace(/\\"/g, '"');
580
+ } else {
581
+ const hash = value.indexOf(" #");
582
+ if (hash !== -1) value = value.slice(0, hash).trim();
583
+ }
584
+ out[key] = value;
585
+ }
586
+ return out;
587
+ };
588
+ var readProjectEnv = (projectPath, envFile = ".env") => {
589
+ try {
590
+ return parseEnv(readFileSync2(path3.join(projectPath, envFile), "utf8"));
591
+ } catch (error) {
592
+ if (error.code === "ENOENT") return {};
593
+ throw error;
594
+ }
595
+ };
596
+
597
+ // src/core/config/interpolate.ts
598
+ var TOKEN = /\$\{([^}]*)\}/g;
599
+ var splitOnce = (input, separator) => {
600
+ const index = input.indexOf(separator);
601
+ return index === -1 ? [input, void 0] : [input.slice(0, index), input.slice(index + separator.length)];
602
+ };
603
+ var interpolate = (input, ctx) => input.replace(TOKEN, (match, body) => {
604
+ const [scheme, rest] = splitOnce(body.trim(), ":");
605
+ switch (scheme) {
606
+ case "env": {
607
+ if (!rest) throw new ConfigError(`\${env:...} needs a variable name, got "${match}"`);
608
+ const [key, fallback] = splitOnce(rest, ":");
609
+ const value = ctx.env[key];
610
+ if (value !== void 0) return value;
611
+ if (fallback !== void 0) return fallback;
612
+ throw new ConfigError(`environment variable "${key}" is not set (used in "${match}")`);
613
+ }
614
+ case "project.path": {
615
+ if (!ctx.project) throw new ConfigError(`"${match}" used on a service that has no \`project\``);
616
+ return ctx.project.path;
617
+ }
618
+ case "project.env": {
619
+ if (!ctx.project) throw new ConfigError(`"${match}" used on a service that has no \`project\``);
620
+ if (!rest) throw new ConfigError(`\${project.env:...} needs a variable name, got "${match}"`);
621
+ const [key, fallback] = splitOnce(rest, ":");
622
+ const value = ctx.project.env[key];
623
+ if (value !== void 0) return value;
624
+ if (fallback !== void 0) return fallback;
625
+ throw new ConfigError(`"${key}" is not in the project's .env (used in "${match}")`);
626
+ }
627
+ case "stack.dir": {
628
+ if (!ctx.stackDir) throw new ConfigError(`"${match}" used outside a stack folder`);
629
+ return ctx.stackDir;
630
+ }
631
+ case "port": {
632
+ const port = Number(rest);
633
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
634
+ throw new ConfigError(`"${match}" is not a valid port number`);
635
+ }
636
+ ctx.ports?.add(port);
637
+ return String(port);
638
+ }
639
+ default:
640
+ throw new ConfigError(
641
+ `unknown token "${match}" \u2014 supported: \${env:VAR}, \${project.path}, \${project.env:VAR}, \${stack.dir}, \${port:N}`
642
+ );
643
+ }
644
+ });
645
+ var interpolateDeep = (value, ctx) => {
646
+ if (typeof value === "string") return interpolate(value, ctx);
647
+ if (Array.isArray(value)) return value.map((item) => interpolateDeep(item, ctx));
648
+ if (value && typeof value === "object") {
649
+ const out = {};
650
+ for (const [key, item] of Object.entries(value)) {
651
+ if (key === "__proto__" || key === "constructor" || key === "prototype") continue;
652
+ out[key] = interpolateDeep(item, ctx);
653
+ }
654
+ return out;
655
+ }
656
+ return value;
657
+ };
658
+
659
+ // src/core/config/resolve.ts
660
+ var PALETTE = ["cyan", "magenta", "green", "yellow", "blue", "red"];
661
+ var toCommand = (cmd) => Array.isArray(cmd) ? { kind: "argv", file: cmd[0], args: cmd.slice(1) } : { kind: "shell", line: cmd };
662
+ var matches = (service, selectors) => selectors.some((selector) => service.name === selector || service.groups.includes(selector));
663
+ var resolveProjects = (stack, shared) => {
664
+ const wanted = /* @__PURE__ */ new Map();
665
+ for (const key of stack.config.use) {
666
+ const project = shared[key];
667
+ if (!project) {
668
+ const suggestion = didYouMean(key, Object.keys(shared));
669
+ throw new ConfigError(
670
+ `\`use:\` refers to project "${key}", which is not in projects.yaml` + (suggestion ? ` \u2014 did you mean "${suggestion}"?` : ""),
671
+ { file: stack.file }
672
+ );
673
+ }
674
+ wanted.set(key, project);
675
+ }
676
+ for (const [key, project] of Object.entries(stack.config.projects)) wanted.set(key, project);
677
+ const out = {};
678
+ let index = 0;
679
+ for (const [key, project] of wanted) {
680
+ const root = path4.resolve(project.path);
681
+ out[key] = {
682
+ key,
683
+ path: root,
684
+ php: project.php,
685
+ envFile: project.envFile,
686
+ env: readProjectEnv(root, project.envFile),
687
+ color: project.color ?? PALETTE[index % PALETTE.length]
688
+ };
689
+ index += 1;
690
+ }
691
+ return out;
692
+ };
693
+ var mergeService = (raw, stack, projects, ports, processEnv, colorIndex) => {
694
+ const defaults = stack.config.defaults;
695
+ let project;
696
+ if (raw.project) {
697
+ project = projects[raw.project];
698
+ if (!project) {
699
+ const suggestion = didYouMean(raw.project, Object.keys(projects));
700
+ throw new ConfigError(
701
+ `service "${raw.name}" refers to project "${raw.project}", which this stack does not define` + (suggestion ? ` \u2014 did you mean "${suggestion}"?` : ""),
702
+ { file: stack.file }
703
+ );
704
+ }
705
+ }
706
+ const ctx = {
707
+ env: processEnv,
708
+ stackDir: stack.dir,
709
+ ports,
710
+ ...project ? { project: { path: project.path, env: project.env } } : {}
711
+ };
712
+ let service;
713
+ try {
714
+ service = interpolateDeep(raw, ctx);
715
+ } catch (error) {
716
+ if (error instanceof ConfigError) {
717
+ throw new ConfigError(`service "${raw.name}": ${error.message}`, { file: stack.file, cause: error });
718
+ }
719
+ throw error;
720
+ }
721
+ const cwd = service.cwd ? path4.resolve(project?.path ?? stack.dir, service.cwd) : project?.path ?? stack.dir;
722
+ return {
723
+ name: service.name,
724
+ ...project ? { project } : {},
725
+ ...service.cmd ? { command: toCommand(service.cmd) } : {},
726
+ cwd,
727
+ env: { ...service.env },
728
+ groups: service.groups,
729
+ needs: service.needs,
730
+ ...service.ready ? { ready: service.ready } : {},
731
+ restart: service.restart ?? defaults.restart,
732
+ backoff: backoffSchema.parse({ ...defaults.backoff, ...service.backoff ?? {} }),
733
+ stop: stopPolicySchema.parse({ ...defaults.stop, ...service.stop ?? {} }),
734
+ ...service.watch ? { watch: service.watch } : {},
735
+ ...service.metrics ? { metrics: service.metrics } : {},
736
+ ...service.url ? { url: service.url } : {},
737
+ external: service.external,
738
+ autostart: service.autostart,
739
+ color: service.color ?? project?.color ?? PALETTE[colorIndex % PALETTE.length]
740
+ };
741
+ };
742
+ var topoLevels = (services, file) => {
743
+ const names = new Set(services.map((s) => s.name));
744
+ const remaining = /* @__PURE__ */ new Map();
745
+ for (const service of services) {
746
+ for (const need of service.needs) {
747
+ if (!names.has(need)) {
748
+ const suggestion = didYouMean(need, [...names]);
749
+ throw new ConfigError(
750
+ `service "${service.name}" needs "${need}", which is not in this stack` + (suggestion ? ` \u2014 did you mean "${suggestion}"?` : ""),
751
+ file ? { file } : {}
752
+ );
753
+ }
754
+ }
755
+ remaining.set(service.name, new Set(service.needs));
756
+ }
757
+ const levels = [];
758
+ const done = /* @__PURE__ */ new Set();
759
+ while (done.size < services.length) {
760
+ const level = [...remaining.entries()].filter(([name, needs]) => !done.has(name) && [...needs].every((need) => done.has(need))).map(([name]) => name);
761
+ if (level.length === 0) {
762
+ const stuck = services.filter((s) => !done.has(s.name)).map((s) => s.name);
763
+ throw new ConfigError(
764
+ `dependency cycle between: ${stuck.join(" -> ")}`,
765
+ file ? { file } : {}
766
+ );
767
+ }
768
+ for (const name of level) done.add(name);
769
+ levels.push(level);
770
+ }
771
+ return levels;
772
+ };
773
+ var resolveStack = (stack, options = {}) => {
774
+ const processEnv = options.env ?? process.env;
775
+ const projects = resolveProjects(stack, options.sharedProjects ?? {});
776
+ const ports = /* @__PURE__ */ new Set();
777
+ const seen = /* @__PURE__ */ new Set();
778
+ for (const service of stack.config.services) {
779
+ if (seen.has(service.name)) {
780
+ throw new ConfigError(`two services are both named "${service.name}"`, { file: stack.file });
781
+ }
782
+ seen.add(service.name);
783
+ }
784
+ let services = stack.config.services.filter((service) => service.enabled).map((service, index) => mergeService(service, stack, projects, ports, processEnv, index));
785
+ const profile = options.profile ? stack.config.profiles[options.profile] : void 0;
786
+ if (options.profile && !profile) {
787
+ const suggestion = didYouMean(options.profile, Object.keys(stack.config.profiles));
788
+ throw new ConfigError(
789
+ `no profile "${options.profile}" in this stack` + (suggestion ? ` \u2014 did you mean "${suggestion}"?` : ""),
790
+ { file: stack.file }
791
+ );
792
+ }
793
+ const only = options.only?.length ? options.only : profile?.only;
794
+ const except = options.except?.length ? options.except : profile?.except;
795
+ if (only?.length) services = services.filter((service) => matches(service, only));
796
+ if (except?.length) services = services.filter((service) => !matches(service, except));
797
+ if (services.length === 0) {
798
+ throw new ConfigError("the selection matched no services", { file: stack.file });
799
+ }
800
+ const kept = new Set(services.map((service) => service.name));
801
+ services = services.map((service) => ({
802
+ ...service,
803
+ needs: service.needs.filter((need) => kept.has(need))
804
+ }));
805
+ const manual = new Set(
806
+ services.filter((service) => !service.autostart && !service.external).map((service) => service.name)
807
+ );
808
+ for (const service of services) {
809
+ if (!service.autostart) continue;
810
+ const blocked = service.needs.filter((need) => manual.has(need));
811
+ if (blocked.length > 0) {
812
+ throw new ConfigError(
813
+ `service "${service.name}" starts at launch but needs "${blocked.join('", "')}", which ${blocked.length === 1 ? "does" : "do"} not`,
814
+ { file: stack.file, details: ["give it `autostart: false` too, or start the dependency at launch"] }
815
+ );
816
+ }
817
+ }
818
+ for (const service of services) {
819
+ if (!service.external && !existsSync2(service.cwd)) {
820
+ throw new ConfigError(
821
+ `service "${service.name}" has cwd "${service.cwd}", which does not exist`,
822
+ { file: stack.file }
823
+ );
824
+ }
825
+ }
826
+ return {
827
+ name: stack.config.name,
828
+ ...stack.config.description ? { description: stack.config.description } : {},
829
+ dir: stack.dir,
830
+ file: stack.file,
831
+ services,
832
+ levels: topoLevels(services, stack.file),
833
+ projects,
834
+ ports: [...ports].sort((a, b) => a - b),
835
+ logs: stack.config.defaults.logs
836
+ };
837
+ };
838
+
839
+ // src/core/process/supervisor.ts
840
+ import path6 from "path";
841
+
842
+ // src/core/events/types.ts
843
+ var isLive = (state) => state === "starting" || state === "ready" || state === "running";
844
+
845
+ // src/core/logs/file-sink.ts
846
+ import { closeSync, existsSync as existsSync3, mkdirSync, openSync, renameSync, rmSync, statSync as statSync2, writeSync } from "fs";
847
+ import path5 from "path";
848
+ var stamp = (at) => {
849
+ const date = new Date(at);
850
+ const pad2 = (value, width = 2) => String(value).padStart(width, "0");
851
+ return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())} ${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}.${pad2(date.getMilliseconds(), 3)}`;
852
+ };
853
+ var parseLine = (raw) => {
854
+ const match = /^(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3}) (stdout|stderr|system) (.*)$/s.exec(raw);
855
+ if (!match) return void 0;
856
+ const at = new Date(match[1].replace(" ", "T")).getTime();
857
+ return { at: Number.isNaN(at) ? 0 : at, stream: match[2], line: match[3] };
858
+ };
859
+ var safeFileName = (service) => service.replace(/[^a-zA-Z0-9._-]+/g, "-").replace(/^-+|-+$/g, "") || "service";
860
+ var fileNamesFor = (services) => {
861
+ const used = /* @__PURE__ */ new Set();
862
+ const out = /* @__PURE__ */ new Map();
863
+ for (const service of services) {
864
+ const base = safeFileName(service);
865
+ let candidate = base;
866
+ let index = 2;
867
+ while (used.has(candidate)) candidate = `${base}-${index++}`;
868
+ used.add(candidate);
869
+ out.set(service, `${candidate}.log`);
870
+ }
871
+ return out;
872
+ };
873
+ var FileSink = class {
874
+ file;
875
+ #fd;
876
+ #bytes = 0;
877
+ #closed = false;
878
+ #options;
879
+ constructor(file, options) {
880
+ this.file = file;
881
+ this.#options = options;
882
+ }
883
+ #open() {
884
+ if (this.#fd !== void 0) return this.#fd;
885
+ try {
886
+ mkdirSync(path5.dirname(this.file), { recursive: true });
887
+ this.#bytes = existsSync3(this.file) ? statSync2(this.file).size : 0;
888
+ this.#fd = openSync(this.file, "a");
889
+ return this.#fd;
890
+ } catch {
891
+ this.#closed = true;
892
+ return void 0;
893
+ }
894
+ }
895
+ write(entry) {
896
+ if (this.#closed) return;
897
+ const fd = this.#open();
898
+ if (fd === void 0) return;
899
+ const text = `${stamp(entry.at)} ${entry.stream} ${stripAnsi(entry.line)}
900
+ `;
901
+ try {
902
+ writeSync(fd, text);
903
+ } catch {
904
+ this.#closeHandle();
905
+ this.#closed = true;
906
+ return;
907
+ }
908
+ this.#bytes += Buffer.byteLength(text);
909
+ if (this.#bytes >= this.#options.maxBytes) this.#rotate();
910
+ }
911
+ #closeHandle() {
912
+ if (this.#fd === void 0) return;
913
+ try {
914
+ closeSync(this.#fd);
915
+ } catch {
916
+ }
917
+ this.#fd = void 0;
918
+ }
919
+ #rotate() {
920
+ this.#closeHandle();
921
+ this.#bytes = 0;
922
+ try {
923
+ if (this.#options.keep <= 0) {
924
+ rmSync(this.file, { force: true });
925
+ return;
926
+ }
927
+ const oldest = `${this.file}.${this.#options.keep}`;
928
+ if (existsSync3(oldest)) rmSync(oldest, { force: true });
929
+ for (let index = this.#options.keep - 1; index >= 1; index -= 1) {
930
+ const from = `${this.file}.${index}`;
931
+ if (existsSync3(from)) renameSync(from, `${this.file}.${index + 1}`);
932
+ }
933
+ if (existsSync3(this.file)) renameSync(this.file, `${this.file}.1`);
934
+ } catch {
935
+ }
936
+ }
937
+ close() {
938
+ this.#closed = true;
939
+ this.#closeHandle();
940
+ return Promise.resolve();
941
+ }
942
+ };
943
+ var LogWriter = class {
944
+ dir;
945
+ #sinks = /* @__PURE__ */ new Map();
946
+ #names;
947
+ #unsubscribe;
948
+ constructor(options) {
949
+ this.dir = options.dir;
950
+ this.#names = fileNamesFor(options.services);
951
+ const sinkOptions = {
952
+ maxBytes: options.maxBytes ?? 5e6,
953
+ keep: options.keep ?? 1
954
+ };
955
+ for (const [service, fileName] of this.#names) {
956
+ this.#sinks.set(service, new FileSink(path5.join(this.dir, fileName), sinkOptions));
957
+ }
958
+ }
959
+ /** The file a given service writes to — used by `laracrew logs`. */
960
+ fileFor(service) {
961
+ const name = this.#names.get(service);
962
+ return name ? path5.join(this.dir, name) : void 0;
963
+ }
964
+ attach(bus) {
965
+ this.#unsubscribe = bus.subscribe((event) => {
966
+ if (event.type !== "service:log") return;
967
+ this.#sinks.get(event.log.service)?.write(event.log);
968
+ });
969
+ return this.#unsubscribe;
970
+ }
971
+ async close() {
972
+ this.#unsubscribe?.();
973
+ this.#unsubscribe = void 0;
974
+ await Promise.all([...this.#sinks.values()].map((sink) => sink.close()));
975
+ }
976
+ };
977
+
978
+ // src/core/logs/ring-buffer.ts
979
+ var RingBuffer = class {
980
+ constructor(capacity) {
981
+ this.capacity = capacity;
982
+ if (!Number.isInteger(capacity) || capacity < 1) {
983
+ throw new RangeError(`RingBuffer capacity must be a positive integer, got ${capacity}`);
984
+ }
985
+ this.#items = Array.from({ length: capacity });
986
+ }
987
+ capacity;
988
+ #items;
989
+ #next = 0;
990
+ #count = 0;
991
+ #dropped = 0;
992
+ push(item) {
993
+ if (this.#count === this.capacity) this.#dropped += 1;
994
+ this.#items[this.#next] = item;
995
+ this.#next = (this.#next + 1) % this.capacity;
996
+ this.#count = Math.min(this.#count + 1, this.capacity);
997
+ }
998
+ /** Oldest to newest. */
999
+ toArray() {
1000
+ const out = [];
1001
+ const start = this.#count === this.capacity ? this.#next : 0;
1002
+ for (let i = 0; i < this.#count; i += 1) {
1003
+ out.push(this.#items[(start + i) % this.capacity]);
1004
+ }
1005
+ return out;
1006
+ }
1007
+ /** The last `n` items, oldest to newest — what a log pane actually needs. */
1008
+ tail(n) {
1009
+ if (n >= this.#count) return this.toArray();
1010
+ const out = [];
1011
+ for (let i = this.#count - n; i < this.#count; i += 1) {
1012
+ const start = this.#count === this.capacity ? this.#next : 0;
1013
+ out.push(this.#items[(start + i) % this.capacity]);
1014
+ }
1015
+ return out;
1016
+ }
1017
+ clear() {
1018
+ this.#items = Array.from({ length: this.capacity });
1019
+ this.#next = 0;
1020
+ this.#count = 0;
1021
+ this.#dropped = 0;
1022
+ }
1023
+ get size() {
1024
+ return this.#count;
1025
+ }
1026
+ /** How many items fell off the back — worth showing in the UI. */
1027
+ get dropped() {
1028
+ return this.#dropped;
1029
+ }
1030
+ };
1031
+
1032
+ // src/core/logs/sanitize.ts
1033
+ var ESC = String.fromCharCode(27);
1034
+ var BEL = String.fromCharCode(7);
1035
+ var CSI = new RegExp(ESC + "\\[[0-?]*[ -/]*[@-~]", "g");
1036
+ var OSC = new RegExp(ESC + "\\][^" + BEL + ESC + "]*(?:" + BEL + "|" + ESC + "\\\\)", "g");
1037
+ var SINGLE = new RegExp(ESC + "[=>78MD]", "g");
1038
+ var TAB = 9;
1039
+ var ESC_CODE = 27;
1040
+ var sanitize = (input) => {
1041
+ let out = input.replace(OSC, "").replace(SINGLE, "");
1042
+ out = out.replace(CSI, (seq) => seq.endsWith("m") ? seq : "");
1043
+ if (out.includes("\r")) {
1044
+ const segments = out.split("\r").filter((segment) => segment !== "");
1045
+ out = segments.length > 0 ? segments[segments.length - 1] : "";
1046
+ }
1047
+ return out.replace(new RegExp("\\p{Cc}", "gu"), (character) => {
1048
+ const code = character.charCodeAt(0);
1049
+ return code === TAB || code === ESC_CODE ? character : "";
1050
+ });
1051
+ };
1052
+ var LineSplitter = class {
1053
+ #buffer = "";
1054
+ #maxLineLength;
1055
+ constructor(maxLineLength = 16384) {
1056
+ this.#maxLineLength = maxLineLength;
1057
+ }
1058
+ push(chunk) {
1059
+ this.#buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
1060
+ const lines = [];
1061
+ let index = this.#buffer.indexOf("\n");
1062
+ while (index !== -1) {
1063
+ const line = this.#buffer.slice(0, index).replace(/\r$/, "");
1064
+ lines.push(sanitize(line));
1065
+ this.#buffer = this.#buffer.slice(index + 1);
1066
+ index = this.#buffer.indexOf("\n");
1067
+ }
1068
+ if (this.#buffer.length > this.#maxLineLength) {
1069
+ lines.push(sanitize(this.#buffer));
1070
+ this.#buffer = "";
1071
+ }
1072
+ return lines;
1073
+ }
1074
+ /** Emits whatever is left when the stream closes. */
1075
+ flush() {
1076
+ if (this.#buffer === "") return [];
1077
+ const line = sanitize(this.#buffer.replace(/\r$/, ""));
1078
+ this.#buffer = "";
1079
+ return line === "" ? [] : [line];
1080
+ }
1081
+ };
1082
+
1083
+ // src/core/process/stop.ts
1084
+ import { spawn as spawn2 } from "child_process";
1085
+ var IS_WINDOWS2 = process.platform === "win32";
1086
+ var isAlive = (pid) => {
1087
+ try {
1088
+ process.kill(pid, 0);
1089
+ return true;
1090
+ } catch (error) {
1091
+ return error.code === "EPERM";
1092
+ }
1093
+ };
1094
+ var killTree = async (pid, signal = "SIGKILL") => {
1095
+ if (IS_WINDOWS2) {
1096
+ await new Promise((resolve) => {
1097
+ const killer = spawn2("taskkill", ["/pid", String(pid), "/T", "/F"], {
1098
+ stdio: "ignore",
1099
+ windowsHide: true
1100
+ });
1101
+ killer.once("error", () => resolve());
1102
+ killer.once("close", () => resolve());
1103
+ });
1104
+ return;
1105
+ }
1106
+ try {
1107
+ process.kill(-pid, signal);
1108
+ } catch {
1109
+ try {
1110
+ process.kill(pid, signal);
1111
+ } catch {
1112
+ }
1113
+ }
1114
+ };
1115
+ var waitForExit = (child, timeoutMs) => new Promise((resolve) => {
1116
+ if (child.exitCode !== null || child.signalCode !== null) {
1117
+ resolve(true);
1118
+ return;
1119
+ }
1120
+ const timer = setTimeout(() => {
1121
+ child.removeListener("close", onClose);
1122
+ resolve(false);
1123
+ }, timeoutMs);
1124
+ timer.unref?.();
1125
+ const onClose = () => {
1126
+ clearTimeout(timer);
1127
+ resolve(true);
1128
+ };
1129
+ child.once("close", onClose);
1130
+ });
1131
+ var stopLadder = async (options) => {
1132
+ const { child, graceful, signal, graceMs, onStep } = options;
1133
+ const pid = child.pid;
1134
+ if (pid === void 0 || child.exitCode !== null || child.signalCode !== null) return "already-gone";
1135
+ if (graceful) {
1136
+ onStep?.("graceful");
1137
+ try {
1138
+ await graceful();
1139
+ if (await waitForExit(child, graceMs)) return "graceful";
1140
+ } catch {
1141
+ }
1142
+ }
1143
+ if (!IS_WINDOWS2) {
1144
+ onStep?.("signal");
1145
+ try {
1146
+ process.kill(-pid, signal);
1147
+ } catch {
1148
+ try {
1149
+ child.kill(signal);
1150
+ } catch {
1151
+ return "already-gone";
1152
+ }
1153
+ }
1154
+ if (await waitForExit(child, Math.min(graceMs, 5e3))) return "signal";
1155
+ }
1156
+ onStep?.("tree-kill");
1157
+ await killTree(pid);
1158
+ if (await waitForExit(child, 5e3)) return "tree-kill";
1159
+ return isAlive(pid) ? "survived" : "tree-kill";
1160
+ };
1161
+
1162
+ // src/core/process/managed-process.ts
1163
+ var ManagedProcess = class {
1164
+ service;
1165
+ logs;
1166
+ #bus;
1167
+ #env;
1168
+ #child;
1169
+ #state = "queued";
1170
+ #intentionalStop = false;
1171
+ #restarts = 0;
1172
+ #attempt = 0;
1173
+ #startedAt;
1174
+ #readyAt;
1175
+ #backoffTimer;
1176
+ #logWatchers = /* @__PURE__ */ new Set();
1177
+ #disposed = false;
1178
+ constructor(service, bus, options = {}) {
1179
+ this.service = service;
1180
+ this.#bus = bus;
1181
+ this.#env = options.env ?? process.env;
1182
+ this.logs = new RingBuffer(options.logCapacity ?? 5e3);
1183
+ }
1184
+ get state() {
1185
+ return this.#state;
1186
+ }
1187
+ get pid() {
1188
+ return this.#child?.pid;
1189
+ }
1190
+ get restarts() {
1191
+ return this.#restarts;
1192
+ }
1193
+ get startedAt() {
1194
+ return this.#startedAt;
1195
+ }
1196
+ get uptimeMs() {
1197
+ return this.#startedAt === void 0 ? void 0 : Date.now() - this.#startedAt;
1198
+ }
1199
+ get readyDurationMs() {
1200
+ return this.#readyAt !== void 0 && this.#startedAt !== void 0 ? this.#readyAt - this.#startedAt : void 0;
1201
+ }
1202
+ #setState(to, detail) {
1203
+ if (this.#state === to) return;
1204
+ const from = this.#state;
1205
+ this.#state = to;
1206
+ this.#bus.emit({
1207
+ type: "service:state",
1208
+ service: this.service.name,
1209
+ from,
1210
+ to,
1211
+ ...detail ? { detail } : {},
1212
+ at: Date.now()
1213
+ });
1214
+ }
1215
+ #log(line, stream = "system") {
1216
+ const entry = { service: this.service.name, stream, line, at: Date.now() };
1217
+ this.logs.push(entry);
1218
+ this.#bus.emit({ type: "service:log", log: entry });
1219
+ for (const watcher of this.#logWatchers) watcher(line);
1220
+ }
1221
+ /**
1222
+ * Spawns (unless external) and resolves once the readiness probe passes.
1223
+ * Rejects when the gate fails, which is what aborts a boot.
1224
+ */
1225
+ async start() {
1226
+ if (this.#disposed) throw new Error(`service "${this.service.name}" has been disposed`);
1227
+ this.#intentionalStop = false;
1228
+ this.#setState("starting");
1229
+ this.#startedAt = Date.now();
1230
+ this.#readyAt = void 0;
1231
+ if (this.service.external) {
1232
+ await this.#awaitReady();
1233
+ return;
1234
+ }
1235
+ const { child, description } = spawnService(this.service, this.#env);
1236
+ this.#child = child;
1237
+ child.once("error", (error) => {
1238
+ this.#log(`failed to spawn: ${error.message}`, "stderr");
1239
+ this.#setState("failed", error.message);
1240
+ });
1241
+ this.#pipe(child, "stdout");
1242
+ this.#pipe(child, "stderr");
1243
+ child.once("close", (code, signal) => this.#onExit(code, signal));
1244
+ if (child.pid !== void 0) {
1245
+ this.#bus.emit({
1246
+ type: "service:spawned",
1247
+ service: this.service.name,
1248
+ pid: child.pid,
1249
+ command: description,
1250
+ cwd: this.service.cwd,
1251
+ at: Date.now()
1252
+ });
1253
+ }
1254
+ await this.#awaitReady();
1255
+ }
1256
+ #pipe(child, stream) {
1257
+ const source = child[stream];
1258
+ if (!source) return;
1259
+ const splitter = new LineSplitter();
1260
+ source.setEncoding("utf8");
1261
+ source.on("data", (chunk) => {
1262
+ for (const line of splitter.push(chunk)) this.#log(line, stream);
1263
+ });
1264
+ source.once("end", () => {
1265
+ for (const line of splitter.flush()) this.#log(line, stream);
1266
+ });
1267
+ }
1268
+ async #awaitReady() {
1269
+ const probe = this.service.ready;
1270
+ if (!probe) {
1271
+ this.#readyAt = Date.now();
1272
+ this.#setState("running");
1273
+ return;
1274
+ }
1275
+ const label = describeProbe(probe);
1276
+ const result = await this.#runProbe(probe);
1277
+ if (!result.ok) {
1278
+ this.#setState("failed", `readiness gate failed: ${label}${result.detail ? ` (${result.detail})` : ""}`);
1279
+ throw new Error(
1280
+ `service "${this.service.name}" never became ready: ${label}${result.detail ? ` \u2014 ${result.detail}` : ""}`
1281
+ );
1282
+ }
1283
+ this.#readyAt = Date.now();
1284
+ this.#bus.emit({
1285
+ type: "service:ready",
1286
+ service: this.service.name,
1287
+ durationMs: this.readyDurationMs ?? 0,
1288
+ probe: label,
1289
+ at: Date.now()
1290
+ });
1291
+ this.#setState(this.service.external ? "ready" : "running");
1292
+ }
1293
+ async #runProbe(probe) {
1294
+ if (probe.delayMs !== void 0 && !probe.tcp && !probe.http && !probe.logMatch) {
1295
+ await sleep(probe.delayMs);
1296
+ return { ok: true };
1297
+ }
1298
+ if (probe.logMatch) return this.#waitForLogMatch(probe.logMatch, probe.timeoutMs);
1299
+ if (probe.http) {
1300
+ const url = probe.http;
1301
+ return waitFor(() => httpProbe(url, probe.intervalMs * 4), {
1302
+ timeoutMs: probe.timeoutMs,
1303
+ intervalMs: probe.intervalMs
1304
+ });
1305
+ }
1306
+ const target = probe.tcp;
1307
+ return waitFor(() => tcpProbe(target, probe.intervalMs * 4), {
1308
+ timeoutMs: probe.timeoutMs,
1309
+ intervalMs: probe.intervalMs
1310
+ });
1311
+ }
1312
+ /** Readiness by output, for tools that announce themselves (Vite, Reverb). */
1313
+ #waitForLogMatch(source, timeoutMs) {
1314
+ let pattern;
1315
+ try {
1316
+ pattern = new RegExp(source);
1317
+ } catch (error) {
1318
+ return Promise.resolve({ ok: false, detail: `invalid logMatch regex: ${String(error)}` });
1319
+ }
1320
+ for (const entry of this.logs.toArray()) {
1321
+ if (pattern.test(entry.line)) return Promise.resolve({ ok: true, detail: "matched buffered output" });
1322
+ }
1323
+ return new Promise((resolve) => {
1324
+ const done = (result) => {
1325
+ clearTimeout(timer);
1326
+ this.#logWatchers.delete(watcher);
1327
+ resolve(result);
1328
+ };
1329
+ const watcher = (line) => {
1330
+ if (pattern.test(line)) done({ ok: true });
1331
+ };
1332
+ const timer = setTimeout(() => done({ ok: false, detail: "no matching output" }), timeoutMs);
1333
+ timer.unref?.();
1334
+ this.#logWatchers.add(watcher);
1335
+ });
1336
+ }
1337
+ #onExit(code, signal) {
1338
+ const wasIntentional = this.#intentionalStop;
1339
+ const uptime = this.uptimeMs ?? 0;
1340
+ this.#child = void 0;
1341
+ this.#bus.emit({
1342
+ type: "service:exit",
1343
+ service: this.service.name,
1344
+ code,
1345
+ signal,
1346
+ intentional: wasIntentional,
1347
+ at: Date.now()
1348
+ });
1349
+ if (wasIntentional || this.#disposed) {
1350
+ this.#setState("stopped");
1351
+ return;
1352
+ }
1353
+ if (uptime >= this.service.backoff.resetAfterMs) this.#attempt = 0;
1354
+ const clean = code === 0 && signal === null;
1355
+ const policy = this.service.restart;
1356
+ const shouldRestart = policy === "always" || policy === "on-failure" && !clean;
1357
+ this.#setState("crashed", signal ? `killed by ${signal}` : `exit code ${code}`);
1358
+ if (!shouldRestart) {
1359
+ this.#setState(clean ? "stopped" : "failed", clean ? void 0 : `exited with code ${code}`);
1360
+ return;
1361
+ }
1362
+ if (this.#attempt >= this.service.backoff.maxRestarts) {
1363
+ this.#setState("failed", `gave up after ${this.#attempt} restarts`);
1364
+ return;
1365
+ }
1366
+ this.#attempt += 1;
1367
+ this.#restarts += 1;
1368
+ const { initialMs, factor, maxMs } = this.service.backoff;
1369
+ const delayMs = Math.min(Math.round(initialMs * factor ** (this.#attempt - 1)), maxMs);
1370
+ this.#setState("backoff", `restarting in ${delayMs}ms`);
1371
+ this.#bus.emit({
1372
+ type: "service:restart",
1373
+ service: this.service.name,
1374
+ attempt: this.#attempt,
1375
+ maxAttempts: this.service.backoff.maxRestarts,
1376
+ delayMs,
1377
+ at: Date.now()
1378
+ });
1379
+ this.#backoffTimer = setTimeout(() => {
1380
+ this.#backoffTimer = void 0;
1381
+ if (this.#disposed || this.#intentionalStop) return;
1382
+ void this.start().catch((error) => {
1383
+ this.#log(`restart failed: ${error instanceof Error ? error.message : String(error)}`, "stderr");
1384
+ });
1385
+ }, delayMs);
1386
+ this.#backoffTimer.unref?.();
1387
+ }
1388
+ /** The Laravel-native graceful shutdown, when the service declares one. */
1389
+ #graceful() {
1390
+ const artisan = this.service.stop.artisan;
1391
+ const project = this.service.project;
1392
+ if (!artisan || !project) return void 0;
1393
+ return async () => {
1394
+ const command = { kind: "argv", file: project.php, args: ["artisan", ...artisan.split(/\s+/)] };
1395
+ this.#log(`stop: ${describeCommand(command)}`);
1396
+ await runOnce(command, { cwd: project.path, env: this.#env, timeoutMs: 15e3 });
1397
+ };
1398
+ }
1399
+ async stop() {
1400
+ this.#intentionalStop = true;
1401
+ if (this.#backoffTimer) {
1402
+ clearTimeout(this.#backoffTimer);
1403
+ this.#backoffTimer = void 0;
1404
+ }
1405
+ if (this.service.external) {
1406
+ this.#setState("stopped");
1407
+ return;
1408
+ }
1409
+ const child = this.#child;
1410
+ if (!child) {
1411
+ this.#setState("stopped");
1412
+ return;
1413
+ }
1414
+ this.#setState("stopping");
1415
+ const graceful = this.#graceful();
1416
+ const outcome = await stopLadder({
1417
+ child,
1418
+ ...graceful ? { graceful } : {},
1419
+ signal: this.service.stop.signal,
1420
+ graceMs: this.service.stop.graceMs,
1421
+ onStep: (step) => this.#log(`stop: ${step}`)
1422
+ });
1423
+ if (outcome === "survived") {
1424
+ this.#bus.emit({
1425
+ type: "notice",
1426
+ level: "error",
1427
+ service: this.service.name,
1428
+ message: `process ${child.pid} survived the stop ladder \u2014 kill it manually`,
1429
+ at: Date.now()
1430
+ });
1431
+ }
1432
+ this.#setState("stopped");
1433
+ }
1434
+ async restart() {
1435
+ await this.stop();
1436
+ this.#attempt = 0;
1437
+ this.#restarts += 1;
1438
+ await this.start();
1439
+ }
1440
+ async dispose() {
1441
+ this.#disposed = true;
1442
+ await this.stop();
1443
+ this.#logWatchers.clear();
1444
+ }
1445
+ };
1446
+
1447
+ // src/core/process/supervisor.ts
1448
+ var Supervisor = class {
1449
+ stack;
1450
+ #bus;
1451
+ #processes = /* @__PURE__ */ new Map();
1452
+ #startedOrder = [];
1453
+ #shuttingDown = false;
1454
+ #logWriter;
1455
+ constructor(stack, bus, options = {}) {
1456
+ this.stack = stack;
1457
+ this.#bus = bus;
1458
+ for (const service of stack.services) {
1459
+ this.#processes.set(
1460
+ service.name,
1461
+ new ManagedProcess(service, bus, {
1462
+ logCapacity: stack.logs.maxLines,
1463
+ ...options.env ? { env: options.env } : {}
1464
+ })
1465
+ );
1466
+ }
1467
+ if (stack.logs.toFile) {
1468
+ this.#logWriter = new LogWriter({
1469
+ dir: options.logDir ?? path6.join(paths(options.env ?? process.env).logsDir, stack.name),
1470
+ services: stack.services.map((service) => service.name),
1471
+ maxBytes: stack.logs.maxFileBytes,
1472
+ keep: stack.logs.keepFiles
1473
+ });
1474
+ this.#logWriter.attach(bus);
1475
+ }
1476
+ }
1477
+ /** Where a service's output is being written, if anywhere. */
1478
+ logFileFor(service) {
1479
+ return this.#logWriter?.fileFor(service);
1480
+ }
1481
+ get(name) {
1482
+ return this.#processes.get(name);
1483
+ }
1484
+ get processes() {
1485
+ return [...this.#processes.values()];
1486
+ }
1487
+ snapshot() {
1488
+ return this.processes.map((process2) => ({
1489
+ name: process2.service.name,
1490
+ state: process2.state,
1491
+ ...process2.pid !== void 0 ? { pid: process2.pid } : {},
1492
+ ...process2.uptimeMs !== void 0 && isLive(process2.state) ? { uptimeMs: process2.uptimeMs } : {},
1493
+ restarts: process2.restarts,
1494
+ autostart: process2.service.autostart,
1495
+ groups: process2.service.groups,
1496
+ ...process2.service.project ? { project: process2.service.project.key } : {},
1497
+ ...process2.service.url ? { url: process2.service.url } : {},
1498
+ external: process2.service.external,
1499
+ color: process2.service.color
1500
+ }));
1501
+ }
1502
+ /**
1503
+ * Starts every level in order; everything inside a level goes in parallel.
1504
+ * A failure rolls back what already started, so Ctrl-C is never needed to clean up.
1505
+ */
1506
+ async up() {
1507
+ const startedAt = Date.now();
1508
+ this.#bus.emit({
1509
+ type: "stack:starting",
1510
+ stack: this.stack.name,
1511
+ services: this.stack.services.map((service) => service.name),
1512
+ at: startedAt
1513
+ });
1514
+ for (const level of this.stack.levels) {
1515
+ const toStart = level.filter((name) => this.#processes.get(name)?.service.autostart !== false);
1516
+ const results = await Promise.allSettled(
1517
+ toStart.map(async (name) => {
1518
+ const process2 = this.#processes.get(name);
1519
+ await process2.start();
1520
+ this.#startedOrder.push(name);
1521
+ })
1522
+ );
1523
+ const failures = results.map((result, index) => ({ result, name: toStart[index] })).filter((entry) => entry.result.status === "rejected");
1524
+ if (failures.length > 0) {
1525
+ const reasons = failures.map((failure) => {
1526
+ const error = failure.result.reason;
1527
+ return error instanceof Error ? error.message : String(error);
1528
+ });
1529
+ for (const reason of reasons) {
1530
+ this.#bus.emit({ type: "notice", level: "error", message: reason, at: Date.now() });
1531
+ }
1532
+ await this.down("boot failed");
1533
+ throw new Error(`could not boot "${this.stack.name}":
1534
+ ${reasons.join("\n ")}`);
1535
+ }
1536
+ }
1537
+ this.#bus.emit({
1538
+ type: "stack:ready",
1539
+ stack: this.stack.name,
1540
+ durationMs: Date.now() - startedAt,
1541
+ at: Date.now()
1542
+ });
1543
+ }
1544
+ /** Reverse topological order, parallel within a level. */
1545
+ async down(reason) {
1546
+ if (this.#shuttingDown) return;
1547
+ this.#shuttingDown = true;
1548
+ this.#bus.emit({ type: "stack:stopping", stack: this.stack.name, at: Date.now() });
1549
+ if (reason) {
1550
+ this.#bus.emit({ type: "notice", level: "info", message: `stopping: ${reason}`, at: Date.now() });
1551
+ }
1552
+ for (const level of [...this.stack.levels].reverse()) {
1553
+ await Promise.all(
1554
+ level.map(async (name) => {
1555
+ const process2 = this.#processes.get(name);
1556
+ if (!process2) return;
1557
+ try {
1558
+ await process2.dispose();
1559
+ } catch (error) {
1560
+ this.#bus.emit({
1561
+ type: "notice",
1562
+ level: "error",
1563
+ service: name,
1564
+ message: `stop failed: ${error instanceof Error ? error.message : String(error)}`,
1565
+ at: Date.now()
1566
+ });
1567
+ }
1568
+ })
1569
+ );
1570
+ }
1571
+ this.#bus.emit({ type: "stack:stopped", stack: this.stack.name, at: Date.now() });
1572
+ await this.#logWriter?.close();
1573
+ this.#shuttingDown = false;
1574
+ }
1575
+ async restart(name) {
1576
+ const process2 = this.#requireProcess(name);
1577
+ await process2.restart();
1578
+ }
1579
+ async stopService(name) {
1580
+ await this.#requireProcess(name).stop();
1581
+ }
1582
+ async startService(name) {
1583
+ await this.#requireProcess(name).start();
1584
+ }
1585
+ /** Every service carrying `group`, in dependency order. */
1586
+ servicesInGroup(group) {
1587
+ return this.stack.services.filter((service) => service.groups.includes(group)).map((service) => service.name);
1588
+ }
1589
+ #requireProcess(name) {
1590
+ const process2 = this.#processes.get(name);
1591
+ if (!process2) {
1592
+ const available = [...this.#processes.keys()].join(", ");
1593
+ throw new Error(`no service "${name}" in stack "${this.stack.name}". Available: ${available}`);
1594
+ }
1595
+ return process2;
1596
+ }
1597
+ };
1598
+
1599
+ // src/cli/render/output.ts
1600
+ import "events";
1601
+ var closed = false;
1602
+ var handlers = /* @__PURE__ */ new Set();
1603
+ var onOutputClosed = (handler) => {
1604
+ handlers.add(handler);
1605
+ if (closed) handler();
1606
+ return () => {
1607
+ handlers.delete(handler);
1608
+ };
1609
+ };
1610
+ var markClosed = () => {
1611
+ if (closed) return;
1612
+ closed = true;
1613
+ for (const handler of handlers) {
1614
+ try {
1615
+ handler();
1616
+ } catch {
1617
+ }
1618
+ }
1619
+ };
1620
+ var isPipeError = (error) => error.code === "EPIPE" || error.code === "ERR_STREAM_DESTROYED" || error.code === "ERR_STREAM_WRITE_AFTER_END";
1621
+ var installPipeGuards = (streams = [process.stdout, process.stderr]) => {
1622
+ for (const stream of streams) {
1623
+ stream.on("error", (error) => {
1624
+ if (isPipeError(error)) {
1625
+ markClosed();
1626
+ return;
1627
+ }
1628
+ throw error;
1629
+ });
1630
+ }
1631
+ };
1632
+ var write = (text, stream = process.stdout) => {
1633
+ if (closed) return;
1634
+ try {
1635
+ stream.write(text);
1636
+ } catch (error) {
1637
+ if (isPipeError(error)) markClosed();
1638
+ else throw error;
1639
+ }
1640
+ };
1641
+
1642
+ // src/cli/render/plain.ts
1643
+ var time = (date) => `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}:${String(
1644
+ date.getSeconds()
1645
+ ).padStart(2, "0")}`;
1646
+ var attachPlainRenderer = (bus, options) => {
1647
+ const write2 = options.write ?? ((text) => write(text));
1648
+ const painter = options.painter ?? paint;
1649
+ const showTime = options.timestamps ?? true;
1650
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
1651
+ const colorOf = new Map(options.stack.services.map((service) => [service.name, asColorName(service.color)]));
1652
+ const width = Math.max(8, ...options.stack.services.map((service) => service.name.length), "laracrew".length);
1653
+ const line = (service, text, colorName = colorOf.get(service) ?? "cyan") => {
1654
+ const stamp2 = showTime ? painter("gray", `${time(now())} `) : "";
1655
+ const prefix = painter(colorName, padEnd(service, width));
1656
+ write2(`${stamp2}${prefix} ${painter("gray", "|")} ${text}
1657
+ `);
1658
+ };
1659
+ const system = (text, level = "info") => {
1660
+ const color = level === "error" ? "red" : level === "warn" ? "yellow" : "gray";
1661
+ line("laracrew", painter(color, text), "bold");
1662
+ };
1663
+ const handle = (event) => {
1664
+ switch (event.type) {
1665
+ case "service:log":
1666
+ line(event.log.service, event.log.line);
1667
+ break;
1668
+ case "stack:starting": {
1669
+ system(`starting ${event.stack} (${event.services.length} services)`);
1670
+ const manual = options.stack.services.filter((service) => !service.autostart && !service.external);
1671
+ if (manual.length > 0) {
1672
+ system(`not launched (autostart: false): ${manual.map((service) => service.name).join(", ")}`);
1673
+ }
1674
+ break;
1675
+ }
1676
+ case "stack:ready":
1677
+ system(`${event.stack} ready in ${(event.durationMs / 1e3).toFixed(1)}s`);
1678
+ break;
1679
+ case "stack:stopping":
1680
+ system(`stopping ${event.stack}\u2026`);
1681
+ break;
1682
+ case "stack:stopped":
1683
+ system(`${event.stack} stopped`);
1684
+ break;
1685
+ case "service:spawned":
1686
+ line(event.service, painter("gray", `$ ${event.command}`));
1687
+ break;
1688
+ case "service:ready":
1689
+ line(event.service, painter("green", `ready (${event.probe}) in ${event.durationMs}ms`));
1690
+ break;
1691
+ case "service:exit":
1692
+ if (!event.intentional) {
1693
+ line(
1694
+ event.service,
1695
+ painter("red", `exited (${event.signal ? `signal ${event.signal}` : `code ${event.code}`})`)
1696
+ );
1697
+ }
1698
+ break;
1699
+ case "service:restart":
1700
+ line(
1701
+ event.service,
1702
+ painter("yellow", `restarting in ${event.delayMs}ms (attempt ${event.attempt}/${event.maxAttempts})`)
1703
+ );
1704
+ break;
1705
+ case "service:state":
1706
+ if (event.to === "failed") {
1707
+ line(event.service, painter("red", `failed${event.detail ? `: ${event.detail}` : ""}`));
1708
+ }
1709
+ break;
1710
+ case "notice":
1711
+ if (event.service) {
1712
+ line(event.service, painter(event.level === "error" ? "red" : "yellow", event.message));
1713
+ } else {
1714
+ system(event.message, event.level);
1715
+ }
1716
+ break;
1717
+ default:
1718
+ break;
1719
+ }
1720
+ };
1721
+ return bus.subscribe(handle);
1722
+ };
1723
+
1724
+ // src/tui/model.ts
1725
+ var commandOf = (supervisor, name) => {
1726
+ const service = supervisor.stack.services.find((entry) => entry.name === name);
1727
+ if (!service?.command) return void 0;
1728
+ return service.command.kind === "shell" ? service.command.line : [service.command.file, ...service.command.args].join(" ");
1729
+ };
1730
+ var buildStackView = (supervisor) => {
1731
+ const all = supervisor.processes.map((managed) => ({
1732
+ index: -1,
1733
+ // assigned below, once externals are out of the way
1734
+ name: managed.service.name,
1735
+ state: managed.state,
1736
+ pid: managed.pid,
1737
+ uptimeMs: managed.uptimeMs,
1738
+ restarts: managed.restarts,
1739
+ external: managed.service.external,
1740
+ autostart: managed.service.autostart,
1741
+ probe: managed.service.ready ? describeProbe(managed.service.ready) : void 0,
1742
+ project: managed.service.project?.key,
1743
+ command: commandOf(supervisor, managed.service.name),
1744
+ cwd: managed.service.cwd,
1745
+ url: managed.service.url,
1746
+ groups: managed.service.groups,
1747
+ logLines: managed.logs.size
1748
+ }));
1749
+ const dependencies2 = all.filter((row) => row.external);
1750
+ const rows = all.filter((row) => !row.external).map((row, index) => ({ ...row, index }));
1751
+ return {
1752
+ name: supervisor.stack.name,
1753
+ rows,
1754
+ dependencies: dependencies2,
1755
+ running: rows.filter((row) => row.state === "running" || row.state === "ready").length,
1756
+ total: rows.length,
1757
+ restarts: rows.reduce((sum, row) => sum + row.restarts, 0),
1758
+ failed: rows.filter((row) => row.state === "failed" || row.state === "crashed").length,
1759
+ dependenciesDown: dependencies2.filter((row) => row.state !== "ready" && row.state !== "running").length
1760
+ };
1761
+ };
1762
+ var logsFor = (supervisor, name, limit) => supervisor.get(name)?.logs.tail(limit) ?? [];
1763
+ var mergedLogs = (supervisor, limit) => {
1764
+ const perService = Math.max(20, Math.ceil(limit / Math.max(1, supervisor.processes.length)) * 4);
1765
+ const all = [];
1766
+ for (const managed of supervisor.processes) all.push(...managed.logs.tail(perService));
1767
+ all.sort((a, b) => a.at - b.at);
1768
+ return all.slice(-limit);
1769
+ };
1770
+
1771
+ // src/tui/keys.ts
1772
+ var none = (ui) => ({ ui, command: { type: "none" } });
1773
+ var handleKey = (ui, key, rowCount) => {
1774
+ if (key.ctrl && key.name === "c") {
1775
+ return { ui: { ...ui, confirmQuit: false }, command: { type: ui.phase === "stopping" ? "force-quit" : "quit" } };
1776
+ }
1777
+ if (ui.confirmQuit) {
1778
+ if (key.name === "y" || key.name === "return") {
1779
+ return { ui: { ...ui, confirmQuit: false }, command: { type: "quit" } };
1780
+ }
1781
+ return none({ ...ui, confirmQuit: false, note: "Ready for commands." });
1782
+ }
1783
+ if (ui.view === "help") {
1784
+ return none({ ...ui, view: "overview" });
1785
+ }
1786
+ const clamp = (index) => Math.min(Math.max(0, index), Math.max(0, rowCount - 1));
1787
+ const inLogView = ui.view === "log" || ui.view === "merged";
1788
+ switch (key.name) {
1789
+ case "q":
1790
+ return none({ ...ui, confirmQuit: true });
1791
+ case "?":
1792
+ return none({ ...ui, view: "help" });
1793
+ case "escape":
1794
+ return none({ ...ui, view: "overview", scrollBack: 0, follow: true });
1795
+ case "s":
1796
+ if (inLogView) return none({ ...ui, view: "overview", scrollBack: 0, follow: true });
1797
+ return { ui, command: { type: "toggle", index: ui.selected } };
1798
+ case "t":
1799
+ return { ui, command: { type: "toggle", index: ui.selected } };
1800
+ case "r":
1801
+ return { ui, command: { type: "restart", index: ui.selected } };
1802
+ case "a":
1803
+ return none({ ...ui, view: ui.view === "merged" ? "overview" : "merged", scrollBack: 0, follow: true });
1804
+ case "return":
1805
+ case "v":
1806
+ if (ui.view === "overview") return none({ ...ui, view: "log", scrollBack: 0, follow: true });
1807
+ return none(ui);
1808
+ case "up":
1809
+ case "k":
1810
+ if (inLogView) return none({ ...ui, scrollBack: ui.scrollBack + 1, follow: false });
1811
+ return none({ ...ui, selected: clamp(ui.selected - 1) });
1812
+ case "down":
1813
+ case "j":
1814
+ if (inLogView) {
1815
+ const scrollBack = Math.max(0, ui.scrollBack - 1);
1816
+ return none({ ...ui, scrollBack, follow: scrollBack === 0 });
1817
+ }
1818
+ return none({ ...ui, selected: clamp(ui.selected + 1) });
1819
+ case "pageup":
1820
+ if (inLogView) return none({ ...ui, scrollBack: ui.scrollBack + 10, follow: false });
1821
+ return none({ ...ui, selected: clamp(ui.selected - 5) });
1822
+ case "pagedown":
1823
+ if (inLogView) {
1824
+ const scrollBack = Math.max(0, ui.scrollBack - 10);
1825
+ return none({ ...ui, scrollBack, follow: scrollBack === 0 });
1826
+ }
1827
+ return none({ ...ui, selected: clamp(ui.selected + 5) });
1828
+ case "g":
1829
+ if (inLogView) {
1830
+ return key.shift ? none({ ...ui, scrollBack: 0, follow: true }) : none({ ...ui, scrollBack: Number.MAX_SAFE_INTEGER, follow: false });
1831
+ }
1832
+ return none({ ...ui, selected: key.shift ? clamp(rowCount - 1) : 0 });
1833
+ case "f":
1834
+ if (inLogView) return none({ ...ui, follow: !ui.follow, scrollBack: ui.follow ? ui.scrollBack : 0 });
1835
+ return none(ui);
1836
+ case "home":
1837
+ return inLogView ? none({ ...ui, scrollBack: Number.MAX_SAFE_INTEGER, follow: false }) : none({ ...ui, selected: 0 });
1838
+ case "end":
1839
+ return inLogView ? none({ ...ui, scrollBack: 0, follow: true }) : none({ ...ui, selected: clamp(rowCount - 1) });
1840
+ default: {
1841
+ if (/^[0-9]$/.test(key.name)) {
1842
+ const index = Number(key.name);
1843
+ if (index < rowCount) {
1844
+ return none({ ...ui, selected: index, view: "log", scrollBack: 0, follow: true });
1845
+ }
1846
+ }
1847
+ return none(ui);
1848
+ }
1849
+ }
1850
+ };
1851
+
1852
+ // src/tui/theme.ts
1853
+ var asciiOnly = (env = process.env) => env.LARACREW_ASCII === "1" || env.TERM === "dumb";
1854
+ var UNICODE = {
1855
+ running: "\u25CF",
1856
+ ready: "\u25CF",
1857
+ starting: "\u25D0",
1858
+ stopped: "\u25FC",
1859
+ failed: "\u2716",
1860
+ queued: "\u25CB",
1861
+ branch: "\u251C\u2500",
1862
+ lastBranch: "\u2514\u2500",
1863
+ pipe: "\u2502",
1864
+ pointer: "\u25B8",
1865
+ restart: "\u27F3",
1866
+ rule: "\u2500",
1867
+ dot: "\xB7",
1868
+ tick: "\u2714"
1869
+ };
1870
+ var ASCII = {
1871
+ running: "*",
1872
+ ready: "*",
1873
+ starting: "o",
1874
+ stopped: "#",
1875
+ failed: "X",
1876
+ queued: ".",
1877
+ branch: "|-",
1878
+ lastBranch: "`-",
1879
+ pipe: "|",
1880
+ pointer: ">",
1881
+ restart: "r",
1882
+ rule: "-",
1883
+ dot: ".",
1884
+ tick: "v"
1885
+ };
1886
+ var glyphs = (env = process.env) => asciiOnly(env) ? ASCII : UNICODE;
1887
+ var STATE_STYLE = {
1888
+ queued: { glyph: "queued", color: "gray", label: "QUEUED" },
1889
+ starting: { glyph: "starting", color: "yellow", label: "STARTING" },
1890
+ ready: { glyph: "ready", color: "cyan", label: "READY" },
1891
+ running: { glyph: "running", color: "green", label: "RUNNING" },
1892
+ stopping: { glyph: "starting", color: "yellow", label: "STOPPING" },
1893
+ stopped: { glyph: "stopped", color: "gray", label: "STOPPED" },
1894
+ crashed: { glyph: "failed", color: "red", label: "CRASHED" },
1895
+ backoff: { glyph: "starting", color: "yellow", label: "RESTARTING" },
1896
+ failed: { glyph: "failed", color: "red", label: "FAILED" }
1897
+ };
1898
+ var formatUptime = (ms) => {
1899
+ if (ms === void 0 || ms < 0) return "--:--:--";
1900
+ const total = Math.floor(ms / 1e3);
1901
+ const hours = Math.floor(total / 3600);
1902
+ const minutes = Math.floor(total % 3600 / 60);
1903
+ const seconds = total % 60;
1904
+ return [hours, minutes, seconds].map((part) => String(part).padStart(2, "0")).join(":");
1905
+ };
1906
+ var formatDuration = (ms) => {
1907
+ if (ms === void 0 || ms < 0) return "\u2014";
1908
+ const total = Math.floor(ms / 1e3);
1909
+ if (total < 60) return `${total}s`;
1910
+ const minutes = Math.floor(total / 60);
1911
+ const seconds = total % 60;
1912
+ if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
1913
+ const hours = Math.floor(minutes / 60);
1914
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
1915
+ };
1916
+ var formatClock = (at) => {
1917
+ const date = new Date(at);
1918
+ return [date.getHours(), date.getMinutes(), date.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
1919
+ };
1920
+
1921
+ // src/tui/screen.ts
1922
+ var initialUiState = () => ({
1923
+ view: "overview",
1924
+ selected: 0,
1925
+ follow: true,
1926
+ scrollBack: 0,
1927
+ confirmQuit: false,
1928
+ phase: "booting",
1929
+ note: "Booting\u2026"
1930
+ });
1931
+ var pad = (text, width) => {
1932
+ const visible = stripAnsi(text).length;
1933
+ return visible >= width ? text : text + " ".repeat(width - visible);
1934
+ };
1935
+ var truncate = (text, width) => {
1936
+ if (width <= 1) return "";
1937
+ return stripAnsi(text).length <= width ? text : `${text.slice(0, Math.max(0, width - 1))}\u2026`;
1938
+ };
1939
+ var rule = (width, glyph) => glyph.rule.repeat(Math.max(0, width));
1940
+ var header = ({ stack, ui, size, paint: paint2, glyph, uptimeMs }) => {
1941
+ const title = `laracrew ${glyph.dot} ${stack.name}`;
1942
+ const health = stack.failed > 0 ? paint2("red", `${stack.failed} failed`) : stack.dependenciesDown > 0 ? paint2("red", `${stack.dependenciesDown} dependency down`) : ui.phase === "booting" ? paint2("yellow", "booting") : paint2("green", "all healthy");
1943
+ const idle = stack.rows.filter((entry) => !entry.autostart && entry.state === "queued").length;
1944
+ const right = [
1945
+ `up ${formatUptime(uptimeMs)}`,
1946
+ `${stack.running}/${stack.total} running`,
1947
+ idle > 0 ? `${idle} idle` : "",
1948
+ stack.restarts > 0 ? `${glyph.restart}${stack.restarts}` : ""
1949
+ ].filter(Boolean).join(` ${glyph.dot} `);
1950
+ const left = ` ${paint2("bold", title)}`;
1951
+ const gap = Math.max(1, size.columns - stripAnsi(left).length - stripAnsi(right).length - stripAnsi(health).length - 5);
1952
+ return [
1953
+ "",
1954
+ `${left}${" ".repeat(gap)}${paint2("gray", right)} ${health}`,
1955
+ paint2("gray", ` ${rule(Math.max(0, size.columns - 4), glyph)}`)
1956
+ ];
1957
+ };
1958
+ var KEYS = {
1959
+ overview: [
1960
+ ["up/down", "select"],
1961
+ ["enter", "inspect log"],
1962
+ ["0-9", "jump"],
1963
+ ["r", "restart"],
1964
+ ["s", "stop/start"],
1965
+ ["a", "all logs"],
1966
+ ["?", "help"],
1967
+ ["q", "quit"]
1968
+ ],
1969
+ log: [
1970
+ ["esc", "back to stack"],
1971
+ ["up/down", "scroll"],
1972
+ ["g/G", "top/bottom"],
1973
+ ["f", "follow"],
1974
+ ["r", "restart"],
1975
+ ["q", "quit"]
1976
+ ],
1977
+ merged: [
1978
+ ["esc", "back to stack"],
1979
+ ["up/down", "scroll"],
1980
+ ["g/G", "top/bottom"],
1981
+ ["f", "follow"],
1982
+ ["q", "quit"]
1983
+ ],
1984
+ help: [["any key", "back"]]
1985
+ };
1986
+ var legend = ({ ui, paint: paint2, size }) => {
1987
+ const pairs = KEYS[ui.view].map(([key, label]) => `${paint2("cyan", key)} ${paint2("gray", label)}`);
1988
+ const lines = [];
1989
+ let current = " ";
1990
+ let currentWidth = 2;
1991
+ for (const pair of pairs) {
1992
+ const width = stripAnsi(pair).length + 3;
1993
+ if (currentWidth + width > size.columns - 2 && currentWidth > 2) {
1994
+ lines.push(current);
1995
+ current = " ";
1996
+ currentWidth = 2;
1997
+ }
1998
+ current += `${pair} `;
1999
+ currentWidth += width;
2000
+ }
2001
+ if (currentWidth > 2) lines.push(current);
2002
+ return lines;
2003
+ };
2004
+ var processLine = (row, isSelected, isLast, nameWidth, { paint: paint2, glyph, size }) => {
2005
+ const style = STATE_STYLE[row.state];
2006
+ const branch = isLast ? glyph.lastBranch : glyph.branch;
2007
+ const pointer = isSelected ? paint2("cyan", glyph.pointer) : " ";
2008
+ const name = isSelected ? paint2("cyan", pad(row.name, nameWidth)) : pad(row.name, nameWidth);
2009
+ const manual = !row.autostart && row.state === "queued";
2010
+ const label = manual ? "idle" : style.label.toLowerCase();
2011
+ const labelColor = manual ? "gray" : style.color;
2012
+ const detail = row.external ? paint2("gray", "external") : manual ? paint2("cyan", "press s") : row.state === "running" || row.state === "ready" ? paint2("gray", formatDuration(row.uptimeMs)) : "";
2013
+ const restarts = row.restarts > 0 ? paint2("yellow", ` ${glyph.restart}${row.restarts}`) : "";
2014
+ const line = ` ${paint2("gray", branch)} ${pointer} ${paint2("gray", `[${row.index}]`)} ${paint2(labelColor, glyph[style.glyph])} ${name}${paint2(labelColor, pad(label, 11))}${pad(detail, 9)}${restarts}`;
2015
+ return truncate(line, size.columns + (line.length - stripAnsi(line).length));
2016
+ };
2017
+ var dependencyLine = (row, nameWidth, { paint: paint2, glyph, size }) => {
2018
+ const style = STATE_STYLE[row.state];
2019
+ const label = row.state === "ready" || row.state === "running" ? "ready" : style.label.toLowerCase();
2020
+ const color = row.state === "ready" || row.state === "running" ? "green" : style.color;
2021
+ const line = ` ${paint2(color, glyph[style.glyph])} ${pad(row.name, nameWidth)}${paint2(color, pad(label, 11))}${paint2("gray", row.probe ?? "")}`;
2022
+ return truncate(line, size.columns + (line.length - stripAnsi(line).length));
2023
+ };
2024
+ var dependencies = (input, nameWidth) => {
2025
+ const { stack, paint: paint2 } = input;
2026
+ if (stack.dependencies.length === 0) return [];
2027
+ return [
2028
+ ` ${paint2("bold", "DEPENDENT SERVICES")} ${paint2("gray", "(checked, not managed)")}`,
2029
+ ...stack.dependencies.map((row) => dependencyLine(row, nameWidth, input)),
2030
+ ""
2031
+ ];
2032
+ };
2033
+ var overview = (input, bodyRows) => {
2034
+ const { stack, ui, paint: paint2, glyph } = input;
2035
+ const nameWidth = Math.max(12, ...[...stack.rows, ...stack.dependencies].map((row) => row.name.length)) + 2;
2036
+ const dependencyLines = dependencies(input, nameWidth);
2037
+ const lines = [
2038
+ ` ${paint2("bold", "STACK DETAILS")} ${paint2("gray", "(process tree)")}`,
2039
+ "",
2040
+ ...dependencyLines,
2041
+ ` ${paint2("gray", `processes (${stack.rows.length} managed)`)}`,
2042
+ paint2("gray", ` ${glyph.pipe}`)
2043
+ ];
2044
+ if (stack.rows.length === 0) {
2045
+ lines.push(` ${paint2("gray", `${glyph.lastBranch} nothing to manage in this stack`)}`);
2046
+ return lines;
2047
+ }
2048
+ const capacity = Math.max(3, bodyRows - 5 - dependencyLines.length);
2049
+ let start = 0;
2050
+ if (stack.rows.length > capacity) {
2051
+ start = Math.min(Math.max(0, ui.selected - Math.floor(capacity / 2)), stack.rows.length - capacity);
2052
+ }
2053
+ const visible = stack.rows.slice(start, start + capacity);
2054
+ if (start > 0) lines.push(paint2("gray", ` ${glyph.pipe} ${start} more above`));
2055
+ for (const [offset, row] of visible.entries()) {
2056
+ const index = start + offset;
2057
+ lines.push(processLine(row, index === ui.selected, index === stack.rows.length - 1, nameWidth, input));
2058
+ }
2059
+ const hiddenBelow = stack.rows.length - (start + visible.length);
2060
+ if (hiddenBelow > 0) lines.push(paint2("gray", ` ${glyph.pipe} ${hiddenBelow} more below`));
2061
+ lines.push(paint2("gray", ` ${glyph.pipe}`));
2062
+ lines.push(` ${paint2("gray", `${glyph.lastBranch} ${input.ui.note}`)}`);
2063
+ return lines;
2064
+ };
2065
+ var logHeader = (row, input) => {
2066
+ const { paint: paint2, glyph, size } = input;
2067
+ if (!row) return [` ${paint2("gray", "no process selected")}`];
2068
+ const style = STATE_STYLE[row.state];
2069
+ const meta = [
2070
+ paint2(style.color, style.label.toLowerCase()),
2071
+ row.pid !== void 0 ? paint2("gray", `pid ${row.pid}`) : "",
2072
+ row.state === "running" ? paint2("gray", `up ${formatDuration(row.uptimeMs)}`) : "",
2073
+ row.restarts > 0 ? paint2("yellow", `${glyph.restart}${row.restarts}`) : ""
2074
+ ].filter(Boolean).join(paint2("gray", ` ${glyph.dot} `));
2075
+ const lines = [` ${paint2("bold", row.name)} ${meta}`];
2076
+ if (row.command) lines.push(` ${paint2("gray", truncate(`$ ${row.command}`, size.columns - 4))}`);
2077
+ lines.push(` ${paint2("gray", truncate(row.cwd, size.columns - 4))}`);
2078
+ lines.push(paint2("gray", ` ${rule(Math.max(0, size.columns - 4), glyph)}`));
2079
+ return lines;
2080
+ };
2081
+ var logBody = (input, bodyRows, withServiceName) => {
2082
+ const { logs, ui, paint: paint2, size } = input;
2083
+ const capacity = Math.max(1, bodyRows);
2084
+ if (logs.length === 0) {
2085
+ return [` ${paint2("gray", "no output yet")}`];
2086
+ }
2087
+ const end = Math.max(capacity, logs.length - ui.scrollBack);
2088
+ const window = logs.slice(Math.max(0, end - capacity), end);
2089
+ const nameWidth = withServiceName ? Math.max(...logs.map((line) => line.service.length)) : 0;
2090
+ return window.map((line) => {
2091
+ const stamp2 = paint2("gray", formatClock(line.at));
2092
+ const who = withServiceName ? ` ${paint2("cyan", pad(line.service, nameWidth))}` : "";
2093
+ const body = line.stream === "stderr" ? paint2("red", line.line) : line.line;
2094
+ const rendered = ` ${stamp2}${who} ${paint2("gray", "|")} ${body}`;
2095
+ return truncate(rendered, size.columns + (rendered.length - stripAnsi(rendered).length));
2096
+ });
2097
+ };
2098
+ var help = ({ paint: paint2, glyph }) => [
2099
+ ` ${paint2("bold", "KEYS")}`,
2100
+ "",
2101
+ ...[
2102
+ ["up / down, j / k", "move the selection"],
2103
+ ["0 - 9", "jump straight to that process and open its log"],
2104
+ ["enter, v", "inspect the selected process log"],
2105
+ ["esc, s", "back to the stack overview"],
2106
+ ["a", "merged log across every service"],
2107
+ ["r", "restart the selected process (graceful)"],
2108
+ ["t", "toggle the selected process: stop if running, start if stopped"],
2109
+ ["f", "follow / pause tailing in a log view"],
2110
+ ["g / G", "jump to the top / bottom of a log"],
2111
+ ["?", "this help"],
2112
+ ["q, ctrl-c", "quit \u2014 stops every process first"]
2113
+ ].map(([key, label]) => ` ${paint2("cyan", pad(key, 20))}${paint2("gray", label)}`),
2114
+ "",
2115
+ ` ${paint2("gray", `The overview never streams logs \u2014 that is deliberate. Open one ${glyph.dot} read it ${glyph.dot} go back.`)}`
2116
+ ];
2117
+ var renderShutdown = (input) => {
2118
+ const { stack, paint: paint2, glyph } = input;
2119
+ const lines = ["", ` ${paint2("bold", `Stopping ${stack.name}\u2026`)}`, ""];
2120
+ for (const row of stack.rows) {
2121
+ const done = row.state === "stopped" || row.state === "failed";
2122
+ const mark = done ? paint2("green", glyph.tick) : paint2("yellow", glyph.starting);
2123
+ const label = done ? paint2("gray", "stopped") : paint2("yellow", "stopping\u2026");
2124
+ lines.push(` ${mark} ${pad(row.name, 22)}${label}`);
2125
+ }
2126
+ lines.push("", ` ${paint2("gray", "ctrl-c again to force")}`);
2127
+ return lines;
2128
+ };
2129
+ var renderFrame = (input) => {
2130
+ if (input.ui.phase === "stopping") return renderShutdown(input);
2131
+ const top = header(input);
2132
+ const keys = legend(input);
2133
+ const chrome = top.length + keys.length + 3;
2134
+ const bodyRows = Math.max(3, input.size.rows - chrome);
2135
+ let body;
2136
+ switch (input.ui.view) {
2137
+ case "help":
2138
+ body = help(input);
2139
+ break;
2140
+ case "log": {
2141
+ const row = input.stack.rows[input.ui.selected];
2142
+ const head = logHeader(row, input);
2143
+ body = [...head, ...logBody(input, bodyRows - head.length, false)];
2144
+ break;
2145
+ }
2146
+ case "merged":
2147
+ body = [
2148
+ ` ${input.paint("bold", "ALL SERVICES")}`,
2149
+ input.paint("gray", ` ${rule(Math.max(0, input.size.columns - 4), input.glyph)}`),
2150
+ ...logBody(input, bodyRows - 2, true)
2151
+ ];
2152
+ break;
2153
+ default:
2154
+ body = overview(input, bodyRows);
2155
+ }
2156
+ const status = input.ui.confirmQuit ? ` ${input.paint("yellow", "Quit and stop every process? (y/n)")}` : input.ui.view !== "overview" ? ` ${input.paint("gray", input.ui.note)}` : "";
2157
+ return [...top, "", ...keys, ...body, "", ...status ? [status] : []];
2158
+ };
2159
+
2160
+ // src/tui/terminal.ts
2161
+ import readline from "readline";
2162
+ var ESC2 = String.fromCharCode(27);
2163
+ var CSI2 = `${ESC2}[`;
2164
+ var codes = {
2165
+ altScreenOn: `${CSI2}?1049h`,
2166
+ altScreenOff: `${CSI2}?1049l`,
2167
+ hideCursor: `${CSI2}?25l`,
2168
+ showCursor: `${CSI2}?25h`,
2169
+ home: `${CSI2}H`,
2170
+ clearLine: `${CSI2}K`,
2171
+ clearBelow: `${CSI2}J`
2172
+ };
2173
+ var Terminal = class {
2174
+ #stdout;
2175
+ #stdin;
2176
+ #lastFrame = [];
2177
+ #active = false;
2178
+ #onKey;
2179
+ #onResize;
2180
+ #keyListener;
2181
+ #resizeListener;
2182
+ constructor(options = {}) {
2183
+ this.#stdout = options.stdout ?? process.stdout;
2184
+ this.#stdin = options.stdin ?? process.stdin;
2185
+ }
2186
+ get size() {
2187
+ return {
2188
+ columns: Math.max(40, this.#stdout.columns ?? 80),
2189
+ rows: Math.max(10, this.#stdout.rows ?? 24)
2190
+ };
2191
+ }
2192
+ start(handlers2) {
2193
+ if (this.#active) return;
2194
+ this.#active = true;
2195
+ this.#onKey = handlers2.onKey;
2196
+ if (handlers2.onResize) this.#onResize = handlers2.onResize;
2197
+ this.#stdout.write(codes.altScreenOn + codes.hideCursor);
2198
+ readline.emitKeypressEvents(this.#stdin);
2199
+ if (this.#stdin.isTTY) this.#stdin.setRawMode(true);
2200
+ this.#stdin.resume();
2201
+ this.#keyListener = (_str, key) => {
2202
+ if (!key) return;
2203
+ this.#onKey?.({
2204
+ name: key.name ?? key.sequence ?? "",
2205
+ ...key.ctrl ? { ctrl: true } : {},
2206
+ ...key.shift ? { shift: true } : {}
2207
+ });
2208
+ };
2209
+ this.#stdin.on("keypress", this.#keyListener);
2210
+ this.#resizeListener = () => {
2211
+ this.#lastFrame = [];
2212
+ this.#onResize?.();
2213
+ };
2214
+ this.#stdout.on("resize", this.#resizeListener);
2215
+ }
2216
+ /** Repaints only the lines that changed. */
2217
+ paint(lines) {
2218
+ if (!this.#active) return;
2219
+ const { rows, columns } = this.size;
2220
+ const frame = lines.slice(0, rows - 1).map((line) => clip(line, columns));
2221
+ let output = codes.home;
2222
+ const height = Math.max(frame.length, this.#lastFrame.length);
2223
+ for (let index = 0; index < height; index += 1) {
2224
+ const line = frame[index] ?? "";
2225
+ if (this.#lastFrame[index] === line) {
2226
+ output += `${CSI2}${index + 2};1H`;
2227
+ continue;
2228
+ }
2229
+ output += `${CSI2}${index + 1};1H${line}${codes.clearLine}`;
2230
+ }
2231
+ output += codes.clearBelow;
2232
+ this.#stdout.write(output);
2233
+ this.#lastFrame = frame;
2234
+ }
2235
+ stop() {
2236
+ if (!this.#active) return;
2237
+ this.#active = false;
2238
+ if (this.#keyListener) this.#stdin.off("keypress", this.#keyListener);
2239
+ if (this.#resizeListener) this.#stdout.off("resize", this.#resizeListener);
2240
+ if (this.#stdin.isTTY) this.#stdin.setRawMode(false);
2241
+ this.#stdin.pause();
2242
+ this.#stdout.write(codes.showCursor + codes.altScreenOff);
2243
+ this.#lastFrame = [];
2244
+ }
2245
+ };
2246
+ var clip = (line, columns) => {
2247
+ const visible = stripAnsi(line).length;
2248
+ if (visible <= columns) return line;
2249
+ let out = "";
2250
+ let width = 0;
2251
+ let index = 0;
2252
+ while (index < line.length && width < columns) {
2253
+ if (line[index] === ESC2) {
2254
+ const end = line.indexOf("m", index);
2255
+ if (end === -1) break;
2256
+ out += line.slice(index, end + 1);
2257
+ index = end + 1;
2258
+ continue;
2259
+ }
2260
+ out += line[index];
2261
+ width += 1;
2262
+ index += 1;
2263
+ }
2264
+ return out;
2265
+ };
2266
+
2267
+ // src/tui/app.ts
2268
+ var MAX_LOG_LINES = 400;
2269
+ var runTui = async (supervisor, bus, options = {}) => {
2270
+ const env = options.env ?? process.env;
2271
+ const stdout = options.stdout ?? process.stdout;
2272
+ const terminal = new Terminal({
2273
+ stdout,
2274
+ ...options.stdin ? { stdin: options.stdin } : {}
2275
+ });
2276
+ const basePainter = createPainter(supportsColor(stdout, env));
2277
+ const paint2 = Object.assign(
2278
+ (color, text) => basePainter(color, text),
2279
+ { enabled: basePainter.enabled }
2280
+ );
2281
+ const glyph = glyphs(env);
2282
+ const startedAt = Date.now();
2283
+ let ui = initialUiState();
2284
+ let exitCode = 0;
2285
+ let stopping = false;
2286
+ let finished = false;
2287
+ const setNote = (note) => {
2288
+ ui = { ...ui, note };
2289
+ };
2290
+ const unsubscribe = bus.subscribe((event) => {
2291
+ switch (event.type) {
2292
+ case "stack:ready":
2293
+ ui = { ...ui, phase: "running" };
2294
+ setNote(`Ready in ${(event.durationMs / 1e3).toFixed(1)}s. Awaiting keyboard input\u2026`);
2295
+ break;
2296
+ case "service:restart":
2297
+ setNote(`${event.service} restarting in ${event.delayMs}ms (attempt ${event.attempt}/${event.maxAttempts})`);
2298
+ break;
2299
+ case "service:exit":
2300
+ if (!event.intentional) {
2301
+ setNote(`${event.service} exited with ${event.signal ? `signal ${event.signal}` : `code ${event.code}`}`);
2302
+ }
2303
+ break;
2304
+ case "notice":
2305
+ if (event.level !== "info") setNote(`${event.service ? `${event.service}: ` : ""}${event.message}`);
2306
+ break;
2307
+ default:
2308
+ break;
2309
+ }
2310
+ });
2311
+ const draw = () => {
2312
+ const stack = buildStackView(supervisor);
2313
+ const selectedRow = stack.rows[ui.selected];
2314
+ const logs = ui.view === "log" && selectedRow ? logsFor(supervisor, selectedRow.name, MAX_LOG_LINES) : ui.view === "merged" ? mergedLogs(supervisor, MAX_LOG_LINES) : [];
2315
+ terminal.paint(
2316
+ renderFrame({
2317
+ stack,
2318
+ ui,
2319
+ size: terminal.size,
2320
+ glyph,
2321
+ paint: paint2,
2322
+ logs,
2323
+ uptimeMs: Date.now() - startedAt
2324
+ })
2325
+ );
2326
+ };
2327
+ const shutdown = async (reason) => {
2328
+ if (stopping) return;
2329
+ stopping = true;
2330
+ ui = { ...ui, phase: "stopping" };
2331
+ draw();
2332
+ await supervisor.down(reason);
2333
+ finished = true;
2334
+ };
2335
+ const perform = (command) => {
2336
+ const stack = buildStackView(supervisor);
2337
+ const row = stack.rows[command.type === "none" || command.type === "quit" || command.type === "force-quit" ? ui.selected : command.index];
2338
+ switch (command.type) {
2339
+ case "quit":
2340
+ void shutdown("user quit");
2341
+ break;
2342
+ case "force-quit":
2343
+ terminal.stop();
2344
+ process.exit(130);
2345
+ break;
2346
+ case "restart":
2347
+ if (!row) break;
2348
+ setNote(`restarting ${row.name}\u2026`);
2349
+ void supervisor.restart(row.name).then(() => setNote(`${row.name} restarted`)).catch((error) => setNote(`restart failed: ${error instanceof Error ? error.message : String(error)}`));
2350
+ break;
2351
+ case "toggle": {
2352
+ if (!row) break;
2353
+ if (row.external) {
2354
+ setNote(`${row.name} is external \u2014 laracrew does not start or stop it`);
2355
+ break;
2356
+ }
2357
+ const live = row.state === "running" || row.state === "ready" || row.state === "starting";
2358
+ setNote(`${live ? "stopping" : "starting"} ${row.name}\u2026`);
2359
+ const action = live ? supervisor.stopService(row.name) : supervisor.startService(row.name);
2360
+ void action.then(() => setNote(`${row.name} ${live ? "stopped" : "started"}`)).catch((error) => setNote(error instanceof Error ? error.message : String(error)));
2361
+ break;
2362
+ }
2363
+ default:
2364
+ break;
2365
+ }
2366
+ };
2367
+ terminal.start({
2368
+ onKey: (key) => {
2369
+ const rowCount = buildStackView(supervisor).rows.length;
2370
+ const result = handleKey(ui, key, rowCount);
2371
+ ui = result.ui;
2372
+ perform(result.command);
2373
+ draw();
2374
+ },
2375
+ onResize: draw
2376
+ });
2377
+ const timer = setInterval(draw, options.frameMs ?? 250);
2378
+ timer.unref?.();
2379
+ const onSignal = () => {
2380
+ void shutdown("received signal");
2381
+ };
2382
+ process.on("SIGINT", onSignal);
2383
+ process.on("SIGTERM", onSignal);
2384
+ draw();
2385
+ try {
2386
+ await supervisor.up();
2387
+ } catch (error) {
2388
+ ui = { ...ui, phase: "running" };
2389
+ setNote(error instanceof Error ? error.message : String(error));
2390
+ exitCode = 1;
2391
+ draw();
2392
+ }
2393
+ await new Promise((resolve) => {
2394
+ const check = setInterval(() => {
2395
+ if (finished) {
2396
+ clearInterval(check);
2397
+ resolve();
2398
+ return;
2399
+ }
2400
+ if (stopping) return;
2401
+ const anyLive = supervisor.processes.some(
2402
+ (managed) => managed.state !== "stopped" && managed.state !== "failed"
2403
+ );
2404
+ if (!anyLive) {
2405
+ clearInterval(check);
2406
+ void shutdown("all services finished").then(resolve);
2407
+ }
2408
+ }, 200);
2409
+ check.unref?.();
2410
+ });
2411
+ clearInterval(timer);
2412
+ process.off("SIGINT", onSignal);
2413
+ process.off("SIGTERM", onSignal);
2414
+ unsubscribe();
2415
+ terminal.stop();
2416
+ return exitCode;
2417
+ };
2418
+
2419
+ // src/cli/commands/up.ts
2420
+ var splitList = (values) => (values ?? []).flatMap((value) => value.split(",")).map((value) => value.trim()).filter(Boolean);
2421
+ var prepareStack = (name, options = {}) => {
2422
+ const env = options.env ?? process.env;
2423
+ const loaded = loadStack(name, env);
2424
+ return resolveStack(loaded, {
2425
+ sharedProjects: loadSharedProjects(env),
2426
+ env,
2427
+ ...options.profile ? { profile: options.profile } : {},
2428
+ only: splitList(options.only),
2429
+ except: splitList(options.except)
2430
+ });
2431
+ };
2432
+ var attachJsonRenderer = (bus) => bus.subscribe((event) => process.stdout.write(`${JSON.stringify(event)}
2433
+ `));
2434
+ var shouldUseTui = (options, stdout = process.stdout) => !options.plain && !options.json && Boolean(stdout.isTTY);
2435
+ var upCommand = async (name, options = {}) => {
2436
+ const stack = prepareStack(name, options);
2437
+ const bus = new EventBus();
2438
+ if (shouldUseTui(options)) {
2439
+ const supervisor2 = new Supervisor(stack, bus, options.env ? { env: options.env } : {});
2440
+ return runTui(supervisor2, bus, options.env ? { env: options.env } : {});
2441
+ }
2442
+ const detach = options.json ? attachJsonRenderer(bus) : attachPlainRenderer(bus, { stack });
2443
+ const supervisor = new Supervisor(stack, bus, options.env ? { env: options.env } : {});
2444
+ let exitCode = 0;
2445
+ let stopping = false;
2446
+ const shutdown = async (reason) => {
2447
+ if (stopping) return;
2448
+ stopping = true;
2449
+ await supervisor.down(reason);
2450
+ };
2451
+ const onSignal = (signal) => {
2452
+ if (stopping) {
2453
+ console.error(paint("red", "\nforced exit \u2014 some children may survive"));
2454
+ process.exit(130);
2455
+ }
2456
+ void shutdown(`received ${signal}`);
2457
+ };
2458
+ process.on("SIGINT", onSignal);
2459
+ process.on("SIGTERM", onSignal);
2460
+ const releaseOutputHook = onOutputClosed(() => {
2461
+ void shutdown("output closed");
2462
+ });
2463
+ try {
2464
+ await supervisor.up();
2465
+ await waitUntilIdle(supervisor, () => stopping);
2466
+ } catch (error) {
2467
+ console.error(paint("red", error instanceof Error ? error.message : String(error)));
2468
+ exitCode = 1;
2469
+ } finally {
2470
+ await shutdown("done");
2471
+ process.off("SIGINT", onSignal);
2472
+ process.off("SIGTERM", onSignal);
2473
+ releaseOutputHook();
2474
+ detach();
2475
+ }
2476
+ return exitCode;
2477
+ };
2478
+ var waitUntilIdle = (supervisor, stopRequested) => new Promise((resolve) => {
2479
+ const check = () => {
2480
+ if (stopRequested()) {
2481
+ clearInterval(timer);
2482
+ resolve();
2483
+ return;
2484
+ }
2485
+ const anyLive = supervisor.processes.some(
2486
+ (process2) => process2.state !== "stopped" && process2.state !== "failed"
2487
+ );
2488
+ if (!anyLive) {
2489
+ clearInterval(timer);
2490
+ resolve();
2491
+ }
2492
+ };
2493
+ const timer = setInterval(check, 250);
2494
+ timer.unref?.();
2495
+ });
2496
+
2497
+ // src/cli/commands/doctor.ts
2498
+ var PORT_IN_COMMAND = /--port[= ](\d{2,5})/g;
2499
+ var declaredPorts = (stack) => {
2500
+ const ports = new Set(stack.ports);
2501
+ for (const service of stack.services) {
2502
+ const line = service.command?.kind === "shell" ? service.command.line : service.command ? [service.command.file, ...service.command.args].join(" ") : "";
2503
+ for (const match of line.matchAll(PORT_IN_COMMAND)) {
2504
+ const port = Number(match[1]);
2505
+ if (Number.isInteger(port)) ports.add(port);
2506
+ }
2507
+ }
2508
+ return [...ports].sort((a, b) => a - b);
2509
+ };
2510
+ var redisTargetOf = (project) => `${project.env.REDIS_HOST ?? "127.0.0.1"}:${project.env.REDIS_PORT ?? "6379"}`;
2511
+ var slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2512
+ var redisPrefixOf = (project) => project.env.REDIS_PREFIX ?? `${slug(project.env.APP_NAME ?? "laravel")}_database_`;
2513
+ var redisNamespaceOf = (project) => `${redisTargetOf(project)}/${project.env.REDIS_DB ?? "0"}#${redisPrefixOf(project)}`;
2514
+ var checkProjects = (stack) => {
2515
+ const findings = [];
2516
+ for (const project of Object.values(stack.projects)) {
2517
+ if (!existsSync4(project.path)) {
2518
+ findings.push({ level: "error", message: `project "${project.key}": path does not exist \u2014 ${project.path}` });
2519
+ continue;
2520
+ }
2521
+ if (!existsSync4(path7.join(project.path, "artisan"))) {
2522
+ findings.push({
2523
+ level: "warn",
2524
+ message: `project "${project.key}": no \`artisan\` file in ${project.path}`,
2525
+ hint: "laracrew will still run the commands, but this does not look like a Laravel root"
2526
+ });
2527
+ }
2528
+ if (Object.keys(project.env).length === 0) {
2529
+ findings.push({
2530
+ level: "warn",
2531
+ message: `project "${project.key}": ${project.envFile} is missing or empty`,
2532
+ hint: "queue/Redis checks and ${project.env:...} tokens need it"
2533
+ });
2534
+ }
2535
+ }
2536
+ return findings;
2537
+ };
2538
+ var checkRedisCollisions = (stack) => {
2539
+ const findings = [];
2540
+ const projects = Object.values(stack.projects);
2541
+ const byNamespace = /* @__PURE__ */ new Map();
2542
+ for (const project of projects) {
2543
+ const namespace = redisNamespaceOf(project);
2544
+ byNamespace.set(namespace, [...byNamespace.get(namespace) ?? [], project]);
2545
+ }
2546
+ for (const [namespace, sharing] of byNamespace) {
2547
+ if (sharing.length < 2) continue;
2548
+ const queuesByProject = /* @__PURE__ */ new Map();
2549
+ const streamsByProject = /* @__PURE__ */ new Map();
2550
+ for (const service of stack.services) {
2551
+ if (!service.project || !service.metrics) continue;
2552
+ const key = service.project.key;
2553
+ for (const queue of service.metrics.queues) {
2554
+ queuesByProject.set(key, (queuesByProject.get(key) ?? /* @__PURE__ */ new Set()).add(queue));
2555
+ }
2556
+ for (const stream of service.metrics.streams) {
2557
+ streamsByProject.set(key, (streamsByProject.get(key) ?? /* @__PURE__ */ new Set()).add(`${stream.key}/${stream.group}`));
2558
+ }
2559
+ }
2560
+ const names = sharing.map((project) => project.key);
2561
+ const overlap = (map) => {
2562
+ const counts = /* @__PURE__ */ new Map();
2563
+ for (const key of names) {
2564
+ for (const value of map.get(key) ?? []) counts.set(value, (counts.get(value) ?? 0) + 1);
2565
+ }
2566
+ return [...counts.entries()].filter(([, count]) => count > 1).map(([value]) => value);
2567
+ };
2568
+ const sharedQueues = overlap(queuesByProject);
2569
+ const sharedStreams = overlap(streamsByProject);
2570
+ if (sharedQueues.length > 0) {
2571
+ findings.push({
2572
+ level: "error",
2573
+ message: `${names.join(" and ")} share Redis ${namespace} AND queue(s): ${sharedQueues.join(", ")}`,
2574
+ hint: "each project's workers will steal the other's jobs \u2014 set a different REDIS_DB or REDIS_PREFIX"
2575
+ });
2576
+ }
2577
+ if (sharedStreams.length > 0) {
2578
+ findings.push({
2579
+ level: "error",
2580
+ message: `${names.join(" and ")} share Redis ${namespace} AND stream/group: ${sharedStreams.join(", ")}`,
2581
+ hint: "two consumers in one group split the entries between them \u2014 give each project its own group name"
2582
+ });
2583
+ }
2584
+ if (sharedQueues.length === 0 && sharedStreams.length === 0) {
2585
+ findings.push({
2586
+ level: "warn",
2587
+ message: `${names.join(" and ")} share Redis ${namespace}`,
2588
+ hint: "no queue or stream overlap declared, but cache and session keys can still collide"
2589
+ });
2590
+ }
2591
+ }
2592
+ const byServer = /* @__PURE__ */ new Map();
2593
+ for (const project of projects) {
2594
+ const server = `${redisTargetOf(project)}/${project.env.REDIS_DB ?? "0"}`;
2595
+ byServer.set(server, [...byServer.get(server) ?? [], project]);
2596
+ }
2597
+ for (const [server, sharing] of byServer) {
2598
+ if (sharing.length < 2) continue;
2599
+ const prefixes = new Set(sharing.map(redisPrefixOf));
2600
+ if (prefixes.size === sharing.length) {
2601
+ findings.push({
2602
+ level: "ok",
2603
+ message: `${sharing.map((p) => p.key).join(" and ")} share Redis ${server} but are isolated by prefix (${[...prefixes].join(", ")})`
2604
+ });
2605
+ }
2606
+ }
2607
+ return findings;
2608
+ };
2609
+ var checkQueueConnections = (stack) => {
2610
+ const findings = [];
2611
+ const workerProjects = new Set(
2612
+ stack.services.filter((service) => {
2613
+ if (!service.autostart) return false;
2614
+ const line = service.command?.kind === "shell" ? service.command.line : service.command ? [service.command.file, ...service.command.args].join(" ") : "";
2615
+ return /\b(queue:work|queue:listen|horizon)\b/.test(line);
2616
+ }).map((service) => service.project?.key).filter((key) => Boolean(key))
2617
+ );
2618
+ for (const key of workerProjects) {
2619
+ const project = stack.projects[key];
2620
+ if (!project) continue;
2621
+ const connection = project.env.QUEUE_CONNECTION;
2622
+ if (connection === void 0) continue;
2623
+ if (connection === "sync") {
2624
+ findings.push({
2625
+ level: "error",
2626
+ message: `project "${key}" runs a queue worker but QUEUE_CONNECTION=sync`,
2627
+ hint: "jobs run inline on dispatch, so the worker will sit idle forever \u2014 set it to redis or database"
2628
+ });
2629
+ }
2630
+ }
2631
+ return findings;
2632
+ };
2633
+ var checkPhp = async (stack) => {
2634
+ const binaries = new Set(Object.values(stack.projects).map((project) => project.php));
2635
+ const findings = [];
2636
+ for (const binary of binaries) {
2637
+ try {
2638
+ const result = await runOnce({ kind: "argv", file: binary, args: ["--version"] }, {
2639
+ cwd: process.cwd(),
2640
+ timeoutMs: 8e3
2641
+ });
2642
+ if (result.code === 0) {
2643
+ const version = result.stdout.split("\n")[0]?.trim() ?? "";
2644
+ findings.push({ level: "ok", message: `php: ${version}` });
2645
+ } else {
2646
+ findings.push({ level: "error", message: `"${binary} --version" exited with code ${result.code}` });
2647
+ }
2648
+ } catch {
2649
+ findings.push({
2650
+ level: "error",
2651
+ message: `cannot run "${binary}"`,
2652
+ hint: "put PHP on PATH, or set an absolute path in projects.yaml"
2653
+ });
2654
+ }
2655
+ }
2656
+ return findings;
2657
+ };
2658
+ var checkPorts = async (stack) => {
2659
+ const findings = [];
2660
+ for (const port of declaredPorts(stack)) {
2661
+ const free = await isPortFree(port);
2662
+ findings.push(
2663
+ free ? { level: "ok", message: `port ${port} is free` } : {
2664
+ level: "error",
2665
+ message: `port ${port} is already in use`,
2666
+ hint: "something else is bound to it \u2014 a stale worker from a previous run, most likely"
2667
+ }
2668
+ );
2669
+ }
2670
+ return findings;
2671
+ };
2672
+ var checkRedisReachable = async (stack) => {
2673
+ const targets = new Set(Object.values(stack.projects).map(redisTargetOf));
2674
+ const findings = [];
2675
+ for (const target of targets) {
2676
+ const result = await tcpProbe(target, 2e3);
2677
+ findings.push(
2678
+ result.ok ? { level: "ok", message: `redis reachable at ${target}` } : { level: "warn", message: `redis not reachable at ${target}${result.detail ? ` (${result.detail})` : ""}` }
2679
+ );
2680
+ }
2681
+ return findings;
2682
+ };
2683
+ var runDoctor = async (stackName, env = process.env) => {
2684
+ let stack;
2685
+ try {
2686
+ stack = prepareStack(stackName, { env });
2687
+ } catch (error) {
2688
+ if (error instanceof ConfigError) return [{ level: "error", message: error.format() }];
2689
+ throw error;
2690
+ }
2691
+ const findings = [
2692
+ { level: "ok", message: `stack "${stack.name}" is valid \u2014 ${stack.services.length} services` },
2693
+ ...checkProjects(stack),
2694
+ ...checkQueueConnections(stack),
2695
+ ...checkRedisCollisions(stack)
2696
+ ];
2697
+ const async = await Promise.all([checkPhp(stack), checkPorts(stack), checkRedisReachable(stack)]);
2698
+ return [...findings, ...async.flat()];
2699
+ };
2700
+ var doctorCommand = async (stackName, env = process.env) => {
2701
+ const findings = await runDoctor(stackName, env);
2702
+ const glyph = { ok: paint("green", "\u2714"), warn: paint("yellow", "\u25B2"), error: paint("red", "\u2716") };
2703
+ for (const finding of findings) {
2704
+ console.log(`${glyph[finding.level]} ${finding.message}`);
2705
+ if (finding.hint) console.log(` ${paint("gray", finding.hint)}`);
2706
+ }
2707
+ const errors = findings.filter((finding) => finding.level === "error").length;
2708
+ const warnings = findings.filter((finding) => finding.level === "warn").length;
2709
+ console.log(
2710
+ `
2711
+ ${errors === 0 ? paint("green", "no blocking problems") : paint("red", `${errors} problem(s)`)}` + (warnings > 0 ? paint("yellow", `, ${warnings} warning(s)`) : "")
2712
+ );
2713
+ return errors > 0 ? 1 : 0;
2714
+ };
2715
+
2716
+ // src/cli/commands/init.ts
2717
+ import { mkdirSync as mkdirSync2, writeFileSync, existsSync as existsSync5 } from "fs";
2718
+ import path8 from "path";
2719
+
2720
+ // src/cli/commands/templates.ts
2721
+ var CONFIG_YAML = `# laracrew global preferences
2722
+ # theme: auto | dark | light | mono | high-contrast
2723
+ theme: auto
2724
+
2725
+ # Polling interval for queue depth and stream lag, in milliseconds.
2726
+ metricsIntervalMs: 2000
2727
+
2728
+ # The stack \`laracrew up\` boots when you don't name one.
2729
+ # defaultStack: dual
2730
+ `;
2731
+ var PROJECTS_YAML = `# Your Laravel projects, referenced by key from any stack.
2732
+ # Paths may use forward slashes on Windows.
2733
+ projects: {}
2734
+
2735
+ # Example \u2014 delete the \`{}\` above and uncomment:
2736
+ #
2737
+ # projects:
2738
+ # api:
2739
+ # path: D:/work/api
2740
+ # php: php # or an absolute path to a specific PHP build
2741
+ # envFile: .env # read for QUEUE_CONNECTION, REDIS_*, APP_URL, DB_*
2742
+ # color: cyan
2743
+ # portal:
2744
+ # path: D:/work/portal
2745
+ # php: php
2746
+ # envFile: .env
2747
+ # color: magenta
2748
+ `;
2749
+ var EXAMPLE_STACK_YAML = `# A runnable demo stack \u2014 no Laravel project required.
2750
+ # Try: laracrew up example
2751
+ # Then: laracrew up example --only workers
2752
+ name: example
2753
+ description: Three fake services that prove laracrew works on this machine
2754
+
2755
+ # \`laracrew link example\` installs this as a global command you can run from anywhere.
2756
+ command: demo
2757
+
2758
+ defaults:
2759
+ restart: on-failure
2760
+ backoff: { initialMs: 500, maxMs: 5000, factor: 2, maxRestarts: 5 }
2761
+ stop: { graceMs: 5000 }
2762
+
2763
+ services:
2764
+ - name: demo:heartbeat
2765
+ cmd: ["node", "-e", "let n=0; setInterval(() => console.log('heartbeat ' + (++n)), 1000)"]
2766
+ groups: [workers]
2767
+ color: cyan
2768
+
2769
+ - name: demo:worker
2770
+ cmd: ["node", "-e", "let n=0; setInterval(() => console.log('processed job #' + (++n)), 1700)"]
2771
+ groups: [workers]
2772
+ needs: [demo:heartbeat]
2773
+ color: magenta
2774
+
2775
+ - name: demo:slow-starter
2776
+ cmd: ["node", "-e", "setTimeout(() => console.log('listening on 9'), 1200); setInterval(() => {}, 1000)"]
2777
+ groups: [http]
2778
+ ready: { logMatch: "listening on", timeoutMs: 10000 }
2779
+ color: green
2780
+
2781
+ profiles:
2782
+ quiet:
2783
+ except: [http]
2784
+ `;
2785
+ var REAL_STACK_TEMPLATE = `# Two interconnected Laravel projects: queues, Redis stream listeners, schedulers.
2786
+ # Fill in the project paths in ~/.laracrew/projects.yaml first, then: laracrew up dual
2787
+ name: dual
2788
+ description: API + Portal with queues, streams and schedulers
2789
+
2790
+ # Run \`laracrew link dual\` and this whole set boots by typing \`dual\` from anywhere.
2791
+ command: dual
2792
+
2793
+ use: [api, portal]
2794
+
2795
+ defaults:
2796
+ restart: on-failure
2797
+ backoff: { initialMs: 1000, maxMs: 30000, factor: 2, maxRestarts: 10 }
2798
+ stop: { graceMs: 10000 }
2799
+ logs: { maxLines: 5000, toFile: false }
2800
+
2801
+ services:
2802
+ - name: redis
2803
+ external: true # laracrew checks it, never starts it
2804
+ ready: { tcp: "127.0.0.1:6379" }
2805
+
2806
+ - name: api:serve
2807
+ project: api
2808
+ cmd: php artisan serve --port=\${port:8000}
2809
+ groups: [http]
2810
+ needs: [redis]
2811
+ ready: { http: "http://127.0.0.1:8000/up", timeoutMs: 20000 }
2812
+ url: http://127.0.0.1:8000
2813
+
2814
+ - name: api:queue
2815
+ project: api
2816
+ cmd: php artisan queue:work redis --queue=high,default --tries=3 --timeout=90
2817
+ groups: [workers]
2818
+ needs: [redis]
2819
+ stop: { artisan: "queue:restart", graceMs: 15000 }
2820
+ watch:
2821
+ paths: [app, config, routes, database]
2822
+ strategy: artisan-queue-restart
2823
+ debounceMs: 800
2824
+ metrics:
2825
+ queues: [high, default]
2826
+
2827
+ - name: api:streams
2828
+ project: api
2829
+ cmd: php artisan streams:listen orders
2830
+ groups: [workers, streams]
2831
+ needs: [redis]
2832
+ metrics:
2833
+ streams:
2834
+ - { key: orders, group: api-consumers }
2835
+
2836
+ - name: api:schedule
2837
+ project: api
2838
+ cmd: php artisan schedule:work
2839
+ groups: [workers]
2840
+ needs: [redis]
2841
+
2842
+ - name: portal:serve
2843
+ project: portal
2844
+ cmd: php artisan serve --port=\${port:8001}
2845
+ groups: [http]
2846
+ needs: [redis, api:serve] # don't boot until the API answers
2847
+ ready: { http: "http://127.0.0.1:8001/up", timeoutMs: 20000 }
2848
+ url: http://127.0.0.1:8001
2849
+
2850
+ - name: portal:queue
2851
+ project: portal
2852
+ cmd: php artisan queue:work redis --queue=default --tries=3
2853
+ groups: [workers]
2854
+ needs: [redis, api:serve]
2855
+ stop: { artisan: "queue:restart" }
2856
+ watch: { paths: [app, config], strategy: artisan-queue-restart }
2857
+ metrics: { queues: [default] }
2858
+
2859
+ - name: portal:streams
2860
+ project: portal
2861
+ cmd: php artisan streams:listen inventory
2862
+ groups: [workers, streams]
2863
+ needs: [redis]
2864
+ metrics:
2865
+ streams:
2866
+ - { key: inventory, group: portal-consumers }
2867
+
2868
+ profiles:
2869
+ light:
2870
+ except: [assets]
2871
+ workers-only:
2872
+ only: [workers]
2873
+ `;
2874
+ var RESET_TASK_YAML = `# laracrew run reset
2875
+ name: reset
2876
+ description: Wipe and reseed both databases in the right order
2877
+ pauseServices: [workers] # stopped before the steps, restarted after
2878
+ steps:
2879
+ - { project: api, cmd: php artisan migrate:fresh --seed }
2880
+ - { project: portal, cmd: php artisan migrate:fresh --seed }
2881
+ - parallel:
2882
+ - { project: api, cmd: php artisan cache:clear }
2883
+ - { project: portal, cmd: php artisan cache:clear }
2884
+ `;
2885
+ var HOME_README = `# ~/.laracrew
2886
+
2887
+ Your laracrew configuration. Safe to keep in git \u2014 nothing here is secret unless you put
2888
+ secrets in it.
2889
+
2890
+ config.yaml global preferences
2891
+ projects.yaml your Laravel projects, referenced by key
2892
+ stacks/<name>/ one folder per stack; stack.yaml is the definition
2893
+ tasks/<name>.yaml one-shot sequences (laracrew run <name>)
2894
+ fragments/ shared YAML any stack can reuse
2895
+ logs/ per-run log files, when defaults.logs.toFile is on
2896
+
2897
+ Start here:
2898
+
2899
+ laracrew init --examples # add a demo stack and a two-project template
2900
+ laracrew ls # what is defined
2901
+ laracrew doctor <stack> # check a stack before booting it
2902
+ laracrew link <stack> # give a stack its own global command
2903
+ `;
2904
+
2905
+ // src/cli/commands/init.ts
2906
+ var runInit = (env = process.env, options = {}) => {
2907
+ const home2 = paths(env);
2908
+ const created = [];
2909
+ const skipped = [];
2910
+ for (const dir of [home2.root, home2.stacksDir, home2.tasksDir, home2.fragmentsDir, home2.logsDir, home2.runDir]) {
2911
+ mkdirSync2(dir, { recursive: true });
2912
+ }
2913
+ const files = [
2914
+ [home2.configFile, CONFIG_YAML],
2915
+ [home2.projectsFile, PROJECTS_YAML],
2916
+ [path8.join(home2.root, "README.md"), HOME_README]
2917
+ ];
2918
+ if (options.examples) {
2919
+ mkdirSync2(path8.join(home2.stacksDir, "example"), { recursive: true });
2920
+ mkdirSync2(path8.join(home2.stacksDir, "dual"), { recursive: true });
2921
+ files.push(
2922
+ [path8.join(home2.stacksDir, "example", "stack.yaml"), EXAMPLE_STACK_YAML],
2923
+ [path8.join(home2.stacksDir, "dual", "stack.yaml"), REAL_STACK_TEMPLATE],
2924
+ [path8.join(home2.tasksDir, "reset.yaml"), RESET_TASK_YAML]
2925
+ );
2926
+ }
2927
+ for (const [file, contents] of files) {
2928
+ if (existsSync5(file)) {
2929
+ skipped.push(file);
2930
+ continue;
2931
+ }
2932
+ writeFileSync(file, contents, "utf8");
2933
+ created.push(file);
2934
+ }
2935
+ return { root: home2.root, created, skipped, examples: options.examples ?? false };
2936
+ };
2937
+ var initCommand = (options = {}, env = process.env) => {
2938
+ const result = runInit(env, options);
2939
+ console.log(`${paint("bold", "laracrew")} home: ${result.root}`);
2940
+ for (const file of result.created) console.log(` ${paint("green", "+")} ${path8.relative(result.root, file)}`);
2941
+ for (const file of result.skipped) {
2942
+ console.log(` ${paint("gray", "\xB7")} ${path8.relative(result.root, file)} ${paint("gray", "(kept)")}`);
2943
+ }
2944
+ if (result.examples) {
2945
+ console.log(`
2946
+ ${paint("bold", "Next:")}
2947
+ ${paint("cyan", "laracrew up example")} run the demo stack \u2014 proves it works, no Laravel needed
2948
+ ${paint("cyan", "laracrew link example")} install it as a global command you can type from anywhere
2949
+ edit ${paint("gray", path8.join(result.root, "projects.yaml"))} with your project paths,
2950
+ then ${paint("cyan", "laracrew doctor dual")} and ${paint("cyan", "laracrew up dual")}
2951
+ `);
2952
+ return;
2953
+ }
2954
+ console.log(`
2955
+ ${paint("bold", "Next:")}
2956
+ ${paint("cyan", "laracrew init --examples")} add a runnable demo stack and a two-project template
2957
+ or write your own at ${paint("gray", path8.join(result.root, "stacks", "<name>", "stack.yaml"))}
2958
+ then ${paint("cyan", "laracrew ls")} and ${paint("cyan", "laracrew up <name>")}
2959
+ `);
2960
+ };
2961
+
2962
+ // src/cli/commands/link.ts
2963
+ import { chmodSync, existsSync as existsSync6, mkdirSync as mkdirSync3, readFileSync as readFileSync3, readdirSync as readdirSync2, unlinkSync, writeFileSync as writeFileSync2 } from "fs";
2964
+ import path9 from "path";
2965
+ import { fileURLToPath } from "url";
2966
+ var IS_WINDOWS3 = process.platform === "win32";
2967
+ var MARKER = "laracrew-generated";
2968
+ var RESERVED = /* @__PURE__ */ new Set(["laracrew", "node", "npm", "npx", "php", "git", "cd", "ls", "rm", "sh", "bash"]);
2969
+ var VALID_NAME = /^[A-Za-z0-9][A-Za-z0-9_.-]*$/;
2970
+ var entryScript = (argv = process.argv) => {
2971
+ const main = argv[1];
2972
+ if (!main) return fileURLToPath(import.meta.url);
2973
+ const resolved = path9.resolve(main);
2974
+ if (!resolved.endsWith(".ts")) return resolved;
2975
+ const dist = path9.join(path9.dirname(path9.dirname(resolved)), "dist", "index.js");
2976
+ if (existsSync6(dist)) return dist;
2977
+ throw new ConfigError("run `npm run build` first \u2014 a generated command has to point at dist/index.js");
2978
+ };
2979
+ var currentTarget = (stack) => ({
2980
+ node: process.execPath,
2981
+ entry: entryScript(),
2982
+ stack
2983
+ });
2984
+ var forward = (value) => value.replace(/\\/g, "/");
2985
+ var shShim = ({ node, entry, stack }) => `#!/bin/sh
2986
+ # ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.
2987
+ exec "${forward(node)}" "${forward(entry)}" up ${stack} "$@"
2988
+ `;
2989
+ var cmdShim = ({ node, entry, stack }) => `@ECHO OFF\r
2990
+ REM ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.\r
2991
+ "${node}" "${entry}" up ${stack} %*\r
2992
+ `;
2993
+ var ps1Shim = ({ node, entry, stack }) => `#!/usr/bin/env pwsh
2994
+ # ${MARKER}: boots the "${stack}" stack. Regenerate with \`laracrew link ${stack}\`.
2995
+ & "${node}" "${entry}" up ${stack} @args
2996
+ exit $LASTEXITCODE
2997
+ `;
2998
+ var shimFiles = (name, target) => {
2999
+ const posix = { file: name, contents: shShim(target), exec: true };
3000
+ if (!IS_WINDOWS3) return [posix];
3001
+ return [
3002
+ posix,
3003
+ // Git Bash reads the extensionless file
3004
+ { file: `${name}.cmd`, contents: cmdShim(target), exec: false },
3005
+ { file: `${name}.ps1`, contents: ps1Shim(target), exec: true }
3006
+ ];
3007
+ };
3008
+ var pathDirs = (env) => (env.PATH ?? env.Path ?? "").split(path9.delimiter).filter(Boolean);
3009
+ var samePath = (a, b) => IS_WINDOWS3 ? a.toLowerCase() === b.toLowerCase() : a === b;
3010
+ var ownBinDir = (env) => {
3011
+ for (const dir of pathDirs(env)) {
3012
+ for (const candidate of IS_WINDOWS3 ? ["laracrew.cmd", "laracrew"] : ["laracrew"]) {
3013
+ try {
3014
+ if (existsSync6(path9.join(dir, candidate))) return dir;
3015
+ } catch {
3016
+ }
3017
+ }
3018
+ }
3019
+ return void 0;
3020
+ };
3021
+ var resolveBinDir = (env = process.env) => {
3022
+ if (env.LARACREW_BIN) return { dir: path9.resolve(env.LARACREW_BIN), onPath: true };
3023
+ const own = ownBinDir(env);
3024
+ if (own) return { dir: own, onPath: true };
3025
+ const conventional = IS_WINDOWS3 ? env.APPDATA ? path9.join(env.APPDATA, "npm") : void 0 : path9.dirname(process.execPath);
3026
+ if (conventional && existsSync6(conventional) && pathDirs(env).some((dir) => samePath(dir, conventional))) {
3027
+ return { dir: conventional, onPath: true };
3028
+ }
3029
+ return { dir: path9.join(paths(env).root, "bin"), onPath: false };
3030
+ };
3031
+ var isOurs = (file) => {
3032
+ try {
3033
+ return readFileSync3(file, "utf8").includes(MARKER);
3034
+ } catch {
3035
+ return false;
3036
+ }
3037
+ };
3038
+ var linkStack = (stackName, options = {}) => {
3039
+ const env = options.env ?? process.env;
3040
+ const { config } = loadStack(stackName, env);
3041
+ const name = options.as ?? config.command ?? stackName;
3042
+ if (!VALID_NAME.test(name)) {
3043
+ throw new ConfigError(
3044
+ `"${name}" is not a usable command name \u2014 letters, digits, dot, dash and underscore only`
3045
+ );
3046
+ }
3047
+ if (RESERVED.has(name.toLowerCase()) && !options.force) {
3048
+ throw new ConfigError(`"${name}" would shadow an important command`, {
3049
+ details: ["pick another with `--as <name>`, or pass --force if you really mean it"]
3050
+ });
3051
+ }
3052
+ const resolved = options.dir ? { dir: path9.resolve(options.dir), onPath: true } : resolveBinDir(env);
3053
+ mkdirSync3(resolved.dir, { recursive: true });
3054
+ const target = currentTarget(stackName);
3055
+ const planned = shimFiles(name, target);
3056
+ let replaced = false;
3057
+ for (const { file } of planned) {
3058
+ const full = path9.join(resolved.dir, file);
3059
+ if (!existsSync6(full)) continue;
3060
+ if (isOurs(full)) {
3061
+ replaced = true;
3062
+ continue;
3063
+ }
3064
+ if (!options.force) {
3065
+ throw new ConfigError(`"${full}" already exists and was not created by laracrew`, {
3066
+ details: ["choose another name with `--as <name>`, or pass --force to overwrite"]
3067
+ });
3068
+ }
3069
+ replaced = true;
3070
+ }
3071
+ const written = [];
3072
+ for (const { file, contents, exec } of planned) {
3073
+ const full = path9.join(resolved.dir, file);
3074
+ writeFileSync2(full, contents, "utf8");
3075
+ if (exec && !IS_WINDOWS3) chmodSync(full, 493);
3076
+ else if (exec) {
3077
+ try {
3078
+ chmodSync(full, 493);
3079
+ } catch {
3080
+ }
3081
+ }
3082
+ written.push(full);
3083
+ }
3084
+ return { name, stack: stackName, dir: resolved.dir, onPath: resolved.onPath, files: written, replaced };
3085
+ };
3086
+ var unlinkCommandName = (name, options = {}) => {
3087
+ const env = options.env ?? process.env;
3088
+ const dir = options.dir ? path9.resolve(options.dir) : resolveBinDir(env).dir;
3089
+ const removed = [];
3090
+ for (const candidate of [name, `${name}.cmd`, `${name}.ps1`]) {
3091
+ const full = path9.join(dir, candidate);
3092
+ if (!existsSync6(full)) continue;
3093
+ if (!isOurs(full)) {
3094
+ throw new ConfigError(`"${full}" was not created by laracrew \u2014 leaving it alone`);
3095
+ }
3096
+ unlinkSync(full);
3097
+ removed.push(full);
3098
+ }
3099
+ if (removed.length === 0) throw new ConfigError(`no laracrew command named "${name}" in ${dir}`);
3100
+ return removed;
3101
+ };
3102
+ var listLinks = (options = {}) => {
3103
+ const env = options.env ?? process.env;
3104
+ const dir = options.dir ? path9.resolve(options.dir) : resolveBinDir(env).dir;
3105
+ if (!existsSync6(dir)) return [];
3106
+ const found = [];
3107
+ for (const entry of readdirSync2(dir).sort()) {
3108
+ if (entry.endsWith(".cmd") || entry.endsWith(".ps1")) continue;
3109
+ const full = path9.join(dir, entry);
3110
+ let contents;
3111
+ try {
3112
+ contents = readFileSync3(full, "utf8");
3113
+ } catch {
3114
+ continue;
3115
+ }
3116
+ if (!contents.includes(MARKER)) continue;
3117
+ const stack = /\bup ([^\s"]+)/.exec(contents)?.[1];
3118
+ if (stack) found.push({ name: entry, stack, file: full });
3119
+ }
3120
+ return found;
3121
+ };
3122
+ var linkCommand = (stackName, options, env = process.env) => {
3123
+ const stacks = options.all ? listStacks(env) : stackName ? [stackName] : [];
3124
+ if (stacks.length === 0) {
3125
+ throw new ConfigError("name a stack to link, or pass --all");
3126
+ }
3127
+ const results = [];
3128
+ for (const stack of stacks) {
3129
+ if (options.all) {
3130
+ const { config } = loadStack(stack, env);
3131
+ if (!config.command) continue;
3132
+ }
3133
+ results.push(
3134
+ linkStack(stack, {
3135
+ ...options.as ? { as: options.as } : {},
3136
+ ...options.dir ? { dir: options.dir } : {},
3137
+ ...options.force ? { force: options.force } : {},
3138
+ env
3139
+ })
3140
+ );
3141
+ }
3142
+ if (results.length === 0) {
3143
+ console.log(paint("yellow", "nothing to link \u2014 add `command: <name>` to the stacks you want a command for"));
3144
+ return 0;
3145
+ }
3146
+ for (const result of results) {
3147
+ const verb = result.replaced ? "updated" : "created";
3148
+ console.log(
3149
+ `${paint("green", verb)} ${paint("bold", result.name)} ${paint("gray", `-> laracrew up ${result.stack}`)}`
3150
+ );
3151
+ }
3152
+ const first = results[0];
3153
+ console.log(paint("gray", `
3154
+ in ${first.dir}`));
3155
+ if (!first.onPath) {
3156
+ console.log(
3157
+ `
3158
+ ${paint("yellow", "that directory is not on your PATH.")} Add it once:
3159
+ ` + (IS_WINDOWS3 ? ` ${paint("cyan", `setx PATH "%PATH%;${first.dir}"`)} ${paint("gray", "(then reopen the terminal)")}` : ` ${paint("cyan", `echo 'export PATH="$PATH:${first.dir}"' >> ~/.bashrc`)}`)
3160
+ );
3161
+ } else {
3162
+ console.log(`
3163
+ Run it from anywhere: ${paint("cyan", first.name)}`);
3164
+ console.log(paint("gray", `Flags pass straight through: ${first.name} --only workers`));
3165
+ }
3166
+ return 0;
3167
+ };
3168
+ var unlinkCommandAction = (name, env = process.env) => {
3169
+ const removed = unlinkCommandName(name, { env });
3170
+ for (const file of removed) console.log(`${paint("red", "removed")} ${file}`);
3171
+ return 0;
3172
+ };
3173
+
3174
+ // src/cli/commands/logs.ts
3175
+ import { existsSync as existsSync8, readdirSync as readdirSync3 } from "fs";
3176
+ import path10 from "path";
3177
+
3178
+ // src/core/logs/read.ts
3179
+ import { openSync as openSync2, readSync, closeSync as closeSync2, statSync as statSync3, existsSync as existsSync7 } from "fs";
3180
+ var CHUNK = 64 * 1024;
3181
+ var tailFile = (file, limit) => {
3182
+ if (!existsSync7(file) || limit <= 0) return [];
3183
+ const size = statSync3(file).size;
3184
+ if (size === 0) return [];
3185
+ const handle = openSync2(file, "r");
3186
+ try {
3187
+ let position = size;
3188
+ let text = "";
3189
+ let newlines = 0;
3190
+ while (position > 0 && newlines <= limit) {
3191
+ const length = Math.min(CHUNK, position);
3192
+ position -= length;
3193
+ const buffer = Buffer.alloc(length);
3194
+ readSync(handle, buffer, 0, length, position);
3195
+ const chunk = buffer.toString("utf8");
3196
+ text = chunk + text;
3197
+ newlines = 0;
3198
+ for (const character of text) if (character === "\n") newlines += 1;
3199
+ }
3200
+ const lines = text.split("\n");
3201
+ if (lines.at(-1) === "") lines.pop();
3202
+ return lines.slice(-limit);
3203
+ } finally {
3204
+ closeSync2(handle);
3205
+ }
3206
+ };
3207
+ var toEntries = (service, lines) => lines.map((raw) => {
3208
+ const parsed = parseLine(raw);
3209
+ if (!parsed) return { service, stream: "stdout", line: raw, at: 0 };
3210
+ return {
3211
+ service,
3212
+ stream: parsed.stream === "stderr" ? "stderr" : parsed.stream === "system" ? "system" : "stdout",
3213
+ line: parsed.line,
3214
+ at: parsed.at
3215
+ };
3216
+ });
3217
+ var parseSince = (input) => {
3218
+ const relative = /^(\d+)\s*(s|m|h|d)$/i.exec(input.trim());
3219
+ if (relative) {
3220
+ const amount = Number(relative[1]);
3221
+ const unit = relative[2].toLowerCase();
3222
+ const ms = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[unit] ?? 0;
3223
+ return Date.now() - amount * ms;
3224
+ }
3225
+ const absolute = new Date(input).getTime();
3226
+ return Number.isNaN(absolute) ? void 0 : absolute;
3227
+ };
3228
+ var followFile = (file, options) => {
3229
+ let position = existsSync7(file) ? statSync3(file).size : 0;
3230
+ let carry = "";
3231
+ const read = () => {
3232
+ if (!existsSync7(file)) return;
3233
+ const size = statSync3(file).size;
3234
+ if (size === position) return;
3235
+ if (size < position) {
3236
+ position = 0;
3237
+ carry = "";
3238
+ }
3239
+ const length = size - position;
3240
+ const handle = openSync2(file, "r");
3241
+ try {
3242
+ const buffer = Buffer.alloc(length);
3243
+ readSync(handle, buffer, 0, length, position);
3244
+ position = size;
3245
+ const text = carry + buffer.toString("utf8");
3246
+ const lines = text.split("\n");
3247
+ carry = lines.pop() ?? "";
3248
+ if (lines.length > 0) options.onLines(lines);
3249
+ } finally {
3250
+ closeSync2(handle);
3251
+ }
3252
+ };
3253
+ const timer = setInterval(read, options.intervalMs ?? 300);
3254
+ timer.unref?.();
3255
+ const stop = () => clearInterval(timer);
3256
+ options.signal?.addEventListener("abort", stop, { once: true });
3257
+ return stop;
3258
+ };
3259
+
3260
+ // src/cli/commands/logs.ts
3261
+ var logDirFor = (stackName, env = process.env) => path10.join(paths(env).logsDir, stackName);
3262
+ var targetsFor = (stackName, env = process.env) => {
3263
+ const stack = prepareStack(stackName, { env });
3264
+ const dir = logDirFor(stackName, env);
3265
+ const names = fileNamesFor(stack.services.map((service) => service.name));
3266
+ return stack.services.map((service) => ({
3267
+ service: service.name,
3268
+ file: path10.join(dir, names.get(service.name)),
3269
+ color: service.color
3270
+ })).filter((target) => existsSync8(target.file));
3271
+ };
3272
+ var formatEntry = (entry, width, withName) => {
3273
+ const time2 = new Date(entry.at || Date.now());
3274
+ const clock = [time2.getHours(), time2.getMinutes(), time2.getSeconds()].map((part) => String(part).padStart(2, "0")).join(":");
3275
+ const who = withName ? `${paint(asColorName(void 0), padEnd(entry.service, width))} ` : "";
3276
+ const body = entry.stream === "stderr" ? paint("red", entry.line) : entry.line;
3277
+ return `${paint("gray", clock)} ${who}${paint("gray", "|")} ${body}
3278
+ `;
3279
+ };
3280
+ var logsCommand = async (service, options = {}) => {
3281
+ const env = options.env ?? process.env;
3282
+ const stackName = options.stack;
3283
+ if (!stackName) throw new ConfigError("name a stack with --stack, or run `laracrew logs` from a known stack");
3284
+ const dir = logDirFor(stackName, env);
3285
+ const available = targetsFor(stackName, env);
3286
+ if (options.list) {
3287
+ if (available.length === 0) {
3288
+ console.log(paint("yellow", `no log files yet in ${dir}`));
3289
+ console.log(paint("gray", "they appear once the stack has run at least once"));
3290
+ return 0;
3291
+ }
3292
+ console.log(paint("gray", dir));
3293
+ for (const target of available) {
3294
+ console.log(` ${paint("cyan", target.service)} ${paint("gray", path10.basename(target.file))}`);
3295
+ }
3296
+ return 0;
3297
+ }
3298
+ if (available.length === 0) {
3299
+ console.error(paint("yellow", `no log files in ${dir}`));
3300
+ console.error(
3301
+ paint(
3302
+ "gray",
3303
+ "run the stack at least once, and check `defaults.logs.toFile` is not set to false"
3304
+ )
3305
+ );
3306
+ return 1;
3307
+ }
3308
+ let selected;
3309
+ if (options.all || !service) {
3310
+ selected = available;
3311
+ } else {
3312
+ const match = available.find((target) => target.service === service);
3313
+ if (!match) {
3314
+ const suggestion = didYouMean(
3315
+ service,
3316
+ available.map((target) => target.service)
3317
+ );
3318
+ throw new ConfigError(
3319
+ `no log for service "${service}" in stack "${stackName}"` + (suggestion ? ` \u2014 did you mean "${suggestion}"?` : ""),
3320
+ { details: [`available: ${available.map((target) => target.service).join(", ")}`] }
3321
+ );
3322
+ }
3323
+ selected = [match];
3324
+ }
3325
+ const withName = selected.length > 1;
3326
+ const width = Math.max(...selected.map((target) => target.service.length));
3327
+ const limit = options.lines ?? 200;
3328
+ const since = options.since ? parseSince(options.since) : void 0;
3329
+ if (options.since && since === void 0) {
3330
+ throw new ConfigError(`could not read --since "${options.since}"`, {
3331
+ details: ["try a relative window like 10m, 2h, 1d, or an absolute date"]
3332
+ });
3333
+ }
3334
+ const history = [];
3335
+ for (const target of selected) {
3336
+ history.push(...toEntries(target.service, tailFile(target.file, since ? Math.max(limit, 5e3) : limit)));
3337
+ }
3338
+ history.sort((a, b) => a.at - b.at);
3339
+ const filtered = since ? history.filter((entry) => entry.at >= since) : history;
3340
+ for (const entry of filtered.slice(-limit)) write(formatEntry(entry, width, withName));
3341
+ if (!options.follow) return 0;
3342
+ const controller = new AbortController();
3343
+ const stops = selected.map(
3344
+ (target) => followFile(target.file, {
3345
+ signal: controller.signal,
3346
+ onLines: (lines) => {
3347
+ for (const entry of toEntries(target.service, lines)) {
3348
+ write(formatEntry(entry, width, withName));
3349
+ }
3350
+ }
3351
+ })
3352
+ );
3353
+ await new Promise((resolve) => {
3354
+ const finish = () => {
3355
+ controller.abort();
3356
+ for (const stop of stops) stop();
3357
+ resolve();
3358
+ };
3359
+ process.once("SIGINT", finish);
3360
+ process.once("SIGTERM", finish);
3361
+ });
3362
+ return 0;
3363
+ };
3364
+
3365
+ // src/cli/commands/ls.ts
3366
+ import { existsSync as existsSync9 } from "fs";
3367
+ var lsCommand = (env = process.env, json = false) => {
3368
+ const home2 = paths(env);
3369
+ if (!existsSync9(home2.root)) {
3370
+ console.error(`${paint("yellow", "no laracrew home yet")} \u2014 run ${paint("cyan", "laracrew init")}`);
3371
+ return 1;
3372
+ }
3373
+ const stackNames = listStacks(env);
3374
+ const taskNames = listTasks(env);
3375
+ const projects = loadSharedProjects(env);
3376
+ const linkedByStack = new Map(listLinks({ env }).map((link) => [link.stack, link.name]));
3377
+ const stacks = stackNames.map((name) => {
3378
+ try {
3379
+ const { config } = loadStack(name, env);
3380
+ return {
3381
+ name,
3382
+ description: config.description ?? "",
3383
+ services: config.services.length,
3384
+ profiles: Object.keys(config.profiles),
3385
+ command: linkedByStack.get(name) ?? null,
3386
+ valid: true
3387
+ };
3388
+ } catch (error) {
3389
+ return {
3390
+ name,
3391
+ description: error instanceof ConfigError ? error.message : "invalid",
3392
+ services: 0,
3393
+ profiles: [],
3394
+ command: linkedByStack.get(name) ?? null,
3395
+ valid: false
3396
+ };
3397
+ }
3398
+ });
3399
+ if (json) {
3400
+ console.log(JSON.stringify({ root: home2.root, stacks, tasks: taskNames, projects }, null, 2));
3401
+ return 0;
3402
+ }
3403
+ console.log(paint("gray", home2.root));
3404
+ console.log(`
3405
+ ${paint("bold", "STACKS")}`);
3406
+ if (stacks.length === 0) console.log(paint("gray", " none"));
3407
+ const width = Math.max(4, ...stacks.map((stack) => stack.name.length));
3408
+ for (const stack of stacks) {
3409
+ const label = stack.valid ? paint("cyan", padEnd(stack.name, width)) : paint("red", padEnd(stack.name, width));
3410
+ const meta = stack.valid ? paint("gray", `${stack.services} services`) : paint("red", "invalid");
3411
+ console.log(` ${label} ${meta} ${paint("gray", stack.description)}`);
3412
+ if (stack.command) {
3413
+ console.log(` ${" ".repeat(width)} ${paint("green", `$ ${stack.command}`)} ${paint("gray", "(global command)")}`);
3414
+ }
3415
+ if (stack.profiles.length > 0) {
3416
+ console.log(` ${" ".repeat(width)} ${paint("gray", `profiles: ${stack.profiles.join(", ")}`)}`);
3417
+ }
3418
+ }
3419
+ console.log(`
3420
+ ${paint("bold", "PROJECTS")}`);
3421
+ const projectKeys = Object.keys(projects);
3422
+ if (projectKeys.length === 0) console.log(paint("gray", " none \u2014 add them to projects.yaml"));
3423
+ const projectWidth = Math.max(4, ...projectKeys.map((key) => key.length));
3424
+ for (const [key, project] of Object.entries(projects)) {
3425
+ const exists = existsSync9(project.path);
3426
+ console.log(
3427
+ ` ${paint("magenta", padEnd(key, projectWidth))} ${project.path} ${exists ? "" : paint("red", "(missing)")}`
3428
+ );
3429
+ }
3430
+ console.log(`
3431
+ ${paint("bold", "TASKS")}`);
3432
+ if (taskNames.length === 0) console.log(paint("gray", " none"));
3433
+ for (const task of taskNames) console.log(` ${paint("green", task)}`);
3434
+ console.log("");
3435
+ return 0;
3436
+ };
3437
+
3438
+ // src/cli/program.ts
3439
+ var VERSION = "0.1.0";
3440
+ var resolveStackName = (explicit, env = process.env) => {
3441
+ if (explicit) return explicit;
3442
+ const configured = loadGlobalConfig(env).defaultStack;
3443
+ if (configured) return configured;
3444
+ const stacks = listStacks(env);
3445
+ if (stacks.length === 1) return stacks[0];
3446
+ if (stacks.length === 0) {
3447
+ throw new ConfigError("no stacks defined yet \u2014 run `laracrew init`");
3448
+ }
3449
+ throw new ConfigError(
3450
+ `several stacks exist, so name one: ${stacks.join(", ")}`,
3451
+ { details: ["or set `defaultStack:` in ~/.laracrew/config.yaml"] }
3452
+ );
3453
+ };
3454
+ var collect = (value, previous) => [...previous, value];
3455
+ var buildProgram = () => {
3456
+ const program = new Command();
3457
+ program.name("laracrew").description("Boot and supervise all the long-running processes of your Laravel projects with one command.").version(VERSION, "-v, --version").showHelpAfterError();
3458
+ program.command("init").description("create ~/.laracrew").option("--examples", "also write a runnable demo stack and a two-project template").action((options) => {
3459
+ initCommand({ examples: options.examples ?? false });
3460
+ });
3461
+ program.command("ls").alias("list").description("list stacks, projects and tasks").option("--json", "machine-readable output").action((options) => {
3462
+ process.exitCode = lsCommand(process.env, options.json ?? false);
3463
+ });
3464
+ program.command("up").argument("[stack]", "stack to boot").description("boot a stack and supervise it").option("--only <selector>", "only these services or groups (repeatable, comma-separated)", collect, []).option("--except <selector>", "skip these services or groups (repeatable, comma-separated)", collect, []).option("--profile <name>", "apply a profile from the stack").option("--plain", "prefixed interleaved logs instead of the full-screen view").option("--json", "newline-delimited JSON events").action(async (stack, options) => {
3465
+ const name = resolveStackName(stack);
3466
+ process.exitCode = await upCommand(name, {
3467
+ only: options.only,
3468
+ except: options.except,
3469
+ profile: options.profile,
3470
+ plain: options.plain,
3471
+ json: options.json
3472
+ });
3473
+ });
3474
+ program.command("logs").argument("[service]", "service to read; omit for every service in the stack").description("read what a stack wrote to disk, after the fact").option("--stack <name>", "which stack (defaults the same way `up` does)").option("-n, --lines <count>", "how many lines to show", (value) => Number(value), 200).option("-f, --follow", "keep printing new lines as they arrive").option("--since <window>", "only lines newer than this: 10m, 2h, 1d, or a date").option("--all", "merge every service in the stack").option("--list", "list the services that have a log file").action(async (service, options) => {
3475
+ process.exitCode = await logsCommand(service, {
3476
+ stack: resolveStackName(options.stack),
3477
+ lines: options.lines,
3478
+ follow: options.follow,
3479
+ since: options.since,
3480
+ all: options.all,
3481
+ list: options.list
3482
+ });
3483
+ });
3484
+ program.command("link").argument("[stack]", "stack to give its own global command").description("install a global command that boots one stack").option("--as <name>", "name the command something other than the stack name").option("--all", "link every stack that declares `command:` in its stack.yaml").option("--dir <path>", "install into this directory instead of the npm global bin").option("--force", "overwrite a name that is taken or reserved").action((stack, options) => {
3485
+ process.exitCode = linkCommand(
3486
+ stack,
3487
+ {
3488
+ as: options.as,
3489
+ all: options.all,
3490
+ dir: options.dir,
3491
+ force: options.force
3492
+ },
3493
+ process.env
3494
+ );
3495
+ });
3496
+ program.command("unlink").argument("<name>", "the global command to remove").description("remove a global command laracrew installed").action((name) => {
3497
+ process.exitCode = unlinkCommandAction(name);
3498
+ });
3499
+ program.command("doctor").argument("[stack]", "stack to check").description("check a stack before booting it").action(async (stack) => {
3500
+ process.exitCode = await doctorCommand(resolveStackName(stack));
3501
+ });
3502
+ return program;
3503
+ };
3504
+ var run = async (argv = process.argv) => {
3505
+ try {
3506
+ await buildProgram().parseAsync(argv);
3507
+ } catch (error) {
3508
+ if (error instanceof ConfigError) {
3509
+ console.error(`${paint("red", "config error")}
3510
+ ${error.format()}`);
3511
+ process.exitCode = 1;
3512
+ return;
3513
+ }
3514
+ throw error;
3515
+ }
3516
+ };
3517
+
3518
+ // src/index.ts
3519
+ installPipeGuards();
3520
+ run().catch((error) => {
3521
+ console.error(paint("red", error instanceof Error ? error.stack ?? error.message : String(error)));
3522
+ process.exitCode = 1;
3523
+ });
3524
+ //# sourceMappingURL=index.js.map