laracrew 0.1.2 → 0.2.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/CHANGELOG.md CHANGED
@@ -17,6 +17,96 @@ Planned, in order — see `.claude/PLAN.md`:
17
17
  - `laracrew run <task>` for one-shot cross-project sequences
18
18
  - Background daemon: `up --detach`, `attach`, `status`, `logs -f`
19
19
 
20
+ ## [0.2.1] — 2026-09-20
21
+
22
+ ### Added
23
+
24
+ - **A stack builder**, [`docs/stack-builder.html`](docs/stack-builder.html) — a single self-contained
25
+ page that writes a `stack.yaml` from projects and process commands as you fill them in. It
26
+ encodes the rules the config resolver enforces, so it catches the mistakes before laracrew does:
27
+ a service with no command, `stop.artisan` on a service with no project, a `needs:` pointing at
28
+ nothing, an auto-started service depending on one you have to start by hand, a dependency cycle.
29
+ It also shows the boot order the `needs:` graph produces, level by level.
30
+
31
+ Open it from disk or serve `docs/` with GitHub Pages. It is not part of the npm package — no
32
+ runtime dependencies, no build step, nothing to install.
33
+
34
+ ## [0.2.0] — 2026-09-20
35
+
36
+ Graceful shutdown for any process, not only Laravel queue workers, and a `doctor` that only
37
+ applies its Laravel checks to Laravel.
38
+
39
+ laracrew has always been able to *run* anything — the supervisor takes a command and knows
40
+ nothing about the language behind it. But the two things that made it more than a process
41
+ runner, the graceful stop and the pre-flight checks, both assumed PHP. This release closes that
42
+ gap. Existing stack files keep working unchanged.
43
+
44
+ ### Added
45
+
46
+ - **`stop.exec`** — any command as the first step of the stop ladder. It runs in the service's
47
+ `cwd`, with the service's environment, and laracrew waits `graceMs` for the process to exit by
48
+ itself before escalating to the signal and the tree kill. A Celery worker, a BullMQ consumer or
49
+ a Compose project now shuts down as carefully as a queue worker:
50
+
51
+ ```yaml
52
+ stop: { exec: ["celery", "-A", "app", "control", "shutdown"], graceMs: 25000 }
53
+ stop: { exec: "npm run drain", graceMs: 10000 }
54
+ stop: { exec: ["docker", "compose", "stop"], graceMs: 30000 }
55
+ ```
56
+
57
+ - **`doctor` checks external dependencies.** Every `external: true` service is probed through the
58
+ `tcp` or `http` gate it already declares, so a stack's Postgres, RabbitMQ or HTTP dependency is
59
+ verified before boot — whatever the stack is written in.
60
+ - `doctor` reads `REDIS_URL` when a project sets it instead of `REDIS_HOST` / `REDIS_PORT`.
61
+
62
+ ### Changed
63
+
64
+ - **`stop.artisan` is now shorthand for `stop.exec`.** It resolves to `<project php> artisan
65
+ <command>`, run from the project root even when the service sets its own `cwd`. Behaviour for
66
+ existing Laravel stacks is unchanged; a service may declare one or the other, not both. A
67
+ service that declares its own graceful step now replaces an inherited one in either direction,
68
+ so a stack can default to `artisan: queue:restart` and still give one service its own `exec`.
69
+ - **`doctor` applies each check only where it means something.** It works out which projects run
70
+ PHP (from their commands and from `stop.artisan`) and which talk to Redis (from their `.env`
71
+ and their commands). A stack with no PHP in it gets no PHP findings; a stack with no Redis gets
72
+ no Redis findings. Laravel stacks see exactly what they saw before.
73
+ - A Redis address already covered by an `external: true` service's gate is no longer probed a
74
+ second time from the project's `.env`.
75
+ - `laracrew --help` and the generated `projects.yaml` no longer describe projects as Laravel-only.
76
+ `php:` is documented as what it is: the binary the `stop.artisan` shorthand runs.
77
+
78
+ ### Fixed
79
+
80
+ - **`laracrew --version` reported `0.1.0` on every release.** The version was a hardcoded
81
+ constant that nobody bumped, so `0.1.1` and `0.1.2` both identified themselves as `0.1.0`. It is
82
+ now baked in from `package.json` at build time, with a test that fails if the two ever disagree.
83
+ - **`stop.artisan` on a service with no project was silently ignored** — the graceful step was
84
+ skipped and nothing said so. It is now a config error that names `stop.exec` as the way out.
85
+ - **`doctor` failed on a machine without PHP** for a stack that contains no PHP: `php --version`
86
+ ran for every declared project and a missing binary was a blocking `✖` with exit code 1.
87
+ - **False Redis collision between projects that never touch Redis.** Two projects with no `.env`
88
+ both resolved to the same synthesized default namespace and were reported as sharing it.
89
+ - The "no `artisan` file" and "`.env` is missing or empty" warnings no longer fire for projects
90
+ that are not PHP projects.
91
+
92
+ ### Notes
93
+
94
+ - 244 tests. The graceful stop step had no coverage before this release; it now has unit tests
95
+ for both outcomes (the process exits by itself, and the ladder escalating when it does not),
96
+ plus tests for every new config error.
97
+ - The npm description and `laracrew --help` now say "your projects" rather than "your Laravel
98
+ projects". The README still leads with the Laravel story, which is what the tool was built for.
99
+
100
+ ## [0.1.2] — 2026-09-20
101
+
102
+ ### Added
103
+
104
+ - `repository`, `homepage` and `bugs` metadata, so the npm page links back to the source.
105
+
106
+ ## [0.1.1] — 2026-09-20
107
+
108
+ First release published to npm. No code changes from `0.1.0`.
109
+
20
110
  ## [0.1.0] — 2026-09-19
