laracrew 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,82 @@ 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.0] — 2026-09-20
21
+
22
+ Graceful shutdown for any process, not only Laravel queue workers, and a `doctor` that only
23
+ applies its Laravel checks to Laravel.
24
+
25
+ laracrew has always been able to *run* anything — the supervisor takes a command and knows
26
+ nothing about the language behind it. But the two things that made it more than a process
27
+ runner, the graceful stop and the pre-flight checks, both assumed PHP. This release closes that
28
+ gap. Existing stack files keep working unchanged.
29
+
30
+ ### Added
31
+
32
+ - **`stop.exec`** — any command as the first step of the stop ladder. It runs in the service's
33
+ `cwd`, with the service's environment, and laracrew waits `graceMs` for the process to exit by
34
+ itself before escalating to the signal and the tree kill. A Celery worker, a BullMQ consumer or
35
+ a Compose project now shuts down as carefully as a queue worker:
36
+
37
+ ```yaml
38
+ stop: { exec: ["celery", "-A", "app", "control", "shutdown"], graceMs: 25000 }
39
+ stop: { exec: "npm run drain", graceMs: 10000 }
40
+ stop: { exec: ["docker", "compose", "stop"], graceMs: 30000 }
41
+ ```
42
+
43
+ - **`doctor` checks external dependencies.** Every `external: true` service is probed through the
44
+ `tcp` or `http` gate it already declares, so a stack's Postgres, RabbitMQ or HTTP dependency is
45
+ verified before boot — whatever the stack is written in.
46
+ - `doctor` reads `REDIS_URL` when a project sets it instead of `REDIS_HOST` / `REDIS_PORT`.
47
+
48
+ ### Changed
49
+
50
+ - **`stop.artisan` is now shorthand for `stop.exec`.** It resolves to `<project php> artisan
51
+ <command>`, run from the project root even when the service sets its own `cwd`. Behaviour for
52
+ existing Laravel stacks is unchanged; a service may declare one or the other, not both. A
53
+ service that declares its own graceful step now replaces an inherited one in either direction,
54
+ so a stack can default to `artisan: queue:restart` and still give one service its own `exec`.
55
+ - **`doctor` applies each check only where it means something.** It works out which projects run
56
+ PHP (from their commands and from `stop.artisan`) and which talk to Redis (from their `.env`
57
+ and their commands). A stack with no PHP in it gets no PHP findings; a stack with no Redis gets
58
+ no Redis findings. Laravel stacks see exactly what they saw before.
59
+ - A Redis address already covered by an `external: true` service's gate is no longer probed a
60
+ second time from the project's `.env`.
61
+ - `laracrew --help` and the generated `projects.yaml` no longer describe projects as Laravel-only.
62
+ `php:` is documented as what it is: the binary the `stop.artisan` shorthand runs.
63
+
64
+ ### Fixed
65
+
66
+ - **`laracrew --version` reported `0.1.0` on every release.** The version was a hardcoded
67
+ constant that nobody bumped, so `0.1.1` and `0.1.2` both identified themselves as `0.1.0`. It is
68
+ now baked in from `package.json` at build time, with a test that fails if the two ever disagree.
69
+ - **`stop.artisan` on a service with no project was silently ignored** — the graceful step was
70
+ skipped and nothing said so. It is now a config error that names `stop.exec` as the way out.
71
+ - **`doctor` failed on a machine without PHP** for a stack that contains no PHP: `php --version`
72
+ ran for every declared project and a missing binary was a blocking `✖` with exit code 1.
73
+ - **False Redis collision between projects that never touch Redis.** Two projects with no `.env`
74
+ both resolved to the same synthesized default namespace and were reported as sharing it.
75
+ - The "no `artisan` file" and "`.env` is missing or empty" warnings no longer fire for projects
76
+ that are not PHP projects.
77
+
78
+ ### Notes
79
+
80
+ - 244 tests. The graceful stop step had no coverage before this release; it now has unit tests
81
+ for both outcomes (the process exits by itself, and the ladder escalating when it does not),
82
+ plus tests for every new config error.
83
+ - The npm description and `laracrew --help` now say "your projects" rather than "your Laravel
84
+ projects". The README still leads with the Laravel story, which is what the tool was built for.
85
+
86
+ ## [0.1.2] — 2026-09-20
87
+
88
+ ### Added
89
+
90
+ - `repository`, `homepage` and `bugs` metadata, so the npm page links back to the source.
91
+
92
+ ## [0.1.1] — 2026-09-20
93
+
94
+ First release published to npm. No code changes from `0.1.0`.
95
+
20
96
  ## [0.1.0] — 2026-09-19
21
97
 
22
98
  First release. Boots and supervises the long-running processes of several Laravel projects at
@@ -90,5 +166,8 @@ once, from one command.
90
166
  - Accepted by the schema but not yet acted on: `watch`, `metrics` (read by `doctor` only), and
91
167
  `hooks`. Stack files written today stay valid.
