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 +81 -2
- package/README.md +52 -14
- package/dist/index.js +154 -35
- package/dist/index.js.map +1 -1
- package/examples/README.md +7 -1
- package/examples/django-celery/stack.yaml +5 -0
- package/examples/node-api-and-web/stack.yaml +5 -2
- package/examples/polyglot-microservices/stack.yaml +4 -2
- package/package.json +2 -2
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/
|
|
94
|
-
[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
|
|
94
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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` | `
|
|
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. **
|
|
511
|
-
|
|
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.
|
|
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
|
|
547
|
-
|
|
548
|
-
renderers, per-stack global commands
|
|
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,
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
|
1391
|
-
|
|
1392
|
-
if (!artisan || !project) return void 0;
|
|
1428
|
+
const graceful = this.service.graceful;
|
|
1429
|
+
if (!graceful) return void 0;
|
|
1393
1430
|
return async () => {
|
|
1394
|
-
|
|
1395
|
-
this.#
|
|
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
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
2673
|
-
const
|
|
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([
|
|
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
|
|
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 #
|
|
2741
|
-
# envFile: .env # read for
|
|
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
|
-
#
|
|
2744
|
-
# path: D:/work/
|
|
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.
|
|
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
|
|
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
|
});
|