21
111
 
22
112
  First release. Boots and supervises the long-running processes of several Laravel projects at
@@ -90,5 +180,9 @@ once, from one command.
90
180
  - Accepted by the schema but not yet acted on: `watch`, `metrics` (read by `doctor` only), and
91
181
  `hooks`. Stack files written today stay valid.
92
182
 
93
- [Unreleased]: https://github.com/OWNER/laracrew/compare/v0.1.0...HEAD
94
- [0.1.0]: https://github.com/OWNER/laracrew/releases/tag/v0.1.0
183
+ [Unreleased]: https://github.com/vidux/laracrew/compare/v0.2.1...HEAD
184
+ [0.2.1]: https://github.com/vidux/laracrew/compare/v0.2.0...v0.2.1
185
+ [0.2.0]: https://github.com/vidux/laracrew/compare/v0.1.2...v0.2.0
186
+ [0.1.2]: https://github.com/vidux/laracrew/compare/v0.1.1...v0.1.2
187
+ [0.1.1]: https://github.com/vidux/laracrew/releases/tag/v0.1.1
188
+ [0.1.0]: https://github.com/vidux/laracrew/releases/tag/v0.1.0
package/README.md CHANGED
@@ -90,8 +90,9 @@ they're holding before they exit, and nothing is left behind.
90
90
 
91
91
  Those run processes. laracrew knows what the processes *are*.
92
92
 
93
- - **It stops workers the Laravel way.** `php artisan queue:restart` first, wait for the current
94
- job to finish, *then* terminate. Never a job killed mid-flight.
93
+ - **It stops workers on their own terms.** `php artisan queue:restart` for Laravel,
94
+ `celery control shutdown` for Celery, any command you name — run first, wait for the process to
95
+ finish the job it is holding, *then* terminate. Never a job killed mid-flight.
95
96
  - **It kills whole process trees.** `php artisan serve` spawns a child PHP server; `npm run dev`
96
97
  spawns Vite. Killing the parent orphans them and the port stays bound. Every stop is a tree kill
97
98
  (`taskkill /T /F` on Windows, process-group kill on POSIX).
@@ -110,11 +111,15 @@ npm install -g laracrew
110
111
  laracrew init --examples
111
112
  ```
112
113
 
113
- > Not published to npm yet. Until it is, install from source:
114
- > `git clone https://github.com/vidux/laracrew && cd laracrew && npm install && npm run build && npm link`
114
+ Or from source:
115
+
116
+ ```bash
117
+ git clone https://github.com/vidux/laracrew && cd laracrew
118
+ npm install && npm run build && npm link
119
+ ```
115
120
 
116
121
  Requires **Node 20+**. Works on Windows, macOS and Linux. PHP is only needed for the projects
117
- laracrew runs, not for laracrew itself.
122
+ laracrew runs, not for laracrew itself — and only if those projects are PHP.
118
123
 
