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/CHANGELOG.md +81 -2
- package/README.md +638 -598
- 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 +63 -55
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
|
});
|