92
168
 
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
169
+ [Unreleased]: https://github.com/vidux/laracrew/compare/v0.2.0...HEAD
170
+ [0.2.0]: https://github.com/vidux/laracrew/compare/v0.1.2...v0.2.0
171
+ [0.1.2]: https://github.com/vidux/laracrew/compare/v0.1.1...v0.1.2
172
+ [0.1.1]: https://github.com/vidux/laracrew/releases/tag/v0.1.1
173
+ [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
 
@@ -433,7 +438,7 @@ profiles:
433
438
  | `ready` | `tcp`, `http`, `logMatch` (regex over output) or `delayMs`, plus `timeoutMs` (default 30000) and `intervalMs` (default 250). Without it, "spawned" means ready. |
434
439
  | `restart` | `never` · `on-failure` (default) · `always`. |
435
440
  | `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`. |
441
+ | `stop` | `exec` (any graceful shutdown command), `artisan` (sugar for one that runs artisan, such as `queue:restart` or `horizon:terminate`), `signal`, `graceMs`. |
437
442
  | `url` | Recorded for the service; `laracrew open` is not built yet. |
438
443
  | `external` | Health-checked but never spawned — Redis, MySQL, a Docker service. |
439
444
  | `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 +505,24 @@ laracrew up dual --json | jq 'select(.type=="service:exit")'
500
505
 
501
506
  Exit code is 1 when anything is at `✖`, so it drops straight into a pre-flight script.
502
507
 
508
+ **Every Laravel-specific check is skipped on a stack that isn't Laravel.** `doctor` works out
509
+ which projects actually run PHP — from the commands they declare and from `stop.artisan` — and
510
+ only those get the `php --version` check and the "no `artisan` file" warning. The same goes for
511
+ Redis: a project is only checked for namespace collisions and reachability if its `.env` or its
512
+ commands say it talks to Redis. Run `doctor` on a Django or Node stack and you get the checks
513
+ that apply to it, not a wall of PHP complaints:
514
+
515
+ ```
516
+ ✔ stack "django-celery" is valid — 8 services
517
+ ✔ port 8000 is free
518
+ ✔ postgres is reachable — tcp 127.0.0.1:5432
519
+ ▲ redis is not reachable — tcp 127.0.0.1:6379 (ECONNREFUSED)
520
+ laracrew never starts an external service; anything that needs it will wait at its gate
521
+ ```
522
+
523
+ Services marked `external: true` are checked through the gate they already declare, so whatever
524
+ your stack depends on — Postgres, RabbitMQ, an HTTP service — gets verified before boot.
525
+
503
526
  ---
504
527
 
505
528
  ## How shutdown works
@@ -507,9 +530,8 @@ Exit code is 1 when anything is at `✖`, so it drops straight into a pre-flight
507
530
  This is where most process managers leave a mess, so it's worth stating exactly. Each step runs
508
531
  only if the previous one timed out:
509
532
 
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.
533
+ 1. **Graceful** — if the service declares `stop.exec`, run it and wait up to `graceMs` for the
534
+ process to exit on its own, having finished whatever it was holding.
513
535
  2. **Signal** — `SIGTERM` to the process group. Skipped on Windows, which has no equivalent.
514
536
  3. **Tree kill** — `taskkill /pid <pid> /T /F` on Windows, `kill(-pid)` on POSIX. This is what
515
537
  catches the child PHP server behind `artisan serve` and the Vite process behind `npm run dev`.
@@ -518,6 +540,19 @@ only if the previous one timed out:
518
540
  Stacks come down in reverse dependency order, parallel within a level. A second Ctrl-C escalates
519
541
  immediately and says so.
520
542
 
543
+ Step 1 is any command, so every worker gets the same treatment your queue workers do:
544
+
545
+ ```yaml
546
+ stop: { exec: ["celery", "-A", "app", "control", "shutdown"], graceMs: 20000 }
547
+ stop: { exec: "npm run drain", graceMs: 5000 }
548
+ stop: { exec: ["docker", "compose", "stop"], graceMs: 30000 }
549
+ stop: { artisan: "queue:restart", graceMs: 15000 } # shorthand for `<php> artisan queue:restart`
550
+ ```
551
+
552
+ `artisan` is the Laravel shorthand: it runs through the project's PHP binary, from the project
553
+ root, even when the service sets its own `cwd`. It needs a `project`; anything else uses `exec`.
554
+ Without either, the ladder starts at the signal.
555
+
521
556
  If a readiness gate fails during boot, laracrew rolls back everything it already started before
522
557
  exiting non-zero — you never get a half-booted stack you have to clean up by hand.
523
558
 
@@ -540,12 +575,15 @@ project, `LARACREW_PROJECT=<key>`.
540
575
 
541
576
  ## Status
542
577
 
543
- **v0.1.0 — supervisor and full-screen view are built and tested.**
578
+ **v0.2.0 — supervisor and full-screen view are built and tested.**
544
579
 
545
580
  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`.
581
+ exponential backoff, the graceful stop ladder — `stop.exec` for any process, `stop.artisan` as the
582
+ Laravel shorthand — the full-screen process tree with per-process log inspection, logs persisted to
583
+ disk with `laracrew logs` to read them back, plain and JSON renderers, per-stack global commands
584
+ (`link` / `unlink`), `doctor`, `init`, `ls`.
585
+
586
+ See [CHANGELOG.md](CHANGELOG.md) for what changed in each release.
549
587
 
550
588
  Accepted by the config schema but **not yet acted on** — they validate, so your stack files are
551
589
  future-proof, but nothing happens yet:
@@ -577,7 +615,7 @@ The full plan lives in [.claude/PLAN.md](.claude/PLAN.md), with the design in
577
615
  npm install
578
616
  npm run dev -- up example # tsx, no build step
579
617
  npm run build # tsup -> dist/index.js
580
- npm test # vitest, 226 tests
618
+ npm test # vitest, 244 tests
581
619
  npm run typecheck
582
620
  npm link # put `laracrew` on PATH while hacking on it
583
621
  ```
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.0" : 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
  });