119
124
  ## Quick start
120
125
 
@@ -150,6 +155,11 @@ laracrew doctor dual
150
155
  laracrew up dual
151
156
  ```
152
157
 
158
+ **Prefer not to write YAML by hand?** Open [`docs/stack-builder.html`](docs/stack-builder.html) in
159
+ a browser. Add your projects and the commands that run under them, and it writes the `stack.yaml`
160
+ as you go — checking the same rules laracrew does, so a stack that looks right there boots. No
161
+ install, no build step; it is one file.
162
+
153
163
  ---
154
164
 
155
165
  ## One command per project set
@@ -433,7 +443,7 @@ profiles:
433
443
  | `ready` | `tcp`, `http`, `logMatch` (regex over output) or `delayMs`, plus `timeoutMs` (default 30000) and `intervalMs` (default 250). Without it, "spawned" means ready. |
434
444
  | `restart` | `never` · `on-failure` (default) · `always`. |
435
445
  | `backoff` | `initialMs`, `maxMs`, `factor`, `maxRestarts`, `resetAfterMs`. Delay is `min(initialMs × factor^n, maxMs)`; the counter resets after the service stays up for `resetAfterMs`. |
436
- | `stop` | `artisan` (a graceful command such as `queue:restart` or `horizon:terminate`), `signal`, `graceMs`. |
446
+ | `stop` | `exec` (any graceful shutdown command), `artisan` (sugar for one that runs artisan, such as `queue:restart` or `horizon:terminate`), `signal`, `graceMs`. |
437
447
  | `url` | Recorded for the service; `laracrew open` is not built yet. |
438
448
  | `external` | Health-checked but never spawned — Redis, MySQL, a Docker service. |
439
449
  | `autostart` | `false` defines the service without launching it. It shows as **idle** in the tree; select it and press `s` when you need it. |
@@ -500,6 +510,24 @@ laracrew up dual --json | jq 'select(.type=="service:exit")'
500
510
 
501
511
  Exit code is 1 when anything is at `✖`, so it drops straight into a pre-flight script.
502
512
 
513
+ **Every Laravel-specific check is skipped on a stack that isn't Laravel.** `doctor` works out
514
+ which projects actually run PHP — from the commands they declare and from `stop.artisan` — and
515
+ only those get the `php --version` check and the "no `artisan` file" warning. The same goes for
516
+ Redis: a project is only checked for namespace collisions and reachability if its `.env` or its
517
+ commands say it talks to Redis. Run `doctor` on a Django or Node stack and you get the checks
518
+ that apply to it, not a wall of PHP complaints:
519
+
520
+ ```
521
+ ✔ stack "django-celery" is valid — 8 services
522
+ ✔ port 8000 is free
523
+ ✔ postgres is reachable — tcp 127.0.0.1:5432
524
+ ▲ redis is not reachable — tcp 127.0.0.1:6379 (ECONNREFUSED)
525
+ laracrew never starts an external service; anything that needs it will wait at its gate
526
+ ```
527
+
528
+ Services marked `external: true` are checked through the gate they already declare, so whatever
529
+ your stack depends on — Postgres, RabbitMQ, an HTTP service — gets verified before boot.
530
+
503
531
  ---
504
532
 
505
533
  ## How shutdown works
@@ -507,9 +535,8 @@ Exit code is 1 when anything is at `✖`, so it drops straight into a pre-flight
507
535
  This is where most process managers leave a mess, so it's worth stating exactly. Each step runs
508
536
  only if the previous one timed out:
509
537
 
510
- 1. **Laravel graceful** — if the service declares `stop.artisan`, run it (`php artisan
511
- queue:restart`, `horizon:terminate`) and wait up to `graceMs` for the worker to exit on its own,
512
- having finished the job it was holding.
538
+ 1. **Graceful** — if the service declares `stop.exec`, run it and wait up to `graceMs` for the
539
+ process to exit on its own, having finished whatever it was holding.
513
540
  2. **Signal** — `SIGTERM` to the process group. Skipped on Windows, which has no equivalent.
514
541
  3. **Tree kill** — `taskkill /pid <pid> /T /F` on Windows, `kill(-pid)` on POSIX. This is what
515
542
  catches the child PHP server behind `artisan serve` and the Vite process behind `npm run dev`.
@@ -518,6 +545,19 @@ only if the previous one timed out:
518
545
  Stacks come down in reverse dependency order, parallel within a level. A second Ctrl-C escalates
519
546
  immediately and says so.
520
547
 
548
+ Step 1 is any command, so every worker gets the same treatment your queue workers do:
549
+
550
+ ```yaml
551
+ stop: { exec: ["celery", "-A", "app", "control", "shutdown"], graceMs: 20000 }
552
+ stop: { exec: "npm run drain", graceMs: 5000 }
553
+ stop: { exec: ["docker", "compose", "stop"], graceMs: 30000 }
554
+ stop: { artisan: "queue:restart", graceMs: 15000 } # shorthand for `<php> artisan queue:restart`
555
+ ```
556
+
557
+ `artisan` is the Laravel shorthand: it runs through the project's PHP binary, from the project
558
+ root, even when the service sets its own `cwd`. It needs a `project`; anything else uses `exec`.
559
+ Without either, the ladder starts at the signal.
560
+
521
561
  If a readiness gate fails during boot, laracrew rolls back everything it already started before
522
562
  exiting non-zero — you never get a half-booted stack you have to clean up by hand.
523
563
 
@@ -540,12 +580,15 @@ project, `LARACREW_PROJECT=<key>`.
540
580
 
541
581
  ## Status
542
582
 
543
- **v0.1.0 — supervisor and full-screen view are built and tested.**
583
+ **v0.2.1 — supervisor and full-screen view are built and tested.**
544
584
 
545
585
  Working now: config pipeline, dependency-ordered boot with readiness gates, restart policies with
546
- exponential backoff, the Laravel-aware stop ladder, the full-screen process tree with per-process
547
- log inspection, logs persisted to disk with `laracrew logs` to read them back, plain and JSON
548
- renderers, per-stack global commands (`link` / `unlink`), `doctor`, `init`, `ls`.
586
+ exponential backoff, the graceful stop ladder — `stop.exec` for any process, `stop.artisan` as the
587
+ Laravel shorthand — the full-screen process tree with per-process log inspection, logs persisted to
588
+ disk with `laracrew logs` to read them back, plain and JSON renderers, per-stack global commands
589
+ (`link` / `unlink`), `doctor`, `init`, `ls`.
590
+
591
+ See [CHANGELOG.md](CHANGELOG.md) for what changed in each release.
549
592
 
550
593
  Accepted by the config schema but **not yet acted on** — they validate, so your stack files are
551
594
  future-proof, but nothing happens yet:
@@ -577,7 +620,7 @@ The full plan lives in [.claude/PLAN.md](.claude/PLAN.md), with the design in
577
620
  npm install
578
621
  npm run dev -- up example # tsx, no build step
579
622
  npm run build # tsup -> dist/index.js
580
- npm test # vitest, 226 tests
623
+ npm test # vitest, 244 tests
581
624
  npm run typecheck
582
625
  npm link # put `laracrew` on PATH while hacking on it
583
626
  ```
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/program.ts
4
+ import { readFileSync as readFileSync4 } from "fs";
5
+ import path11 from "path";
6
+ import { fileURLToPath as fileURLToPath2 } from "url";
4
7
  import { Command } from "commander";
