laracrew 0.1.1 → 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/README.md CHANGED
@@ -1,598 +1,638 @@
1
- <div align="center">
2
-
3
- # laracrew
4
-
5
- **Boot every long-running process of every Laravel project you're working on — with one command.**
6
-
7
- `php artisan serve` · `queue:work` · `horizon` · Redis stream listeners · `schedule:work` · `npm run dev`
8
- — across two, three or ten projects, in the right order, supervised, in one terminal.
9
-
10
- </div>
11
-
12
- ---
13
-
14
- ## The problem
15
-
16
- You develop two Laravel apps that talk to each other. Starting work means opening eight to
17
- fourteen terminal tabs and typing, in the right order:
18
-
19
- ```bash
20
- cd D:/work/api && php artisan serve
21
- cd D:/work/api && php artisan queue:work redis --queue=high,default
22
- cd D:/work/api && php artisan streams:listen orders
23
- cd D:/work/api && php artisan schedule:work
24
- cd D:/work/api && npm run dev
25
- cd D:/work/portal && php artisan serve --port=8001
26
- cd D:/work/portal && php artisan queue:work redis --queue=default
27
- cd D:/work/portal && php artisan streams:listen inventory
28
- ```
29
-
30
- Every one is long-running. When a worker dies you don't notice. When you edit a job class you
31
- have to remember PHP workers cache code. When you close the terminal, half of them survive as
32
- orphans still holding port 8000.
33
-
34
- ## The fix
35
-
36
- ```bash
37
- laracrew up dual
38
- ```
39
-
40
- ```
41
- laracrew · dual up 00:15:27 · 6/7 running all healthy
42
- ────────────────────────────────────────────────────────────────────────────────────────────
43
- up/down select enter inspect log 0-9 jump r restart s stop/start a all logs
44
- ? help q quit
45
- STACK DETAILS (process tree)
46
-
47
- DEPENDENT SERVICES (checked, not managed)
48
- ● redis ready tcp 127.0.0.1:6379
49
-
50
- processes (7 managed)
51
- │
52
- ├─ ▸ [0] ● api:serve running 15m 04s
53
- ├─ [1] ● api:queue running 15m 04s ⟳2
54
- ├─ [2] ● api:streams running 15m 04s
55
- ├─ [3] ● api:schedule running 15m 04s
56
- ├─ [4] ● portal:serve running 14m 58s
57
- ├─ [5] ◼ portal:queue stopped
58
- └─ [6] ● portal:streams running 14m 58s
59
- │
60
- └─ Ready in 1.9s. Awaiting keyboard input…
61
- ```
62
-
63
- Services marked `external: true` — Redis, Postgres, anything laracrew checks but never runs —
64
- sit above the tree under their own heading, with the probe they are checked with. They carry no
65
- index, because there is nothing to start or stop; if one is unreachable the header says
66
- `1 dependency down` before anything else.
67
-
68
- **The default screen never streams logs.** Nine services interleaving output is unreadable — you
69
- can't see the shape of the fleet and you can't follow any one process. So you get the tree, and
70
- you open logs deliberately: press `2`, read `api:queue`, press `esc`.
71
-
72
- ```
73
- api:queue running · pid 14184 · up 15m 04s
74
- $ php artisan queue:work redis --queue=high,default
75
- D:/work/api
76
- ────────────────────────────────────────────────────────────────────────────────────────────
77
- 08:51:30 | Processing: App\Jobs\SyncOrder
78
- 08:51:30 | Processed: App\Jobs\SyncOrder (412ms)
79
- 08:51:31 | Processing: App\Jobs\NotifyPortal
80
- ```
81
-
82
- One Ctrl-C stops all of it, in reverse dependency order, gracefully — workers finish the job
83
- they're holding before they exit, and nothing is left behind.
84
-
85
- ---
86
-
87
- ## Why not `concurrently`, `pm2` or `docker compose`
88
-
89
- Those run processes. laracrew knows what the processes *are*.
90
-
91
- - **It stops workers the Laravel way.** `php artisan queue:restart` first, wait for the current
92
- job to finish, *then* terminate. Never a job killed mid-flight.
93
- - **It kills whole process trees.** `php artisan serve` spawns a child PHP server; `npm run dev`
94
- spawns Vite. Killing the parent orphans them and the port stays bound. Every stop is a tree kill
95
- (`taskkill /T /F` on Windows, process-group kill on POSIX).
96
- - **It gates on readiness, not on sleep.** `portal:serve` doesn't start until Redis answers *and*
97
- `api:serve` returns HTTP 200.
98
- - **It knows your `.env`.** `laracrew doctor` catches two projects quietly sharing one Redis
99
- database *and* a queue name — where each app's workers silently steal the other's jobs. That
100
- class of bug eats afternoons.
101
-
102
- ---
103
-
104
- ## Install
105
-
106
- ```bash
107
- npm install -g laracrew
108
- laracrew init --examples
109
- ```
110
-
111
- > Not published to npm yet. Until it is, install from source:
112
- > `git clone <repo> && cd laracrew && npm install && npm run build && npm link`
113
-
114
- Requires **Node 20+**. Works on Windows, macOS and Linux. PHP is only needed for the projects
115
- laracrew runs, not for laracrew itself.
116
-
117
- ## Quick start
118
-
119
- ```bash
120
- laracrew init --examples # creates ~/.laracrew with a demo stack and a two-project template
121
- laracrew up example # runs the demo — no Laravel project needed, proves it works here
122
- laracrew ls # what's defined
123
- ```
124
-
125
- `laracrew init` on its own creates just the config files. Add `--examples` when you want the
126
- runnable demo stack and the ready-made two-project template to start from.
127
-
128
- Then point it at real projects. Edit `~/.laracrew/projects.yaml`:
129
-
130
- ```yaml
131
- projects:
132
- api:
133
- path: D:/work/api
134
- php: php # or an absolute path to a specific PHP build
135
- envFile: .env
136
- color: cyan
137
- portal:
138
- path: D:/work/portal
139
- php: php
140
- color: magenta
141
- ```
142
-
143
- `laracrew init` already wrote you a `dual` stack wired for exactly this scenario. Check it, then
144
- boot it:
145
-
146
- ```bash
147
- laracrew doctor dual
148
- laracrew up dual
149
- ```
150
-
151
- ---
152
-
153
- ## One command per project set
154
-
155
- Typing `laracrew up dual` every morning gets old, and you have more than one project set. Give
156
- each set its own global command:
157
-
158
- ```bash
159
- laracrew link dual
160
- ```
161
-
162
- ```
163
- created dual -> laracrew up dual
164
-
165
- in C:\Users\you\AppData\Roaming\npm
166
-
167
- Run it from anywhere: dual
168
- Flags pass straight through: dual --only workers
169
- ```
170
-
171
- From then on, one word boots that whole set — from any directory:
172
-
173
- ```bash
174
- dual # boots all 8 services of the dual stack
175
- dual --only workers # every `laracrew up` flag still works
176
- ```
177
-
178
- A stack names its own command in `stack.yaml`:
179
-
180
- ```yaml
181
- name: dual
182
- command: dual # what `laracrew link` installs; defaults to the stack name
183
- ```
184
-
185
- So you end up with one command per set — `dual`, `billing`, `legacy` — each booting its own
186
- fleet of projects.
187
-
188
- | | |
189
- |---|---|
190
- | `laracrew link <stack>` | Install the command. Re-run any time to update it. |
191
- | `laracrew link <stack> --as <name>` | Use a different name than the stack declares. |
192
- | `laracrew link --all` | Install for every stack that declares `command:`. |
193
- | `laracrew link <stack> --dir <path>` | Install somewhere other than the default. |
194
- | `laracrew unlink <name>` | Remove it again. |
195
- | `laracrew ls` | Shows which stacks have a command installed. |
196
-
197
- **Where they go.** Into the same directory as `laracrew` itself — the npm global bin, which is
198
- already on your PATH. On Windows you get three files (`dual`, `dual.cmd`, `dual.ps1`) so the
199
- command behaves identically in Git Bash, cmd and PowerShell. Override with `--dir` or the
200
- `LARACREW_BIN` environment variable. If laracrew can't find a directory that's on your PATH, it
201
- falls back to `~/.laracrew/bin` and prints the one line you need to add it.
202
-
203
- **It won't stomp on anything.** Every generated file carries a `laracrew-generated` marker.
204
- laracrew refuses to overwrite a file it didn't write, refuses to shadow names like `npm` or
205
- `git`, and `unlink` leaves foreign files alone. Pass `--force` if you genuinely mean it.
206
-
207
- ---
208
-
209
- ## Logs survive
210
-
211
- The live view keeps the last few thousand lines per service in memory — minutes, on a busy
212
- stack. Everything is also written to disk, so the exception you watched scroll past is still
213
- there tomorrow.
214
-
215
- ```bash
216
- laracrew logs api:queue # last 200 lines, after the fact
217
- laracrew logs api:queue -f # and keep following
218
- laracrew logs --all --since 10m # every service, merged and time-ordered
219
- laracrew logs --list # which services have a log
220
- ```
221
-
222
- Files land in `~/.laracrew/logs/<stack>/<service>.log`, rotated at 5 MB with one older copy
223
- kept. They are plain text with a sortable local timestamp and no colour codes, so your usual
224
- tools work on them directly:
225
-
226
- ```
227
- 2026-09-19 11:35:25.565 stderr PaymentFailedException: card declined
228
- ```
229
-
230
- ```bash
231
- grep -i exception ~/.laracrew/logs/dual/api-queue.log
232
- ```
233
-
234
- This is on by default. Turn it off per stack if you would rather not:
235
-
236
- ```yaml
237
- defaults:
238
- logs: { toFile: false }
239
- ```
240
-
241
- | Setting | Default | Meaning |
242
- |---|---|---|
243
- | `logs.toFile` | `true` | Write every line to disk |
244
- | `logs.maxLines` | `5000` | Lines kept in memory for the live view |
245
- | `logs.maxFileBytes` | `5000000` | Rotate a service's log past this size |
246
- | `logs.keepFiles` | `1` | Rotated copies kept beside the current file |
247
-
248
- ---
249
-
250
- ## Starting things on demand
251
-
252
- Not every command should run all day. A scheduler tick, a one-off sync listener, a queue you
253
- only drain occasionally — define them in the stack, but don't launch them:
254
-
255
- ```yaml
256
- - name: api:queue
257
- project: api
258
- cmd: ["php", "artisan", "queue:work"] # starts with the stack
259
-
260
- - name: api:streams
261
- project: api
262
- cmd: ["php", "artisan", "redis-stream:run", "orders_sync"]
263
- autostart: false # defined, listed, not launched
264
-
265
- - name: api:schedule
266
- project: api
267
- cmd: ["php", "artisan", "schedule:run"]
268
- autostart: false
269
- restart: never # one tick, not a daemon
270
- ```
271
-
272
- They appear in the tree as **idle**, waiting for you:
273
-
274
- ```
275
- ├─ [1] ● api:serve running 8s
276
- ├─ [2] ● api:queue running 8s
277
- ├─ [3] ○ api:streams idle press s
278
- ├─ [4] ○ api:schedule idle press s
279
- ├─ [5] ● portal:queue running 8s
280
- ```
281
-
282
- Select one and press `s` to start it; `s` again to stop it. The header counts them
283
- (`4/7 running · 2 idle`) so you can see at a glance what is dormant.
284
-
285
- One rule the config enforces: a service that starts at launch may not `needs:` a service you
286
- have to start by hand — that would leave it waiting on a gate nobody opened. laracrew refuses
287
- the stack with a message naming both services rather than hanging.
288
-
289
- ---
290
-
291
- ## Driving it
292
-
293
- | Key | Does |
294
- |---|---|
295
- | `up` `down` / `j` `k` | Move the selection |
296
- | `0`-`9` | Jump to that process **and** open its log |
297
- | `enter` | Inspect the selected process |
298
- | `esc` | Back to the tree |
299
- | `a` | Merged log across every service - the firehose, on demand |
300
- | `r` | Restart the selected process (graceful: `queue:restart` first) |
301
- | `s` | Stop it, or start it again if stopped |
302
- | `f` / `g` / `G` | Follow-pause tailing; jump to top or bottom |
303
- | `?` | Help |
304
- | `q` / `ctrl-c` | Quit - stops every process first |
305
-
306
- The view is plain ANSI on `node:readline`, no Ink and no React. It repaints on a 250 ms poll
307
- rather than per log line, so a worker emitting 500 lines a second costs nothing to display.
308
- `LARACREW_ASCII=1` swaps in an ASCII glyph set for terminals that mangle box drawing.
309
-
310
- When stdout is not a TTY you get prefixed interleaved logs automatically, so
311
- `laracrew up dual | tee dev.log` does the right thing with no flag.
312
-
313
- ---
314
-
315
- ## Examples
316
-
317
- Five working stacks in [`examples/`](examples/) — only one of them is Laravel. Each is
318
- self-contained, so copy one, fix the paths, and run it:
319
-
320
- ```bash
321
- cp -r examples/node-api-and-web ~/.laracrew/stacks/
322
- laracrew up node-api-and-web
323
- ```
324
-
325
- | Example | What it is |
326
- |---|---|
327
- | [laravel-dual](examples/laravel-dual/stack.yaml) | Two interconnected Laravel apps: queues, stream listeners, schedulers, Vite |
328
- | [node-api-and-web](examples/node-api-and-web/stack.yaml) | TypeScript API, Vite frontend, BullMQ worker, Postgres and Redis |
329
- | [django-celery](examples/django-celery/stack.yaml) | Django, a Celery worker, beat, and optional extras |
330
- | [polyglot-microservices](examples/polyglot-microservices/stack.yaml) | Go, Rust, Node and Python behind a gateway, with Docker Compose for infrastructure |
331
- | [frontend-monorepo](examples/frontend-monorepo/stack.yaml) | tsc, Tailwind, Storybook and docs watchers in one repo — no servers at all |
332
-
333
- [`examples/README.md`](examples/README.md) explains what each one is there to teach, plus the
334
- patterns worth stealing: gating on reality instead of sleeping, keeping occasional commands in
335
- the stack but idle, and letting laracrew own `docker compose` too.
336
-
337
- ---
338
-
339
- ## Concepts
340
-
341
- | Concept | What it is |
342
- |---|---|
343
- | **Project** | One Laravel app root: path, PHP binary, `.env`. Defined once in `projects.yaml`, referenced by key. |
344
- | **Service** | One long-running process: command, cwd, dependencies, readiness gate, restart policy. |
345
- | **Stack** | A named set of services spanning one or more projects — the thing you `laracrew up`, and what a linked command boots. |
346
- | **Group** | A tag on a service (`workers`, `http`, `assets`) for `--only` / `--except`. |
347
- | **Profile** | A named filter stored in the stack (`light` = everything except assets). |
348
- | **Task** | A one-shot ordered sequence across projects (`reset` = migrate:fresh + seed on both). |
349
-
350
- Everything lives under `~/.laracrew/`, never inside your Laravel projects. laracrew only ever
351
- reads your project files.
352
-
353
- ```
354
- ~/.laracrew/
355
- ├── config.yaml # theme, default stack, poll intervals
356
- ├── projects.yaml # your projects, referenced by key
357
- ├── stacks/
358
- │ ├── dual/
359
- │ │ ├── stack.yaml # the definition
360
- │ │ └── services/ # optional: split a big stack into fragments
361
- │ └── example/stack.yaml
362
- ├── tasks/reset.yaml
363
- └── fragments/
364
- ```
365
-
366
- One folder per stack, so a stack can carry its own fragments and notes. The whole directory is
367
- safe to keep in git and sync between machines.
368
-
369
- ---
370
-
371
- ## Configuring a stack
372
-
373
- A complete two-project setup:
374
-
375
- ```yaml
376
- name: dual
377
- description: API + Portal with queues, streams and schedulers
378
- command: dual # `laracrew link dual` installs this as a global command
379
-
380
- use: [api, portal] # from ~/.laracrew/projects.yaml
381
-
382
- defaults: # inherited by every service, overridable per service
383
- restart: on-failure
384
- backoff: { initialMs: 1000, maxMs: 30000, factor: 2, maxRestarts: 10 }
385
- stop: { graceMs: 10000 }
386
-
387
- services:
388
- - name: redis
389
- external: true # health-checked, never started by laracrew
390
- ready: { tcp: "127.0.0.1:6379" }
391
-
392
- - name: api:serve
393
- project: api
394
- cmd: php artisan serve --port=${port:8000}
395
- groups: [http]
396
- needs: [redis]
397
- ready: { http: "http://127.0.0.1:8000/up", timeoutMs: 20000 }
398
- url: http://127.0.0.1:8000
399
-
400
- - name: api:queue
401
- project: api
402
- cmd: php artisan queue:work redis --queue=high,default --tries=3
403
- groups: [workers]
404
- needs: [redis]
405
- stop: { artisan: "queue:restart", graceMs: 15000 } # finish the current job first
406
- metrics: { queues: [high, default] }
407
-
408
- - name: portal:serve
409
- project: portal
410
- cmd: php artisan serve --port=${port:8001}
411
- groups: [http]
412
- needs: [redis, api:serve] # waits for the API to actually answer
413
- ready: { http: "http://127.0.0.1:8001/up" }
414
-
415
- profiles:
416
- light: { except: [assets] }
417
- workers-only: { only: [workers] }
418
- ```
419
-
420
- ### Service fields
421
-
422
- | Field | Notes |
423
- |---|---|
424
- | `name` | Required. Convention is `project:role`; used as the log prefix. |
425
- | `project` | Supplies `cwd`, the PHP binary, the `.env` and the colour. |
426
- | `cmd` | A shell string, or an argv array (`["php", "artisan", "queue:work"]`). The array form skips shell parsing — prefer it when arguments contain spaces. |
427
- | `cwd` | Defaults to the project path. |
428
- | `env` | Extra environment variables, merged over the inherited environment. |
429
- | `groups` | Tags for `--only` / `--except`. |
430
- | `needs` | Dependency edges. Cycles are a config error that names the members. |
431
- | `ready` | `tcp`, `http`, `logMatch` (regex over output) or `delayMs`, plus `timeoutMs` (default 30000) and `intervalMs` (default 250). Without it, "spawned" means ready. |
432
- | `restart` | `never` · `on-failure` (default) · `always`. |
433
- | `backoff` | `initialMs`, `maxMs`, `factor`, `maxRestarts`, `resetAfterMs`. Delay is `min(initialMs × factor^n, maxMs)`; the counter resets after the service stays up for `resetAfterMs`. |
434
- | `stop` | `artisan` (a graceful command such as `queue:restart` or `horizon:terminate`), `signal`, `graceMs`. |
435
- | `url` | Recorded for the service; `laracrew open` is not built yet. |
436
- | `external` | Health-checked but never spawned — Redis, MySQL, a Docker service. |
437
- | `autostart` | `false` defines the service without launching it. It shows as **idle** in the tree; select it and press `s` when you need it. |
438
- | `enabled` | Quick off switch without deleting the block. |
439
- | `color` | Log-prefix colour; defaults to the project's. |
440
-
441
- ### Interpolation
442
-
443
- | Token | Expands to |
444
- |---|---|
445
- | `${env:FOO}` / `${env:FOO:fallback}` | laracrew's own environment |
446
- | `${project.path}` | the service's project root |
447
- | `${project.env:REDIS_PORT}` | a value from that project's `.env` |
448
- | `${stack.dir}` | the stack's own folder |
449
- | `${port:8000}` | a port, recorded so `doctor` can check it for clashes |
450
-
451
- ---
452
-
453
- ## Commands
454
-
455
- ```bash
456
- laracrew init [--examples] # create ~/.laracrew; --examples adds a demo stack + template
457
- laracrew ls [--json] # list stacks, projects and tasks
458
- laracrew doctor [stack] # check a stack before booting it
459
- laracrew up [stack] [options] # boot the fleet and supervise it
460
- laracrew logs [service] [--stack name] [-n 200] [-f] [--since 10m] [--all] [--list]
461
- laracrew link [stack] [--as name] [--all] [--dir path] [--force]
462
- laracrew unlink <name> # remove a command laracrew installed
463
- laracrew --version
464
- ```
465
-
466
- `laracrew up` options:
467
-
468
- | Option | Effect |
469
- |---|---|
470
- | `--only <selector>` | Only these services or groups. Repeatable, comma-separated. |
471
- | `--except <selector>` | Skip these services or groups. |
472
- | `--profile <name>` | Apply a profile defined in the stack. |
473
- | `--json` | Newline-delimited JSON events instead of logs — one object per line. |
474
- | `--plain` | Prefixed interleaved logs instead of the full-screen view. Automatic when stdout is not a TTY. |
475
-
476
- Omit the stack name and laracrew uses `defaultStack` from `config.yaml`, or the only stack that
477
- exists, or tells you which ones it found.
478
-
479
- ```bash
480
- laracrew up dual --only workers # just the queue workers and listeners
481
- laracrew up dual --except assets # skip Vite
482
- laracrew up dual --profile light
483
- laracrew up dual --json | jq 'select(.type=="service:exit")'
484
- ```
485
-
486
- ### What `doctor` checks
487
-
488
- ```
489
- ✔ stack "dual" is valid — 8 services
490
- ✔ php: PHP 8.3.11 (cli)
491
- ✖ api and portal share Redis 127.0.0.1:6379/0# AND queue(s): default
492
- each project's workers will steal the other's jobs — set a different REDIS_DB or REDIS_PREFIX
493
- ✖ project "api" runs a queue worker but QUEUE_CONNECTION=sync
494
- jobs run inline on dispatch, so the worker will sit idle forever — set it to redis or database
495
- ✖ port 8000 is already in use
496
- ▲ redis not reachable at 127.0.0.1:6380
497
- ```
498
-
499
- Exit code is 1 when anything is at `✖`, so it drops straight into a pre-flight script.
500
-
501
- ---
502
-
503
- ## How shutdown works
504
-
505
- This is where most process managers leave a mess, so it's worth stating exactly. Each step runs
506
- only if the previous one timed out:
507
-
508
- 1. **Laravel graceful** — if the service declares `stop.artisan`, run it (`php artisan
509
- queue:restart`, `horizon:terminate`) and wait up to `graceMs` for the worker to exit on its own,
510
- having finished the job it was holding.
511
- 2. **Signal** — `SIGTERM` to the process group. Skipped on Windows, which has no equivalent.
512
- 3. **Tree kill** — `taskkill /pid <pid> /T /F` on Windows, `kill(-pid)` on POSIX. This is what
513
- catches the child PHP server behind `artisan serve` and the Vite process behind `npm run dev`.
514
- 4. **Verify** — re-check the pid and warn loudly if anything survived.
515
-
516
- Stacks come down in reverse dependency order, parallel within a level. A second Ctrl-C escalates
517
- immediately and says so.
518
-
519
- If a readiness gate fails during boot, laracrew rolls back everything it already started before
520
- exiting non-zero — you never get a half-booted stack you have to clean up by hand.
521
-
522
- ---
523
-
524
- ## Environment
525
-
526
- | Variable | Effect |
527
- |---|---|
528
- | `LARACREW_HOME` | Override `~/.laracrew`. |
529
- | `LARACREW_BIN` | Where `laracrew link` installs global commands. |
530
- | `LARACREW_ASCII` | `1` swaps box-drawing glyphs for ASCII. |
531
- | `NO_COLOR` | Disable colour, even on a TTY. |
532
- | `FORCE_COLOR` | Enable colour when piping. |
533
-
534
- Each child process is given `LARACREW=1`, `LARACREW_SERVICE=<name>` and, when it belongs to a
535
- project, `LARACREW_PROJECT=<key>`.
536
-
537
- ---
538
-
539
- ## Status
540
-
541
- **v0.1.0 — supervisor and full-screen view are built and tested.**
542
-
543
- Working now: config pipeline, dependency-ordered boot with readiness gates, restart policies with
544
- exponential backoff, the Laravel-aware stop ladder, the full-screen process tree with per-process
545
- log inspection, logs persisted to disk with `laracrew logs` to read them back, plain and JSON
546
- renderers, per-stack global commands (`link` / `unlink`), `doctor`, `init`, `ls`.
547
-
548
- Accepted by the config schema but **not yet acted on** — they validate, so your stack files are
549
- future-proof, but nothing happens yet:
550
-
551
- | Key | Lands in |
552
- |---|---|
553
- | `watch` | M4 — file-change restarts via `queue:restart` |
554
- | `metrics` | M3 — live queue depth and stream lag (today `doctor` reads it for collision checks) |
555
- | `hooks.preUp` / `hooks.postDown` | not scheduled |
556
-
557
- ### Roadmap
558
-
559
- | | |
560
- |---|---|
561
- | **M3** | `laracrew scan` project discovery, Redis queue depth and stream consumer lag on screen, failed-job badge |
562
- | **M4** | File watching with graceful `queue:restart`, and `laracrew run <task>` |
563
- | **M5** | Background daemon: `up --detach`, `attach`, `status`, `logs -f` |
564
- | **M6** | Themes, JSON Schema for editor autocomplete, shell completions |
565
-
566
- The full plan lives in [.claude/PLAN.md](.claude/PLAN.md), with the design in
567
- [ARCHITECTURE.md](.claude/ARCHITECTURE.md), [CONFIG-SPEC.md](.claude/CONFIG-SPEC.md) and
568
- [TUI-UX.md](.claude/TUI-UX.md).
569
-
570
- ---
571
-
572
- ## Development
573
-
574
- ```bash
575
- npm install
576
- npm run dev -- up example # tsx, no build step
577
- npm run build # tsup -> dist/index.js
578
- npm test # vitest, 226 tests
579
- npm run typecheck
580
- npm link # put `laracrew` on PATH while hacking on it
581
- ```
582
-
583
- Runtime dependencies, in total: `commander`, `yaml`, `zod`. Process spawning, tree-killing and
584
- colour are hand-rolled — see [ARCHITECTURE.md §9](.claude/ARCHITECTURE.md) for why `execa`,
585
- `tree-kill` and `picocolors` were dropped. Startup time is a feature for a tool you run twenty
586
- times a day.
587
-
588
- The test suite spawns real child processes, binds real ports and asserts that no pid survives a
589
- shutdown — including a deliberately spawned grandchild and a process that ignores `SIGTERM`. Every
590
- test runs against a throwaway `LARACREW_HOME`.
591
-
592
- Architectural rule worth knowing before you contribute: **nothing in `src/core/` may import from
593
- `src/cli/`**. Core emits typed events; the plain renderer, the JSON renderer and the coming TUI are
594
- all just subscribers. That's what keeps `--plain`, `--detach` and the tests honest.
595
-
596
- ## License
597
-
598
- MIT
1
+ <div align="center">
2
+
3
+ # laracrew
4
+
5
+ **Boot every long-running process of every Laravel project you're working on — with one command.**
6
+
7
+ `php artisan serve` · `queue:work` · `horizon` · Redis stream listeners · `schedule:work` · `npm run dev`
8
+ — across two, three or ten projects, in the right order, supervised, in one terminal.
9
+
10
+ [github.com/vidux/laracrew](https://github.com/vidux/laracrew)
11
+
12
+ </div>
13
+
14
+ ---
15
+
16
+ ## The problem
17
+
18
+ You develop two Laravel apps that talk to each other. Starting work means opening eight to
19
+ fourteen terminal tabs and typing, in the right order:
20
+
21
+ ```bash
22
+ cd D:/work/api && php artisan serve
23
+ cd D:/work/api && php artisan queue:work redis --queue=high,default
24
+ cd D:/work/api && php artisan streams:listen orders
25
+ cd D:/work/api && php artisan schedule:work
26
+ cd D:/work/api && npm run dev
27
+ cd D:/work/portal && php artisan serve --port=8001
28
+ cd D:/work/portal && php artisan queue:work redis --queue=default
29
+ cd D:/work/portal && php artisan streams:listen inventory
30
+ ```
31
+
32
+ Every one is long-running. When a worker dies you don't notice. When you edit a job class you
33
+ have to remember PHP workers cache code. When you close the terminal, half of them survive as
34
+ orphans still holding port 8000.
35
+
36
+ ## The fix
37
+
38
+ ```bash
39
+ laracrew up dual
40
+ ```
41
+
42
+ ```
43
+ laracrew · dual up 00:15:27 · 6/7 running all healthy
44
+ ────────────────────────────────────────────────────────────────────────────────────────────
45
+ up/down select enter inspect log 0-9 jump r restart s stop/start a all logs
46
+ ? help q quit
47
+ STACK DETAILS (process tree)
48
+
49
+ DEPENDENT SERVICES (checked, not managed)
50
+ ● redis ready tcp 127.0.0.1:6379
51
+
52
+ processes (7 managed)
53
+ │
54
+ ├─ ▸ [0] ● api:serve running 15m 04s
55
+ ├─ [1] ● api:queue running 15m 04s ⟳2
56
+ ├─ [2] ● api:streams running 15m 04s
57
+ ├─ [3] ● api:schedule running 15m 04s
58
+ ├─ [4] ● portal:serve running 14m 58s
59
+ ├─ [5] ◼ portal:queue stopped
60
+ └─ [6] ● portal:streams running 14m 58s
61
+ │
62
+ └─ Ready in 1.9s. Awaiting keyboard input…
63
+ ```
64
+
65
+ Services marked `external: true` — Redis, Postgres, anything laracrew checks but never runs —
66
+ sit above the tree under their own heading, with the probe they are checked with. They carry no
67
+ index, because there is nothing to start or stop; if one is unreachable the header says
68
+ `1 dependency down` before anything else.
69
+
70
+ **The default screen never streams logs.** Nine services interleaving output is unreadable — you
71
+ can't see the shape of the fleet and you can't follow any one process. So you get the tree, and
72
+ you open logs deliberately: press `2`, read `api:queue`, press `esc`.
73
+
74
+ ```
75
+ api:queue running · pid 14184 · up 15m 04s
76
+ $ php artisan queue:work redis --queue=high,default
77
+ D:/work/api
78
+ ────────────────────────────────────────────────────────────────────────────────────────────
79
+ 08:51:30 | Processing: App\Jobs\SyncOrder
80
+ 08:51:30 | Processed: App\Jobs\SyncOrder (412ms)
81
+ 08:51:31 | Processing: App\Jobs\NotifyPortal
82
+ ```
83
+
84
+ One Ctrl-C stops all of it, in reverse dependency order, gracefully — workers finish the job
85
+ they're holding before they exit, and nothing is left behind.
86
+
87
+ ---
88
+
89
+ ## Why not `concurrently`, `pm2` or `docker compose`
90
+
91
+ Those run processes. laracrew knows what the processes *are*.
92
+
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.
96
+ - **It kills whole process trees.** `php artisan serve` spawns a child PHP server; `npm run dev`
97
+ spawns Vite. Killing the parent orphans them and the port stays bound. Every stop is a tree kill
98
+ (`taskkill /T /F` on Windows, process-group kill on POSIX).
99
+ - **It gates on readiness, not on sleep.** `portal:serve` doesn't start until Redis answers *and*
100
+ `api:serve` returns HTTP 200.
101
+ - **It knows your `.env`.** `laracrew doctor` catches two projects quietly sharing one Redis
102
+ database *and* a queue name — where each app's workers silently steal the other's jobs. That
103
+ class of bug eats afternoons.
104
+
105
+ ---
106
+
107
+ ## Install
108
+
109
+ ```bash
110
+ npm install -g laracrew
111
+ laracrew init --examples
112
+ ```
113
+
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
+ ```
120
+
121
+ Requires **Node 20+**. Works on Windows, macOS and Linux. PHP is only needed for the projects
122
+ laracrew runs, not for laracrew itself — and only if those projects are PHP.
123
+
124
+ ## Quick start
125
+
126
+ ```bash
127
+ laracrew init --examples # creates ~/.laracrew with a demo stack and a two-project template
128
+ laracrew up example # runs the demo — no Laravel project needed, proves it works here
129
+ laracrew ls # what's defined
130
+ ```
131
+
132
+ `laracrew init` on its own creates just the config files. Add `--examples` when you want the
133
+ runnable demo stack and the ready-made two-project template to start from.
134
+
135
+ Then point it at real projects. Edit `~/.laracrew/projects.yaml`:
136
+
137
+ ```yaml
138
+ projects:
139
+ api:
140
+ path: D:/work/api
141
+ php: php # or an absolute path to a specific PHP build
142
+ envFile: .env
143
+ color: cyan
144
+ portal:
145
+ path: D:/work/portal
146
+ php: php
147
+ color: magenta
148
+ ```
149
+
150
+ `laracrew init` already wrote you a `dual` stack wired for exactly this scenario. Check it, then
151
+ boot it:
152
+
153
+ ```bash
154
+ laracrew doctor dual
155
+ laracrew up dual
156
+ ```
157
+
158
+ ---
159
+
160
+ ## One command per project set
161
+
162
+ Typing `laracrew up dual` every morning gets old, and you have more than one project set. Give
163
+ each set its own global command:
164
+
165
+ ```bash
166
+ laracrew link dual
167
+ ```
168
+
169
+ ```
170
+ created dual -> laracrew up dual
171
+
172
+ in C:\Users\you\AppData\Roaming\npm
173
+
174
+ Run it from anywhere: dual
175
+ Flags pass straight through: dual --only workers
176
+ ```
177
+
178
+ From then on, one word boots that whole set — from any directory:
179
+
180
+ ```bash
181
+ dual # boots all 8 services of the dual stack
182
+ dual --only workers # every `laracrew up` flag still works
183
+ ```
184
+
185
+ A stack names its own command in `stack.yaml`:
186
+
187
+ ```yaml
188
+ name: dual
189
+ command: dual # what `laracrew link` installs; defaults to the stack name
190
+ ```
191
+
192
+ So you end up with one command per set — `dual`, `billing`, `legacy` — each booting its own
193
+ fleet of projects.
194
+
195
+ | | |
196
+ |---|---|
197
+ | `laracrew link <stack>` | Install the command. Re-run any time to update it. |
198
+ | `laracrew link <stack> --as <name>` | Use a different name than the stack declares. |
199
+ | `laracrew link --all` | Install for every stack that declares `command:`. |
200
+ | `laracrew link <stack> --dir <path>` | Install somewhere other than the default. |
201
+ | `laracrew unlink <name>` | Remove it again. |
202
+ | `laracrew ls` | Shows which stacks have a command installed. |
203
+
204
+ **Where they go.** Into the same directory as `laracrew` itself — the npm global bin, which is
205
+ already on your PATH. On Windows you get three files (`dual`, `dual.cmd`, `dual.ps1`) so the
206
+ command behaves identically in Git Bash, cmd and PowerShell. Override with `--dir` or the
207
+ `LARACREW_BIN` environment variable. If laracrew can't find a directory that's on your PATH, it
208
+ falls back to `~/.laracrew/bin` and prints the one line you need to add it.
209
+
210
+ **It won't stomp on anything.** Every generated file carries a `laracrew-generated` marker.
211
+ laracrew refuses to overwrite a file it didn't write, refuses to shadow names like `npm` or
212
+ `git`, and `unlink` leaves foreign files alone. Pass `--force` if you genuinely mean it.
213
+
214
+ ---
215
+
216
+ ## Logs survive
217
+
218
+ The live view keeps the last few thousand lines per service in memory — minutes, on a busy
219
+ stack. Everything is also written to disk, so the exception you watched scroll past is still
220
+ there tomorrow.
221
+
222
+ ```bash
223
+ laracrew logs api:queue # last 200 lines, after the fact
224
+ laracrew logs api:queue -f # and keep following
225
+ laracrew logs --all --since 10m # every service, merged and time-ordered
226
+ laracrew logs --list # which services have a log
227
+ ```
228
+
229
+ Files land in `~/.laracrew/logs/<stack>/<service>.log`, rotated at 5 MB with one older copy
230
+ kept. They are plain text with a sortable local timestamp and no colour codes, so your usual
231
+ tools work on them directly:
232
+
233
+ ```
234
+ 2026-09-19 11:35:25.565 stderr PaymentFailedException: card declined
235
+ ```
236
+
237
+ ```bash
238
+ grep -i exception ~/.laracrew/logs/dual/api-queue.log
239
+ ```
240
+
241
+ This is on by default. Turn it off per stack if you would rather not:
242
+
243
+ ```yaml
244
+ defaults:
245
+ logs: { toFile: false }
246
+ ```
247
+
248
+ | Setting | Default | Meaning |
249
+ |---|---|---|
250
+ | `logs.toFile` | `true` | Write every line to disk |
251
+ | `logs.maxLines` | `5000` | Lines kept in memory for the live view |
252
+ | `logs.maxFileBytes` | `5000000` | Rotate a service's log past this size |
253
+ | `logs.keepFiles` | `1` | Rotated copies kept beside the current file |
254
+
255
+ ---
256
+
257
+ ## Starting things on demand
258
+
259
+ Not every command should run all day. A scheduler tick, a one-off sync listener, a queue you
260
+ only drain occasionally — define them in the stack, but don't launch them:
261
+
262
+ ```yaml
263
+ - name: api:queue
264
+ project: api
265
+ cmd: ["php", "artisan", "queue:work"] # starts with the stack
266
+
267
+ - name: api:streams
268
+ project: api
269
+ cmd: ["php", "artisan", "redis-stream:run", "orders_sync"]
270
+ autostart: false # defined, listed, not launched
271
+
272
+ - name: api:schedule
273
+ project: api
274
+ cmd: ["php", "artisan", "schedule:run"]
275
+ autostart: false
276
+ restart: never # one tick, not a daemon
277
+ ```
278
+
279
+ They appear in the tree as **idle**, waiting for you:
280
+
281
+ ```
282
+ ├─ [1] ● api:serve running 8s
283
+ ├─ [2] ● api:queue running 8s
284
+ ├─ [3] ○ api:streams idle press s
285
+ ├─ [4] ○ api:schedule idle press s
286
+ ├─ [5] ● portal:queue running 8s
287
+ ```
288
+
289
+ Select one and press `s` to start it; `s` again to stop it. The header counts them
290
+ (`4/7 running · 2 idle`) so you can see at a glance what is dormant.
291
+
292
+ One rule the config enforces: a service that starts at launch may not `needs:` a service you
293
+ have to start by hand — that would leave it waiting on a gate nobody opened. laracrew refuses
294
+ the stack with a message naming both services rather than hanging.
295
+
296
+ ---
297
+
298
+ ## Driving it
299
+
300
+ | Key | Does |
301
+ |---|---|
302
+ | `up` `down` / `j` `k` | Move the selection |
303
+ | `0`-`9` | Jump to that process **and** open its log |
304
+ | `enter` | Inspect the selected process |
305
+ | `esc` | Back to the tree |
306
+ | `a` | Merged log across every service - the firehose, on demand |
307
+ | `r` | Restart the selected process (graceful: `queue:restart` first) |
308
+ | `s` | Stop it, or start it again if stopped |
309
+ | `f` / `g` / `G` | Follow-pause tailing; jump to top or bottom |
310
+ | `?` | Help |
311
+ | `q` / `ctrl-c` | Quit - stops every process first |
312
+
313
+ The view is plain ANSI on `node:readline`, no Ink and no React. It repaints on a 250 ms poll
314
+ rather than per log line, so a worker emitting 500 lines a second costs nothing to display.
315
+ `LARACREW_ASCII=1` swaps in an ASCII glyph set for terminals that mangle box drawing.
316
+
317
+ When stdout is not a TTY you get prefixed interleaved logs automatically, so
318
+ `laracrew up dual | tee dev.log` does the right thing with no flag.
319
+
320
+ ---
321
+
322
+ ## Examples
323
+
324
+ Five working stacks in [`examples/`](examples/) — only one of them is Laravel. Each is
325
+ self-contained, so copy one, fix the paths, and run it:
326
+
327
+ ```bash
328
+ cp -r examples/node-api-and-web ~/.laracrew/stacks/
329
+ laracrew up node-api-and-web
330
+ ```
331
+
332
+ | Example | What it is |
333
+ |---|---|
334
+ | [laravel-dual](examples/laravel-dual/stack.yaml) | Two interconnected Laravel apps: queues, stream listeners, schedulers, Vite |
335
+ | [node-api-and-web](examples/node-api-and-web/stack.yaml) | TypeScript API, Vite frontend, BullMQ worker, Postgres and Redis |
336
+ | [django-celery](examples/django-celery/stack.yaml) | Django, a Celery worker, beat, and optional extras |
337
+ | [polyglot-microservices](examples/polyglot-microservices/stack.yaml) | Go, Rust, Node and Python behind a gateway, with Docker Compose for infrastructure |
338
+ | [frontend-monorepo](examples/frontend-monorepo/stack.yaml) | tsc, Tailwind, Storybook and docs watchers in one repo — no servers at all |
339
+
340
+ [`examples/README.md`](examples/README.md) explains what each one is there to teach, plus the
341
+ patterns worth stealing: gating on reality instead of sleeping, keeping occasional commands in
342
+ the stack but idle, and letting laracrew own `docker compose` too.
343
+
344
+ ---
345
+
346
+ ## Concepts
347
+
348
+ | Concept | What it is |
349
+ |---|---|
350
+ | **Project** | One Laravel app root: path, PHP binary, `.env`. Defined once in `projects.yaml`, referenced by key. |
351
+ | **Service** | One long-running process: command, cwd, dependencies, readiness gate, restart policy. |
352
+ | **Stack** | A named set of services spanning one or more projects — the thing you `laracrew up`, and what a linked command boots. |
353
+ | **Group** | A tag on a service (`workers`, `http`, `assets`) for `--only` / `--except`. |
354
+ | **Profile** | A named filter stored in the stack (`light` = everything except assets). |
355
+ | **Task** | A one-shot ordered sequence across projects (`reset` = migrate:fresh + seed on both). |
356
+
357
+ Everything lives under `~/.laracrew/`, never inside your Laravel projects. laracrew only ever
358
+ reads your project files.
359
+
360
+ ```
361
+ ~/.laracrew/
362
+ ├── config.yaml # theme, default stack, poll intervals
363
+ ├── projects.yaml # your projects, referenced by key
364
+ ├── stacks/
365
+ │ ├── dual/
366
+ │ │ ├── stack.yaml # the definition
367
+ │ │ └── services/ # optional: split a big stack into fragments
368
+ │ └── example/stack.yaml
369
+ ├── tasks/reset.yaml
370
+ └── fragments/
371
+ ```
372
+
373
+ One folder per stack, so a stack can carry its own fragments and notes. The whole directory is
374
+ safe to keep in git and sync between machines.
375
+
376
+ ---
377
+
378
+ ## Configuring a stack
379
+
380
+ A complete two-project setup:
381
+
382
+ ```yaml
383
+ name: dual
384
+ description: API + Portal with queues, streams and schedulers
385
+ command: dual # `laracrew link dual` installs this as a global command
386
+
387
+ use: [api, portal] # from ~/.laracrew/projects.yaml
388
+
389
+ defaults: # inherited by every service, overridable per service
390
+ restart: on-failure
391
+ backoff: { initialMs: 1000, maxMs: 30000, factor: 2, maxRestarts: 10 }
392
+ stop: { graceMs: 10000 }
393
+
394
+ services:
395
+ - name: redis
396
+ external: true # health-checked, never started by laracrew
397
+ ready: { tcp: "127.0.0.1:6379" }
398
+
399
+ - name: api:serve
400
+ project: api
401
+ cmd: php artisan serve --port=${port:8000}
402
+ groups: [http]
403
+ needs: [redis]
404
+ ready: { http: "http://127.0.0.1:8000/up", timeoutMs: 20000 }
405
+ url: http://127.0.0.1:8000
406
+
407
+ - name: api:queue
408
+ project: api
409
+ cmd: php artisan queue:work redis --queue=high,default --tries=3
410
+ groups: [workers]
411
+ needs: [redis]
412
+ stop: { artisan: "queue:restart", graceMs: 15000 } # finish the current job first
413
+ metrics: { queues: [high, default] }
414
+
415
+ - name: portal:serve
416
+ project: portal
417
+ cmd: php artisan serve --port=${port:8001}
418
+ groups: [http]
419
+ needs: [redis, api:serve] # waits for the API to actually answer
420
+ ready: { http: "http://127.0.0.1:8001/up" }
421
+
422
+ profiles:
423
+ light: { except: [assets] }
424
+ workers-only: { only: [workers] }
425
+ ```
426
+
427
+ ### Service fields
428
+
429
+ | Field | Notes |
430
+ |---|---|
431
+ | `name` | Required. Convention is `project:role`; used as the log prefix. |
432
+ | `project` | Supplies `cwd`, the PHP binary, the `.env` and the colour. |
433
+ | `cmd` | A shell string, or an argv array (`["php", "artisan", "queue:work"]`). The array form skips shell parsing — prefer it when arguments contain spaces. |
434
+ | `cwd` | Defaults to the project path. |
435
+ | `env` | Extra environment variables, merged over the inherited environment. |
436
+ | `groups` | Tags for `--only` / `--except`. |
437
+ | `needs` | Dependency edges. Cycles are a config error that names the members. |
438
+ | `ready` | `tcp`, `http`, `logMatch` (regex over output) or `delayMs`, plus `timeoutMs` (default 30000) and `intervalMs` (default 250). Without it, "spawned" means ready. |
439
+ | `restart` | `never` · `on-failure` (default) · `always`. |
440
+ | `backoff` | `initialMs`, `maxMs`, `factor`, `maxRestarts`, `resetAfterMs`. Delay is `min(initialMs × factor^n, maxMs)`; the counter resets after the service stays up for `resetAfterMs`. |
441
+ | `stop` | `exec` (any graceful shutdown command), `artisan` (sugar for one that runs artisan, such as `queue:restart` or `horizon:terminate`), `signal`, `graceMs`. |
442
+ | `url` | Recorded for the service; `laracrew open` is not built yet. |
443
+ | `external` | Health-checked but never spawned — Redis, MySQL, a Docker service. |
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. |
445
+ | `enabled` | Quick off switch without deleting the block. |
446
+ | `color` | Log-prefix colour; defaults to the project's. |
447
+
448
+ ### Interpolation
449
+
450
+ | Token | Expands to |
451
+ |---|---|
452
+ | `${env:FOO}` / `${env:FOO:fallback}` | laracrew's own environment |
453
+ | `${project.path}` | the service's project root |
454
+ | `${project.env:REDIS_PORT}` | a value from that project's `.env` |
455
+ | `${stack.dir}` | the stack's own folder |
456
+ | `${port:8000}` | a port, recorded so `doctor` can check it for clashes |
457
+
458
+ ---
459
+
460
+ ## Commands
461
+
462
+ ```bash
463
+ laracrew init [--examples] # create ~/.laracrew; --examples adds a demo stack + template
464
+ laracrew ls [--json] # list stacks, projects and tasks
465
+ laracrew doctor [stack] # check a stack before booting it
466
+ laracrew up [stack] [options] # boot the fleet and supervise it
467
+ laracrew logs [service] [--stack name] [-n 200] [-f] [--since 10m] [--all] [--list]
468
+ laracrew link [stack] [--as name] [--all] [--dir path] [--force]
469
+ laracrew unlink <name> # remove a command laracrew installed
470
+ laracrew --version
471
+ ```
472
+
473
+ `laracrew up` options:
474
+
475
+ | Option | Effect |
476
+ |---|---|
477
+ | `--only <selector>` | Only these services or groups. Repeatable, comma-separated. |
478
+ | `--except <selector>` | Skip these services or groups. |
479
+ | `--profile <name>` | Apply a profile defined in the stack. |
480
+ | `--json` | Newline-delimited JSON events instead of logs — one object per line. |
481
+ | `--plain` | Prefixed interleaved logs instead of the full-screen view. Automatic when stdout is not a TTY. |
482
+
483
+ Omit the stack name and laracrew uses `defaultStack` from `config.yaml`, or the only stack that
484
+ exists, or tells you which ones it found.
485
+
486
+ ```bash
487
+ laracrew up dual --only workers # just the queue workers and listeners
488
+ laracrew up dual --except assets # skip Vite
489
+ laracrew up dual --profile light
490
+ laracrew up dual --json | jq 'select(.type=="service:exit")'
491
+ ```
492
+
493
+ ### What `doctor` checks
494
+
495
+ ```
496
+ ✔ stack "dual" is valid — 8 services
497
+ ✔ php: PHP 8.3.11 (cli)
498
+ ✖ api and portal share Redis 127.0.0.1:6379/0# AND queue(s): default
499
+ each project's workers will steal the other's jobs — set a different REDIS_DB or REDIS_PREFIX
500
+ ✖ project "api" runs a queue worker but QUEUE_CONNECTION=sync
501
+ jobs run inline on dispatch, so the worker will sit idle forever — set it to redis or database
502
+ ✖ port 8000 is already in use
503
+ ▲ redis not reachable at 127.0.0.1:6380
504
+ ```
505
+
506
+ Exit code is 1 when anything is at `✖`, so it drops straight into a pre-flight script.
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
+
526
+ ---
527
+
528
+ ## How shutdown works
529
+
530
+ This is where most process managers leave a mess, so it's worth stating exactly. Each step runs
531
+ only if the previous one timed out:
532
+
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.
535
+ 2. **Signal** — `SIGTERM` to the process group. Skipped on Windows, which has no equivalent.
536
+ 3. **Tree kill** — `taskkill /pid <pid> /T /F` on Windows, `kill(-pid)` on POSIX. This is what
537
+ catches the child PHP server behind `artisan serve` and the Vite process behind `npm run dev`.
538
+ 4. **Verify** — re-check the pid and warn loudly if anything survived.
539
+
540
+ Stacks come down in reverse dependency order, parallel within a level. A second Ctrl-C escalates
541
+ immediately and says so.
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
+
556
+ If a readiness gate fails during boot, laracrew rolls back everything it already started before
557
+ exiting non-zero — you never get a half-booted stack you have to clean up by hand.
558
+
559
+ ---
560
+
561
+ ## Environment
562
+
563
+ | Variable | Effect |
564
+ |---|---|
565
+ | `LARACREW_HOME` | Override `~/.laracrew`. |
566
+ | `LARACREW_BIN` | Where `laracrew link` installs global commands. |
567
+ | `LARACREW_ASCII` | `1` swaps box-drawing glyphs for ASCII. |
568
+ | `NO_COLOR` | Disable colour, even on a TTY. |
569
+ | `FORCE_COLOR` | Enable colour when piping. |
570
+
571
+ Each child process is given `LARACREW=1`, `LARACREW_SERVICE=<name>` and, when it belongs to a
572
+ project, `LARACREW_PROJECT=<key>`.
573
+
574
+ ---
575
+
576
+ ## Status
577
+
578
+ **v0.2.0 — supervisor and full-screen view are built and tested.**
579
+
580
+ Working now: config pipeline, dependency-ordered boot with readiness gates, restart policies with
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.
587
+
588
+ Accepted by the config schema but **not yet acted on** — they validate, so your stack files are
589
+ future-proof, but nothing happens yet:
590
+
591
+ | Key | Lands in |
592
+ |---|---|
593
+ | `watch` | M4 — file-change restarts via `queue:restart` |
594
+ | `metrics` | M3 — live queue depth and stream lag (today `doctor` reads it for collision checks) |
595
+ | `hooks.preUp` / `hooks.postDown` | not scheduled |
596
+
597
+ ### Roadmap
598
+
599
+ | | |
600
+ |---|---|
601
+ | **M3** | `laracrew scan` project discovery, Redis queue depth and stream consumer lag on screen, failed-job badge |
602
+ | **M4** | File watching with graceful `queue:restart`, and `laracrew run <task>` |
603
+ | **M5** | Background daemon: `up --detach`, `attach`, `status`, `logs -f` |
604
+ | **M6** | Themes, JSON Schema for editor autocomplete, shell completions |
605
+
606
+ The full plan lives in [.claude/PLAN.md](.claude/PLAN.md), with the design in
607
+ [ARCHITECTURE.md](.claude/ARCHITECTURE.md), [CONFIG-SPEC.md](.claude/CONFIG-SPEC.md) and
608
+ [TUI-UX.md](.claude/TUI-UX.md).
609
+
610
+ ---
611
+
612
+ ## Development
613
+
614
+ ```bash
615
+ npm install
616
+ npm run dev -- up example # tsx, no build step
617
+ npm run build # tsup -> dist/index.js
618
+ npm test # vitest, 244 tests
619
+ npm run typecheck
620
+ npm link # put `laracrew` on PATH while hacking on it
621
+ ```
622
+
623
+ Runtime dependencies, in total: `commander`, `yaml`, `zod`. Process spawning, tree-killing and
624
+ colour are hand-rolled — see [ARCHITECTURE.md §9](.claude/ARCHITECTURE.md) for why `execa`,
625
+ `tree-kill` and `picocolors` were dropped. Startup time is a feature for a tool you run twenty
626
+ times a day.
627
+
628
+ The test suite spawns real child processes, binds real ports and asserts that no pid survives a
629
+ shutdown — including a deliberately spawned grandchild and a process that ignores `SIGTERM`. Every
630
+ test runs against a throwaway `LARACREW_HOME`.
631
+
632
+ Architectural rule worth knowing before you contribute: **nothing in `src/core/` may import from
633
+ `src/cli/`**. Core emits typed events; the plain renderer, the JSON renderer and the coming TUI are
634
+ all just subscribers. That's what keeps `--plain`, `--detach` and the tests honest.
635
+
636
+ ## License
637
+
638
+ MIT