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/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli/program.ts
4
+ import { readFileSync as readFileSync4 } from "fs";
5
+ import path11 from "path";
6
+ import { fileURLToPath as fileURLToPath2 } from "url";
4
7
  import { Command } from "commander";
5
8
 
6
9
  // src/core/config/errors.ts
@@ -137,8 +140,15 @@ var backoffSchema = z.object({
137
140
  /** Stay up this long and the attempt counter resets, so a slow leak can't exhaust it. */
138
141
  resetAfterMs: z.number().int().positive().default(6e4)
139
142
  }).strict();
143
+ var commandSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
140
144
  var stopPolicySchema = z.object({
141
- /** Laravel-native graceful shutdown, e.g. "queue:restart" or "horizon:terminate". */
145
+ /**
146
+ * Step 1 of the stop ladder: any command that asks the process to finish what it is
147
+ * holding and exit by itself — `celery control shutdown`, `docker compose stop`, a
148
+ * drain script. Runs in the service's cwd; laracrew waits `graceMs` for the exit.
149
+ */
150
+ exec: commandSchema.optional(),
151
+ /** Sugar for `exec` on a Laravel project: runs `<php> artisan <this>` from its root. */
142
152
  artisan: z.string().optional(),
143
153
  signal: z.string().default("SIGTERM"),
144
154
  graceMs: z.number().int().positive().default(1e4)
@@ -153,7 +163,6 @@ var metricsSchema = z.object({
153
163
  queues: z.array(z.string()).default([]),
154
164
  streams: z.array(z.object({ key: z.string(), group: z.string() }).strict()).default([])
155
165
  }).strict();
156
- var commandSchema = z.union([z.string().min(1), z.array(z.string().min(1)).min(1)]);
157
166
  var serviceSchema = z.object({
158
167
  name: z.string().min(1),
159
168
  project: z.string().optional(),
@@ -719,10 +728,35 @@ var mergeService = (raw, stack, projects, ports, processEnv, colorIndex) => {
719
728
  throw error;
720
729
  }
721
730
  const cwd = service.cwd ? path4.resolve(project?.path ?? stack.dir, service.cwd) : project?.path ?? stack.dir;
731
+ const inheritedStop = { ...defaults.stop };
732
+ const ownStop = service.stop ?? {};
733
+ if (ownStop.exec !== void 0) delete inheritedStop.artisan;
734
+ if (ownStop.artisan !== void 0) delete inheritedStop.exec;
735
+ const stop = stopPolicySchema.parse({ ...inheritedStop, ...ownStop });
736
+ if (stop.exec !== void 0 && stop.artisan !== void 0) {
737
+ throw new ConfigError(
738
+ `service "${service.name}" declares both \`stop.exec\` and \`stop.artisan\``,
739
+ { file: stack.file, details: ["keep one \u2014 `artisan` is only shorthand for an `exec` that runs artisan"] }
740
+ );
741
+ }
742
+ if (stop.artisan !== void 0 && !project) {
743
+ throw new ConfigError(
744
+ `service "${service.name}" declares \`stop.artisan\` but belongs to no project`,
745
+ {
746
+ file: stack.file,
747
+ details: ["`artisan` runs `<project php> artisan ...`, so it needs a project", "for any other command use `stop.exec`"]
748
+ }
749
+ );
750
+ }
751
+ const graceful = stop.exec ? { command: toCommand(stop.exec), cwd } : stop.artisan && project ? (
752
+ // artisan must run from the Laravel root even when the service sets its own cwd.
753
+ { command: { kind: "argv", file: project.php, args: ["artisan", ...stop.artisan.split(/\s+/)] }, cwd: project.path }
754
+ ) : void 0;
722
755
  return {
723
756
  name: service.name,
724
757
  ...project ? { project } : {},
725
758
  ...service.cmd ? { command: toCommand(service.cmd) } : {},
759
+ ...graceful ? { graceful } : {},
726
760
  cwd,
727
761
  env: { ...service.env },
728
762
  groups: service.groups,
@@ -730,7 +764,7 @@ var mergeService = (raw, stack, projects, ports, processEnv, colorIndex) => {
730
764
  ...service.ready ? { ready: service.ready } : {},
731
765
  restart: service.restart ?? defaults.restart,
732
766
  backoff: backoffSchema.parse({ ...defaults.backoff, ...service.backoff ?? {} }),
733
- stop: stopPolicySchema.parse({ ...defaults.stop, ...service.stop ?? {} }),
767
+ stop,
734
768
  ...service.watch ? { watch: service.watch } : {},
735
769
  ...service.metrics ? { metrics: service.metrics } : {},
736
770
  ...service.url ? { url: service.url } : {},
@@ -1385,15 +1419,17 @@ var ManagedProcess = class {
1385
1419
  }, delayMs);
1386
1420
  this.#backoffTimer.unref?.();
1387
1421
  }
1388
- /** The Laravel-native graceful shutdown, when the service declares one. */
1422
+ /**
1423
+ * Step 1 of the stop ladder: ask the process to finish what it is holding and exit by
1424
+ * itself. What that command is — `queue:restart`, `celery control shutdown`, a drain
1425
+ * script — was decided in `resolve.ts`; this only runs it.
1426
+ */
1389
1427
  #graceful() {
1390
- const artisan = this.service.stop.artisan;
1391
- const project = this.service.project;
1392
- if (!artisan || !project) return void 0;
1428
+ const graceful = this.service.graceful;
1429
+ if (!graceful) return void 0;
1393
1430
  return async () => {
1394
- const command = { kind: "argv", file: project.php, args: ["artisan", ...artisan.split(/\s+/)] };
1395
- this.#log(`stop: ${describeCommand(command)}`);
1396
- await runOnce(command, { cwd: project.path, env: this.#env, timeoutMs: 15e3 });
1431
+ this.#log(`stop: ${describeCommand(graceful.command)}`);
1432
+ await runOnce(graceful.command, { cwd: graceful.cwd, env: this.#env, timeoutMs: 15e3 });
1397
1433
  };
1398
1434
  }
1399
1435
  async stop() {
@@ -2496,36 +2532,88 @@ var waitUntilIdle = (supervisor, stopRequested) => new Promise((resolve) => {
2496
2532
 
2497
2533
  // src/cli/commands/doctor.ts
2498
2534
  var PORT_IN_COMMAND = /--port[= ](\d{2,5})/g;
2535
+ var commandLine = (service) => {
2536
+ if (!service.command) return "";
2537
+ return service.command.kind === "shell" ? service.command.line : [service.command.file, ...service.command.args].join(" ");
2538
+ };
2539
+ var PHP_BINARY = /(^|[\\/\s"'])php[\d.]*(\.exe)?(\s|$)/i;
2540
+ var phpProjects = (stack) => {
2541
+ const keys = /* @__PURE__ */ new Set();
2542
+ for (const service of stack.services) {
2543
+ const key = service.project?.key;
2544
+ if (!key) continue;
2545
+ const line = commandLine(service);
2546
+ const php = stack.projects[key]?.php;
2547
+ if (service.stop.artisan || PHP_BINARY.test(line) || php !== void 0 && line.startsWith(php)) {
2548
+ keys.add(key);
2549
+ }
2550
+ }
2551
+ return keys;
2552
+ };
2553
+ var REDIS_DRIVER_KEYS = [
2554
+ "QUEUE_CONNECTION",
2555
+ "CACHE_STORE",
2556
+ "CACHE_DRIVER",
2557
+ "SESSION_DRIVER",
2558
+ "BROADCAST_CONNECTION",
2559
+ "BROADCAST_DRIVER"
2560
+ ];
2561
+ var redisProjects = (stack) => {
2562
+ const keys = /* @__PURE__ */ new Set();
2563
+ for (const [key, project] of Object.entries(stack.projects)) {
2564
+ const declared = Object.keys(project.env).some((name) => name.startsWith("REDIS_"));
2565
+ const driver = REDIS_DRIVER_KEYS.some((name) => project.env[name]?.toLowerCase() === "redis");
2566
+ if (declared || driver) keys.add(key);
2567
+ }
2568
+ for (const service of stack.services) {
2569
+ const key = service.project?.key;
2570
+ if (!key) continue;
2571
+ if (service.metrics || /\b(redis|horizon)\b/i.test(commandLine(service))) keys.add(key);
2572
+ }
2573
+ return keys;
2574
+ };
2499
2575
  var declaredPorts = (stack) => {
2500
2576
  const ports = new Set(stack.ports);
2501
2577
  for (const service of stack.services) {
2502
- const line = service.command?.kind === "shell" ? service.command.line : service.command ? [service.command.file, ...service.command.args].join(" ") : "";
2503
- for (const match of line.matchAll(PORT_IN_COMMAND)) {
2578
+ for (const match of commandLine(service).matchAll(PORT_IN_COMMAND)) {
2504
2579
  const port = Number(match[1]);
2505
2580
  if (Number.isInteger(port)) ports.add(port);
2506
2581
  }
2507
2582
  }
2508
2583
  return [...ports].sort((a, b) => a - b);
2509
2584
  };
2510
- var redisTargetOf = (project) => `${project.env.REDIS_HOST ?? "127.0.0.1"}:${project.env.REDIS_PORT ?? "6379"}`;
2585
+ var parseRedisUrl = (url) => {
2586
+ try {
2587
+ const parsed = new URL(url);
2588
+ return parsed.hostname ? { host: parsed.hostname, port: parsed.port || "6379" } : void 0;
2589
+ } catch {
2590
+ return void 0;
2591
+ }
2592
+ };
2593
+ var redisTargetOf = (project) => {
2594
+ const url = project.env.REDIS_URL ? parseRedisUrl(project.env.REDIS_URL) : void 0;
2595
+ const host = project.env.REDIS_HOST ?? url?.host ?? "127.0.0.1";
2596
+ const port = project.env.REDIS_PORT ?? url?.port ?? "6379";
2597
+ return `${host}:${port}`;
2598
+ };
2511
2599
  var slug = (value) => value.toLowerCase().replace(/[^a-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
2512
2600
  var redisPrefixOf = (project) => project.env.REDIS_PREFIX ?? `${slug(project.env.APP_NAME ?? "laravel")}_database_`;
2513
2601
  var redisNamespaceOf = (project) => `${redisTargetOf(project)}/${project.env.REDIS_DB ?? "0"}#${redisPrefixOf(project)}`;
2514
- var checkProjects = (stack) => {
2602
+ var checkProjects = (stack, php, redis) => {
2515
2603
  const findings = [];
2516
2604
  for (const project of Object.values(stack.projects)) {
2517
2605
  if (!existsSync4(project.path)) {
2518
2606
  findings.push({ level: "error", message: `project "${project.key}": path does not exist \u2014 ${project.path}` });
2519
2607
  continue;
2520
2608
  }
2521
- if (!existsSync4(path7.join(project.path, "artisan"))) {
2609
+ if (php.has(project.key) && !existsSync4(path7.join(project.path, "artisan"))) {
2522
2610
  findings.push({
2523
2611
  level: "warn",
2524
2612
  message: `project "${project.key}": no \`artisan\` file in ${project.path}`,
2525
2613
  hint: "laracrew will still run the commands, but this does not look like a Laravel root"
2526
2614
  });
2527
2615
  }
2528
- if (Object.keys(project.env).length === 0) {
2616
+ if (Object.keys(project.env).length === 0 && (php.has(project.key) || redis.has(project.key))) {
2529
2617
  findings.push({
2530
2618
  level: "warn",
2531
2619
  message: `project "${project.key}": ${project.envFile} is missing or empty`,
@@ -2535,9 +2623,9 @@ var checkProjects = (stack) => {
2535
2623
  }
2536
2624
  return findings;
2537
2625
  };
2538
- var checkRedisCollisions = (stack) => {
2626
+ var checkRedisCollisions = (stack, redis) => {
2539
2627
  const findings = [];
2540
- const projects = Object.values(stack.projects);
2628
+ const projects = Object.values(stack.projects).filter((project) => redis.has(project.key));
2541
2629
  const byNamespace = /* @__PURE__ */ new Map();
2542
2630
  for (const project of projects) {
2543
2631
  const namespace = redisNamespaceOf(project);
@@ -2611,8 +2699,7 @@ var checkQueueConnections = (stack) => {
2611
2699
  const workerProjects = new Set(
2612
2700
  stack.services.filter((service) => {
2613
2701
  if (!service.autostart) return false;
2614
- const line = service.command?.kind === "shell" ? service.command.line : service.command ? [service.command.file, ...service.command.args].join(" ") : "";
2615
- return /\b(queue:work|queue:listen|horizon)\b/.test(line);
2702
+ return /\b(queue:work|queue:listen|horizon)\b/.test(commandLine(service));
2616
2703
  }).map((service) => service.project?.key).filter((key) => Boolean(key))
2617
2704
  );
2618
2705
  for (const key of workerProjects) {
@@ -2630,8 +2717,10 @@ var checkQueueConnections = (stack) => {
2630
2717
  }
2631
2718
  return findings;
2632
2719
  };
2633
- var checkPhp = async (stack) => {
2634
- const binaries = new Set(Object.values(stack.projects).map((project) => project.php));
2720
+ var checkPhp = async (stack, php) => {
2721
+ const binaries = new Set(
2722
+ Object.values(stack.projects).filter((project) => php.has(project.key)).map((project) => project.php)
2723
+ );
2635
2724
  const findings = [];
2636
2725
  for (const binary of binaries) {
2637
2726
  try {
@@ -2669,8 +2758,29 @@ var checkPorts = async (stack) => {
2669
2758
  }
2670
2759
  return findings;
2671
2760
  };
2672
- var checkRedisReachable = async (stack) => {
2673
- const targets = new Set(Object.values(stack.projects).map(redisTargetOf));
2761
+ var checkExternalServices = async (stack) => {
2762
+ const external = stack.services.filter((service) => service.external && (service.ready?.tcp ?? service.ready?.http));
2763
+ return Promise.all(
2764
+ external.map(async (service) => {
2765
+ const probe = service.ready;
2766
+ const label = describeProbe(probe);
2767
+ const result = probe.http ? await httpProbe(probe.http, 2e3) : await tcpProbe(probe.tcp, 2e3);
2768
+ return result.ok ? { level: "ok", message: `${service.name} is reachable \u2014 ${label}` } : {
2769
+ level: "warn",
2770
+ message: `${service.name} is not reachable \u2014 ${label}${result.detail ? ` (${result.detail})` : ""}`,
2771
+ hint: "laracrew never starts an external service; anything that needs it will wait at its gate"
2772
+ };
2773
+ })
2774
+ );
2775
+ };
2776
+ var externalTcpTargets = (stack) => new Set(
2777
+ stack.services.filter((service) => service.external && service.ready?.tcp).map((service) => service.ready.tcp)
2778
+ );
2779
+ var checkRedisReachable = async (stack, redis) => {
2780
+ const covered = externalTcpTargets(stack);
2781
+ const targets = new Set(
2782
+ Object.values(stack.projects).filter((project) => redis.has(project.key)).map(redisTargetOf).filter((target) => !covered.has(target))
2783
+ );
2674
2784
  const findings = [];
2675
2785
  for (const target of targets) {
2676
2786
  const result = await tcpProbe(target, 2e3);
@@ -2688,13 +2798,20 @@ var runDoctor = async (stackName, env = process.env) => {
2688
2798
  if (error instanceof ConfigError) return [{ level: "error", message: error.format() }];
2689
2799
  throw error;
2690
2800
  }
2801
+ const php = phpProjects(stack);
2802
+ const redis = redisProjects(stack);
2691
2803
  const findings = [
2692
2804
  { level: "ok", message: `stack "${stack.name}" is valid \u2014 ${stack.services.length} services` },
2693
- ...checkProjects(stack),
2805
+ ...checkProjects(stack, php, redis),
2694
2806
  ...checkQueueConnections(stack),
2695
- ...checkRedisCollisions(stack)
2807
+ ...checkRedisCollisions(stack, redis)
2696
2808
  ];
2697
- const async = await Promise.all([checkPhp(stack), checkPorts(stack), checkRedisReachable(stack)]);
2809
+ const async = await Promise.all([
2810
+ checkPhp(stack, php),
2811
+ checkPorts(stack),
2812
+ checkExternalServices(stack),
2813
+ checkRedisReachable(stack, redis)
2814
+ ]);
2698
2815
  return [...findings, ...async.flat()];
2699
2816
  };
2700
2817
  var doctorCommand = async (stackName, env = process.env) => {
@@ -2728,8 +2845,11 @@ metricsIntervalMs: 2000
2728
2845
  # The stack \`laracrew up\` boots when you don't name one.
2729
2846
  # defaultStack: dual
2730
2847
  `;
2731
- var PROJECTS_YAML = `# Your Laravel projects, referenced by key from any stack.
2848
+ var PROJECTS_YAML = `# Your projects, referenced by key from any stack. A project is just a root directory
2849
+ # plus the defaults its services inherit \u2014 cwd, .env and colour. Any language.
2732
2850
  # Paths may use forward slashes on Windows.
2851
+ #
2852
+ # Declaring projects here is optional: a service can carry its own \`cwd\` instead.
2733
2853
  projects: {}
2734
2854
 
2735
2855
  # Example \u2014 delete the \`{}\` above and uncomment:
@@ -2737,12 +2857,11 @@ projects: {}
2737
2857
  # projects:
2738
2858
  # api:
2739
2859
  # path: D:/work/api
2740
- # php: php # or an absolute path to a specific PHP build
2741
- # envFile: .env # read for QUEUE_CONNECTION, REDIS_*, APP_URL, DB_*
2860
+ # php: php # only used by \`stop: { artisan: ... }\`; ignore it for non-PHP projects
2861
+ # envFile: .env # read for \${project.env:KEY} tokens, and by \`doctor\`
2742
2862
  # color: cyan
2743
- # portal:
2744
- # path: D:/work/portal
2745
- # php: php
2863
+ # web:
2864
+ # path: D:/work/web
2746
2865
  # envFile: .env
2747
2866
  # color: magenta
2748
2867
  `;
@@ -3436,7 +3555,7 @@ ${paint("bold", "TASKS")}`);
3436
3555
  };
3437
3556
 
3438
3557
  // src/cli/program.ts
3439
- var VERSION = "0.1.0";
3558
+ var VERSION = true ? "0.2.0" : versionFromPackageJson();
3440
3559
  var resolveStackName = (explicit, env = process.env) => {
3441
3560
  if (explicit) return explicit;
3442
3561
  const configured = loadGlobalConfig(env).defaultStack;
@@ -3454,7 +3573,7 @@ var resolveStackName = (explicit, env = process.env) => {
3454
3573
  var collect = (value, previous) => [...previous, value];
3455
3574
  var buildProgram = () => {
3456
3575
  const program = new Command();
3457
- program.name("laracrew").description("Boot and supervise all the long-running processes of your Laravel projects with one command.").version(VERSION, "-v, --version").showHelpAfterError();
3576
+ program.name("laracrew").description("Boot and supervise all the long-running processes of your projects with one command.").version(VERSION, "-v, --version").showHelpAfterError();
3458
3577
  program.command("init").description("create ~/.laracrew").option("--examples", "also write a runnable demo stack and a two-project template").action((options) => {
3459
3578
  initCommand({ examples: options.examples ?? false });
3460
3579
  });