5
8
 
6
9
  // src/core/config/errors.ts
@@ -137,8 +140,15 @@ var backoffSchema = z.object({
137
140
  /** Stay up this long and the attempt counter resets, so a slow leak can't exhaust it. */
138
141
  resetAfterMs: z.number().int().positive().default(6e4)
139
142
  }).strict();
143
+ var commandSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
140
144
  var stopPolicySchema = z.object({
141
- /** Laravel-native graceful shutdown, e.g. "queue:restart" or "horizon:terminate". */
145
+ /**
146
+ * Step 1 of the stop ladder: any command that asks the process to finish what it is
147
+ * holding and exit by itself — `celery control shutdown`, `docker compose stop`, a
148
+ * drain script. Runs in the service's cwd; laracrew waits `graceMs` for the exit.
149
+ */
150
+ exec: commandSchema.optional(),
151
+ /** Sugar for `exec` on a Laravel project: runs `<php> artisan <this>` from its root. */
142
152
  artisan: z.string().optional(),
143
153
  signal: z.string().default("SIGTERM"),
144
154
  graceMs: z.number().int().positive().default(1e4)
@@ -153,7 +163,6 @@ var metricsSchema = z.object({
153
163
  queues: z.array(z.string()).default([]),
154
164
  streams: z.array(z.object({ key: z.string(), group: z.string() }).strict()).default([])
155
165
  }).strict();
156
- var commandSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
157
166
  var serviceSchema = z.object({
158
167
  name: z.string().min(1),
159
168
  project: z.string().optional(),
@@ -719,10 +728,35 @@ var mergeService = (raw, stack, projects, ports, processEnv, colorIndex) => {
719
728
  throw error;
720
729
  }
721
730
  const cwd = service.cwd ? path4.resolve(project?.path ?? stack.dir, service.cwd) : project?.path ?? stack.dir;
731
+ const inheritedStop = { ...defaults.stop };
732
+ const ownStop = service.stop ?? {};
733
+ if (ownStop.exec !== void 0) delete inheritedStop.artisan;
734
+ if (ownStop.artisan !== void 0) delete inheritedStop.exec;
735
+ const stop = stopPolicySchema.parse({ ...inheritedStop, ...ownStop });
736
+ if (stop.exec !== void 0 && stop.artisan !== void 0) {
737
+ throw new ConfigError(
738
+ `service "${service.name}" declares both \`stop.exec\` and \`stop.artisan\``,
739
+ { file: stack.file, details: ["keep one \u2014 `artisan` is only shorthand for an `exec` that runs artisan"] }
740
+ );
741
+ }
742
+ if (stop.artisan !== void 0 && !project) {
743
+ throw new ConfigError(
744
+ `service "${service.name}" declares \`stop.artisan\` but belongs to no project`,
745
+ {
746
+ file: stack.file,
747
+ details: ["`artisan` runs `<project php> artisan ...`, so it needs a project", "for any other command use `stop.exec`"]
748
+ }
749
+ );
750
+ }
751
+ const graceful = stop.exec ? { command: toCommand(stop.exec), cwd } : stop.artisan && project ? (
752
+ // artisan must run from the Laravel root even when the service sets its own cwd.
753
+ { command: { kind: "argv", file: project.php, args: ["artisan", ...stop.artisan.split(/\s+/)] }, cwd: project.path }
754
+ ) : void 0;
722
755
  return {
723
756
  name: service.name,
724
757
  ...project ? { project } : {},
725
758
  ...service.cmd ? { command: toCommand(service.cmd) } : {},
759
+ ...graceful ? { graceful } : {},
726
760
  cwd,
727
761
  env: { ...service.env },
728
762
  groups: service.groups,
@@ -730,7 +764,7 @@ var mergeService = (raw, stack, projects, ports, processEnv, colorIndex) => {
730
764
  ...service.ready ? { ready: service.ready } : {},
731
765
  restart: service.restart ?? defaults.restart,
732
766
  backoff: backoffSchema.parse({ ...defaults.backoff, ...service.backoff ?? {} }),
733
- stop: stopPolicySchema.parse({ ...defaults.stop, ...service.stop ?? {} }),
767
+ stop,
734
768
  ...service.watch ? { watch: service.watch } : {},
735
769
  ...service.metrics ? { metrics: service.metrics } : {},
736
770
  ...service.url ? { url: service.url } : {},
@@ -1385,15 +1419,17 @@ var ManagedProcess = class {
1385
1419
  }, delayMs);
1386
1420
  this.#backoffTimer.unref?.();
1387
1421
  }
1388
- /** The Laravel-native graceful shutdown, when the service declares one. */
1422
+ /**
1423
+ * Step 1 of the stop ladder: ask the process to finish what it is holding and exit by
1424
+ * itself. What that command is — `queue:restart`, `celery control shutdown`, a drain
1425
+ * script — was decided in `resolve.ts`; this only runs it.
1426
+ */
1389
1427
  #graceful() {
1390
- const artisan = this.service.stop.artisan;
1391
- const project = this.service.project;
1392
- if (!artisan || !project) return void 0;
1428
+ const graceful = this.service.graceful;
1429
+ if (!graceful) return void 0;
1393
1430
  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 });
1431
+ this.#log(`stop: ${describeCommand(graceful.command)}`);
1432
+ await runOnce(graceful.command, { cwd: graceful.cwd, env: this.#env, timeoutMs: 15e3 });
1397
1433
  };
1398
1434
  }
1399
1435
  async stop() {
@@ -2496,36 +2532,88 @@ var waitUntilIdle = (supervisor, stopRequested) => new Promise((resolve) => {
2496
2532
 
2497
2533
  // src/cli/commands/doctor.ts
2498
2534
  var PORT_IN_COMMAND = /--port[= ](\d{2,5})/g;
2535
+ var commandLine = (service) => {
2536
+ if (!service.command) return "";
2537
+ return service.command.kind === "shell" ? service.command.line : [service.command.file, ...service.command.args].join(" ");
2538
+ };
2539
+ var PHP_BINARY = /(^|[\\/\s"'])php[\d.]*(\.exe)?(\s|$)/i;
2540
+ var phpProjects = (stack) => {
2541
+ const keys = /* @__PURE__ */ new Set();
2542
+ for (const service of stack.services) {
2543
+ const key = service.project?.key;
2544
+ if (!key) continue;
2545
+ const line = commandLine(service);
2546
+ const php = stack.projects[key]?.php;
2547
+ if (service.stop.artisan || PHP_BINARY.test(line) || php !== void 0 && line.startsWith(php)) {
2548
+ keys.add(key);
2549
+ }
2550
+ }
2551
+ return keys;
2552
+ };
2553
+ var REDIS_DRIVER_KEYS = [
2554
+ "QUEUE_CONNECTION",
2555
+ "CACHE_STORE",
2556
+ "CACHE_DRIVER",
2557
+ "SESSION_DRIVER",
2558
+ "BROADCAST_CONNECTION",
2559
+ "BROADCAST_DRIVER"
2560
+ ];
2561
+ var redisProjects = (stack) => {
2562
+ const keys = /* @__PURE__ */ new Set();
2563
+ for (const [key, project] of Object.entries(stack.projects)) {
2564
+ const declared = Object.keys(project.env).some((name) => name.startsWith("REDIS_"));
2565
+ const driver = REDIS_DRIVER_KEYS.some((name) => project.env[name]?.toLowerCase() === "redis");
2566
+ if (declared || driver) keys.add(key);
2567
+ }
2568
+ for (const service of stack.services) {
2569
+ const key = service.project?.key;
2570
+ if (!key) continue;
2571
+ if (service.metrics || /\b(redis|horizon)\b/i.test(commandLine(service))) keys.add(key);
2572
+ }
2573
+ return keys;
2574
+ };
2499
2575
  var declaredPorts = (stack) => {
2500
2576
  const ports = new Set(stack.ports);
2501
2577
  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)) {
2578
+ for (const match of commandLine(service).matchAll(PORT_IN_COMMAND)) {
2504
2579
  const port = Number(match[1]);
2505
2580
  if (Number.isInteger(port)) ports.add(port);
2506
2581
  }
2507
2582
  }
2508
2583
  return [...ports].sort((a, b) => a - b);
2509
2584
  };
2510
- var redisTargetOf = (project) => `${project.env.REDIS_HOST ?? "127.0.0.1"}:${project.env.REDIS_PORT ?? "6379"}`;
2585
+ var parseRedisUrl = (url) => {
2586
+ try {
2587
+ const parsed = new URL(url);
2588
+ return parsed.hostname ? { host: parsed.hostname, port: parsed.port || "6379" } : void 0;
2589
+ } catch {
2590
+ return void 0;
2591
+ }
2592
+ };
2593
+ var redisTargetOf = (project) => {
2594
+ const url = project.env.REDIS_URL ? parseRedisUrl(project.env.REDIS_URL) : void 0;
2595
+ const host = project.env.REDIS_HOST ?? url?.host ?? "127.0.0.1";
2596
+ const port = project.env.REDIS_PORT ?? url?.port ?? "6379";
2597
+ return `${host}:${port}`;
2598
+ };
2511
2599
  var slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2512
2600
  var redisPrefixOf = (project) => project.env.REDIS_PREFIX ?? `${slug(project.env.APP_NAME ?? "laravel")}_database_`;
2513
2601
  var redisNamespaceOf = (project) => `${redisTargetOf(project)}/${project.env.REDIS_DB ?? "0"}#${redisPrefixOf(project)}`;
2514
- var checkProjects = (stack) => {
2602
+ var checkProjects = (stack, php, redis) => {
2515
2603
  const findings = [];
2516
2604
  for (const project of Object.values(stack.projects)) {
2517
2605
  if (!existsSync4(project.path)) {
2518
2606
  findings.push({ level: "error", message: `project "${project.key}": path does not exist \u2014 ${project.path}` });
2519
2607
  continue;
2520
2608
  }
2521
- if (!existsSync4(path7.join(project.path, "artisan"))) {
2609
+ if (php.has(project.key) && !existsSync4(path7.join(project.path, "artisan"))) {
2522
2610
  findings.push({
2523
2611
  level: "warn",
2524
2612
  message: `project "${project.key}": no \`artisan\` file in ${project.path}`,
2525
2613
  hint: "laracrew will still run the commands, but this does not look like a Laravel root"
2526
2614
  });
2527
2615
  }
2528
- if (Object.keys(project.env).length === 0) {
2616
+ if (Object.keys(project.env).length === 0 && (php.has(project.key) || redis.has(project.key))) {
2529
2617
  findings.push({
2530
2618
  level: "warn",
2531
2619
  message: `project "${project.key}": ${project.envFile} is missing or empty`,
@@ -2535,9 +2623,9 @@ var checkProjects = (stack) => {
2535
2623
  }
2536
2624
  return findings;
2537
2625
  };
2538
- var checkRedisCollisions = (stack) => {
2626
+ var checkRedisCollisions = (stack, redis) => {
2539
2627
  const findings = [];
2540
- const projects = Object.values(stack.projects);
2628
+ const projects = Object.values(stack.projects).filter((project) => redis.has(project.key));
2541
2629
  const byNamespace = /* @__PURE__ */ new Map();
2542
2630
  for (const project of projects) {
2543
2631
  const namespace = redisNamespaceOf(project);
@@ -2611,8 +2699,7 @@ var checkQueueConnections = (stack) => {
2611
2699
  const workerProjects = new Set(
2612
2700
  stack.services.filter((service) => {
2613
2701
  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);
2702
+ return /\b(queue:work|queue:listen|horizon)\b/.test(commandLine(service));
2616
2703
  }).map((service) => service.project?.key).filter((key) => Boolean(key))
2617
2704
  );
2618
2705
  for (const key of workerProjects) {
@@ -2630,8 +2717,10 @@ var checkQueueConnections = (stack) => {
2630
2717
  }
2631
2718
  return findings;
2632
2719
  };
2633
- var checkPhp = async (stack) => {
2634
- const binaries = new Set(Object.values(stack.projects).map((project) => project.php));
2720
+ var checkPhp = async (stack, php) => {
2721
+ const binaries = new Set(
2722
+ Object.values(stack.projects).filter((project) => php.has(project.key)).map((project) => project.php)
2723
+ );
2635
2724
  const findings = [];
2636
2725
  for (const binary of binaries) {
2637
2726
  try {
@@ -2669,8 +2758,29 @@ var checkPorts = async (stack) => {
2669
2758
  }
2670
2759
  return findings;
2671
2760
  };
2672
- var checkRedisReachable = async (stack) => {
2673
- const targets = new Set(Object.values(stack.projects).map(redisTargetOf));
2761
+ var checkExternalServices = async (stack) => {
2762
+ const external = stack.services.filter((service) => service.external && (service.ready?.tcp ?? service.ready?.http));
2763
+ return Promise.all(
2764
+ external.map(async (service) => {
2765
+ const probe = service.ready;
2766
+ const label = describeProbe(probe);
2767
+ const result = probe.http ? await httpProbe(probe.http, 2e3) : await tcpProbe(probe.tcp, 2e3);
2768
+ return result.ok ? { level: "ok", message: `${service.name} is reachable \u2014 ${label}` } : {
2769
+ level: "warn",
2770
+ message: `${service.name} is not reachable \u2014 ${label}${result.detail ? ` (${result.detail})` : ""}`,
2771
+ hint: "laracrew never starts an external service; anything that needs it will wait at its gate"
2772
+ };
2773
+ })
2774
+ );
2775
+ };
2776
+ var externalTcpTargets = (stack) => new Set(
2777
+ stack.services.filter((service) => service.external && service.ready?.tcp).map((service) => service.ready.tcp)
2778
+ );
2779
+ var checkRedisReachable = async (stack, redis) => {
2780
+ const covered = externalTcpTargets(stack);
2781
+ const targets = new Set(
2782
+ Object.values(stack.projects).filter((project) => redis.has(project.key)).map(redisTargetOf).filter((target) => !covered.has(target))
2783
+ );
2674
2784
  const findings = [];
2675
2785
  for (const target of targets) {
2676
2786
  const result = await tcpProbe(target, 2e3);
@@ -2688,13 +2798,20 @@ var runDoctor = async (stackName, env = process.env) => {
2688
2798
  if (error instanceof ConfigError) return [{ level: "error", message: error.format() }];
2689
2799
  throw error;
2690
2800
  }
2801
+ const php = phpProjects(stack);
2802
+ const redis = redisProjects(stack);
2691
2803
  const findings = [
2692
2804
  { level: "ok", message: `stack "${stack.name}" is valid \u2014 ${stack.services.length} services` },
2693
- ...checkProjects(stack),
2805
+ ...checkProjects(stack, php, redis),
2694
2806
  ...checkQueueConnections(stack),
2695
- ...checkRedisCollisions(stack)
2807
+ ...checkRedisCollisions(stack, redis)
2696
2808
  ];
2697
- const async = await Promise.all([checkPhp(stack), checkPorts(stack), checkRedisReachable(stack)]);
2809
+ const async = await Promise.all([
2810
+ checkPhp(stack, php),
2811
+ checkPorts(stack),
2812
+ checkExternalServices(stack),
2813
+ checkRedisReachable(stack, redis)
2814
+ ]);
2698
2815
  return [...findings, ...async.flat()];
2699
2816
  };
2700
2817
  var doctorCommand = async (stackName, env = process.env) => {
@@ -2728,8 +2845,11 @@ metricsIntervalMs: 2000
2728
2845
  # The stack \`laracrew up\` boots when you don't name one.
2729
2846
  # defaultStack: dual
2730
2847
  `;
2731
- var PROJECTS_YAML = `# Your Laravel projects, referenced by key from any stack.
2848
+ var PROJECTS_YAML = `# Your projects, referenced by key from any stack. A project is just a root directory
2849
+ # plus the defaults its services inherit \u2014 cwd, .env and colour. Any language.
2732
2850
  # Paths may use forward slashes on Windows.
2851
+ #
2852
+ # Declaring projects here is optional: a service can carry its own \`cwd\` instead.
2733
2853
  projects: {}
2734
2854
 
2735
2855
  # Example \u2014 delete the \`{}\` above and uncomment:
@@ -2737,12 +2857,11 @@ projects: {}
2737
2857
  # projects:
2738
2858
  # api:
2739
2859
  # 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_*
2860
+ # php: php # only used by \`stop: { artisan: ... }\`; ignore it for non-PHP projects
2861
+ # envFile: .env # read for \${project.env:KEY} tokens, and by \`doctor\`
2742
2862
  # color: cyan
2743
- # portal:
2744
- # path: D:/work/portal
2745
- # php: php
2863
+ # web:
2864
+ # path: D:/work/web
2746
2865
  # envFile: .env
2747
2866
  # color: magenta
2748
2867
  `;
@@ -3436,7 +3555,7 @@ ${paint("bold", "TASKS")}`);
3436
3555
  };
3437
3556
 
3438
3557
  // src/cli/program.ts
3439
- var VERSION = "0.1.0";
3558
+ var VERSION = true ? "0.2.1" : versionFromPackageJson();
3440
3559
  var resolveStackName = (explicit, env = process.env) => {
3441
3560
  if (explicit) return explicit;
3442
3561
  const configured = loadGlobalConfig(env).defaultStack;
@@ -3454,7 +3573,7 @@ var resolveStackName = (explicit, env = process.env) => {
3454
3573
  var collect = (value, previous) => [...previous, value];
3455
3574
  var buildProgram = () => {
3456
3575
  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();
3576
+ program.name("laracrew").description("Boot and supervise all the long-running processes of your projects with one command.").version(VERSION, "-v, --version").showHelpAfterError();
3458
3577
  program.command("init").description("create ~/.laracrew").option("--examples", "also write a runnable demo stack and a two-project template").action((options) => {
3459
3578
  initCommand({ examples: options.examples ?? false });
3460
3579
  });