@ninhache/probe 0.5.0 → 0.7.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/cli.js CHANGED
@@ -3,6 +3,7 @@ import { createRequire as __createRequire } from "node:module";
3
3
  const require = __createRequire(import.meta.url);
4
4
  import {
5
5
  BACKUP_GLOB,
6
+ COMPLETE_COMMAND,
6
7
  CONFIG_FILE,
7
8
  DASHBOARD_INTERVAL_SECONDS,
8
9
  DEFAULT_MAKEFILE,
@@ -28,18 +29,20 @@ import {
28
29
  WAIT_BACKOFF_FACTOR,
29
30
  WAIT_LAST_PASS_MS,
30
31
  createColors,
32
+ curlCommand,
31
33
  detectTerminal,
32
34
  formatCheckResults,
33
35
  formatServices,
34
36
  isInteractive,
35
37
  runChecks,
36
- stripStyles
37
- } from "./chunk-4VXL4OTD.js";
38
+ stripStyles,
39
+ targetUrl
40
+ } from "./chunk-YTTYVEYF.js";
38
41
  import "./chunk-M3ASEG5F.js";
39
42
 
40
43
  // src/cli.ts
41
44
  import {
42
- defineCommand as defineCommand10,
45
+ defineCommand as defineCommand13,
43
46
  renderUsage,
44
47
  runCommand,
45
48
  runMain
@@ -48,6 +51,53 @@ import {
48
51
  // src/commands/add.ts
49
52
  import { defineCommand } from "citty";
50
53
 
54
+ // src/config/schema.ts
55
+ import { z } from "zod";
56
+ var CURRENT_SCHEMA_VERSION = 1;
57
+ var SERVICE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
58
+ var statusCode = z.number().int().min(100).max(599);
59
+ var serviceSchemaV1 = z.strictObject({
60
+ host: z.string().min(1).default("localhost"),
61
+ port: z.number().int().min(1).max(65535),
62
+ path: z.string().startsWith("/").default("/"),
63
+ expect: z.union([statusCode, z.array(statusCode).min(1)]).optional(),
64
+ /** Seconds given to this service to answer, instead of the default. */
65
+ timeout: z.number().positive().max(MAX_TIMEOUT_SECONDS).optional()
66
+ });
67
+ var configSchemaV1 = z.strictObject({
68
+ schemaVersion: z.literal(1),
69
+ services: z.record(
70
+ z.string().regex(SERVICE_NAME_PATTERN, "letters, digits, _ and - only"),
71
+ serviceSchemaV1
72
+ ).nullish().transform((services) => services ?? {})
73
+ });
74
+ var schemas = {
75
+ 1: configSchemaV1
76
+ };
77
+
78
+ // src/ui/prompt.ts
79
+ async function confirm(message, streams = {}) {
80
+ const { confirm: ask2, isCancel } = await import("./dist-SITQEC3X.js");
81
+ const answer = await ask2({ message, initialValue: false, ...streams });
82
+ return !isCancel(answer) && answer;
83
+ }
84
+ async function ask(message, question = {}, streams = {}) {
85
+ const { text, isCancel } = await import("./dist-SITQEC3X.js");
86
+ const { placeholder, defaultValue, validate: validate2 } = question;
87
+ const answer = await text({
88
+ message,
89
+ placeholder,
90
+ defaultValue,
91
+ validate: (typed) => {
92
+ const value = typed ?? "";
93
+ return value === "" && defaultValue !== void 0 ? void 0 : validate2?.(value);
94
+ },
95
+ ...streams
96
+ });
97
+ if (isCancel(answer)) return null;
98
+ return answer || (defaultValue ?? "");
99
+ }
100
+
51
101
  // src/config/discovery.ts
52
102
  import { statSync } from "fs";
53
103
  import { dirname, join, resolve } from "path";
@@ -89,30 +139,6 @@ import { copyFile, readFile, writeFile } from "fs/promises";
89
139
  import { join as join2, relative } from "path";
90
140
  import { parseDocument } from "yaml";
91
141
 
92
- // src/config/schema.ts
93
- import { z } from "zod";
94
- var CURRENT_SCHEMA_VERSION = 1;
95
- var SERVICE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
96
- var statusCode = z.number().int().min(100).max(599);
97
- var serviceSchemaV1 = z.strictObject({
98
- host: z.string().min(1).default("localhost"),
99
- port: z.number().int().min(1).max(65535),
100
- path: z.string().startsWith("/").default("/"),
101
- expect: z.union([statusCode, z.array(statusCode).min(1)]).optional(),
102
- /** Seconds given to this service to answer, instead of the default. */
103
- timeout: z.number().positive().max(MAX_TIMEOUT_SECONDS).optional()
104
- });
105
- var configSchemaV1 = z.strictObject({
106
- schemaVersion: z.literal(1),
107
- services: z.record(
108
- z.string().regex(SERVICE_NAME_PATTERN, "letters, digits, _ and - only"),
109
- serviceSchemaV1
110
- ).nullish().transform((services) => services ?? {})
111
- });
112
- var schemas = {
113
- 1: configSchemaV1
114
- };
115
-
116
142
  // src/config/migrations.ts
117
143
  var migrations = {};
118
144
  function formatIssues(error) {
@@ -316,7 +342,7 @@ async function writeLock(paths, lock) {
316
342
  }
317
343
 
318
344
  // src/version.ts
319
- var CLI_VERSION = true ? "0.5.0" : "0.0.0-dev";
345
+ var CLI_VERSION = true ? "0.7.0" : "0.0.0-dev";
320
346
 
321
347
  // src/makefile/generator.ts
322
348
  import { createHash } from "crypto";
@@ -331,13 +357,15 @@ function renderBlock(serviceNames) {
331
357
  return [
332
358
  BLOCK_START,
333
359
  `PROBE ?= $(shell command -v probe >/dev/null 2>&1 && echo probe || echo "${fallback}")`,
334
- `.PHONY: ${["help", "check", "status", ...targets].join(" ")}`,
360
+ `.PHONY: ${["help", "check", "status", "probe", ...targets].join(" ")}`,
335
361
  "help: ## List available targets",
336
362
  ` ${HELP_RECIPE}`,
337
363
  "check: ## Check that every service responds",
338
364
  " @$(PROBE) check",
339
365
  "status: ## Show probe status",
340
366
  " @$(PROBE) status",
367
+ "probe: ## Open the dashboard",
368
+ " @$(PROBE)",
341
369
  ...serviceNames.flatMap((name) => [
342
370
  `check-${name}: ## Check ${name}`,
343
371
  ` @$(PROBE) check ${name}`
@@ -426,6 +454,11 @@ function secondsToMs(option, value) {
426
454
  }
427
455
  return Math.ceil(seconds2 * 1e3);
428
456
  }
457
+ function namedService(config, name) {
458
+ const service = Object.hasOwn(config.services, name) ? config.services[name] : void 0;
459
+ if (!service) throw new ProbeError("SERVICE_UNKNOWN", `unknown service "${name}". See: probe ls`);
460
+ return service;
461
+ }
429
462
  function rawServices(loaded) {
430
463
  return loaded.raw.services ?? {};
431
464
  }
@@ -453,6 +486,65 @@ function parseExpect(value) {
453
486
  const codes = value.split(",").map((code) => parseInteger(code.trim(), "--expect"));
454
487
  return codes.length === 1 ? codes[0] : codes;
455
488
  }
489
+ var INTEGER = /^\d+$/;
490
+ var DEFAULT_PATH = "/";
491
+ function schemaProblem(schema, value) {
492
+ const result = schema.safeParse(value);
493
+ return result.success ? void 0 : result.error.issues[0]?.message;
494
+ }
495
+ function nameProblem(name, services) {
496
+ if (!SERVICE_NAME_PATTERN.test(name)) return "Letters, digits, _ and - only, e.g. api";
497
+ if (Object.hasOwn(services, name)) return `${name} already exists`;
498
+ return void 0;
499
+ }
500
+ function portProblem(port) {
501
+ if (!INTEGER.test(port)) return "A port number, e.g. 3000";
502
+ return schemaProblem(serviceSchemaV1.shape.port, Number(port)) && "A port between 1 and 65535";
503
+ }
504
+ function expectProblem(expect) {
505
+ const codes = expect.split(",").map((code) => code.trim());
506
+ if (!codes.every((code) => INTEGER.test(code))) return "A status, or several: 200 or 200,204";
507
+ return schemaProblem(serviceSchemaV1.shape.expect, parseExpect(expect)) && "Statuses go from 100 to 599";
508
+ }
509
+ async function guidedAdd() {
510
+ const loaded = await openProject();
511
+ const services = rawServices(loaded);
512
+ const answers = [];
513
+ const questions = [
514
+ {
515
+ message: "Service name",
516
+ placeholder: "api",
517
+ validate: (v) => nameProblem(v, services)
518
+ },
519
+ { message: "Port", placeholder: "3000", validate: portProblem },
520
+ {
521
+ message: "Path to request",
522
+ placeholder: DEFAULT_PATH,
523
+ defaultValue: DEFAULT_PATH,
524
+ validate: (v) => schemaProblem(serviceSchemaV1.shape.path, v)
525
+ },
526
+ {
527
+ message: "Expected status",
528
+ placeholder: "any 2xx",
529
+ defaultValue: "",
530
+ validate: expectProblem
531
+ }
532
+ ];
533
+ for (const { message, ...question } of questions) {
534
+ const answer = await ask(message, question);
535
+ if (answer === null) {
536
+ console.log("Nothing added.");
537
+ return;
538
+ }
539
+ answers.push(answer);
540
+ }
541
+ const [name, port, path, expect] = answers;
542
+ const service = { port: Number(port) };
543
+ if (path !== DEFAULT_PATH) service.path = path;
544
+ if (expect) service.expect = parseExpect(expect);
545
+ await saveServices(loaded, { ...services, [name]: service });
546
+ console.log(`Added ${name}. Try: probe check ${name}`);
547
+ }
456
548
  var add_default = defineCommand({
457
549
  meta: { name: "add", description: "Add a service" },
458
550
  args: {
@@ -466,25 +558,25 @@ var add_default = defineCommand({
466
558
  description: `Seconds to wait for this service (default: ${DEFAULT_TIMEOUT_SECONDS})`
467
559
  }
468
560
  },
469
- async run({ args }) {
561
+ async run({ args: args2 }) {
470
562
  const loaded = await openProject();
471
563
  const services = rawServices(loaded);
472
- if (Object.hasOwn(services, args.name)) {
564
+ if (Object.hasOwn(services, args2.name)) {
473
565
  throw new ProbeError(
474
566
  "SERVICE_EXISTS",
475
- `service "${args.name}" already exists. Remove it first: probe rm ${args.name}`
567
+ `service "${args2.name}" already exists. Remove it first: probe rm ${args2.name}`
476
568
  );
477
569
  }
478
- const service = { port: parseInteger(String(args.port), "--port") };
479
- if (args.host) service.host = args.host;
480
- if (args.path) service.path = args.path;
481
- if (args.expect) service.expect = parseExpect(String(args.expect));
482
- if (args.timeout !== void 0) {
483
- secondsToMs("--timeout", String(args.timeout));
484
- service.timeout = Number(args.timeout);
570
+ const service = { port: parseInteger(String(args2.port), "--port") };
571
+ if (args2.host) service.host = args2.host;
572
+ if (args2.path) service.path = args2.path;
573
+ if (args2.expect) service.expect = parseExpect(String(args2.expect));
574
+ if (args2.timeout !== void 0) {
575
+ secondsToMs("--timeout", String(args2.timeout));
576
+ service.timeout = Number(args2.timeout);
485
577
  }
486
- await saveServices(loaded, { ...services, [args.name]: service });
487
- console.log(`Added ${args.name}. Try: probe check ${args.name}`);
578
+ await saveServices(loaded, { ...services, [args2.name]: service });
579
+ console.log(`Added ${args2.name}. Try: probe check ${args2.name}`);
488
580
  }
489
581
  });
490
582
 
@@ -606,13 +698,13 @@ var check_default = defineCommand2({
606
698
  description: `Seconds to wait for each service (default: its own timeout, else ${DEFAULT_TIMEOUT_SECONDS})`
607
699
  }
608
700
  },
609
- async run({ args }) {
610
- const forceTimeout = args.timeout !== void 0;
611
- const timeoutMs = secondsToMs("--timeout", args.timeout ?? String(DEFAULT_TIMEOUT_SECONDS));
701
+ async run({ args: args2 }) {
702
+ const forceTimeout = args2.timeout !== void 0;
703
+ const timeoutMs = secondsToMs("--timeout", args2.timeout ?? String(DEFAULT_TIMEOUT_SECONDS));
612
704
  const { config, paths } = await openProject();
613
705
  const skipped = await readSkippedServices(paths);
614
- const results = await runChecks(config, args._, { timeoutMs, forceTimeout }, skipped);
615
- if (args.json) {
706
+ const results = await runChecks(config, args2._, { timeoutMs, forceTimeout }, skipped);
707
+ if (args2.json) {
616
708
  console.log(JSON.stringify(results, null, 2));
617
709
  } else if (results.length === 0) {
618
710
  console.log("No services yet. Add one: probe add api -p 3000");
@@ -623,11 +715,190 @@ var check_default = defineCommand2({
623
715
  }
624
716
  });
625
717
 
718
+ // src/commands/completion.ts
719
+ import { defineCommand as defineCommand3 } from "citty";
720
+
721
+ // src/completion/spec.ts
722
+ var SHELLS = ["zsh", "bash", "fish"];
723
+ var COMPLETION_COMMAND = "completion";
724
+ var SERVICE_COMMANDS = ["rm", "check", "wait", "open", "curl"];
725
+ var ROOT_OPTIONS = ["--interval", "--help", "--version"];
726
+ var SERVICES_QUERY = `probe ${COMPLETE_COMMAND} services 2>/dev/null`;
727
+ var HELP_OPTION = "--help";
728
+ function completionSpec(commands2) {
729
+ return Object.entries(commands2).map(([name, command]) => {
730
+ const meta2 = command.meta ?? {};
731
+ const args2 = command.args ?? {};
732
+ const options = Object.entries(args2).filter(([, arg]) => arg.type !== "positional").map(([option]) => `--${option}`);
733
+ return {
734
+ name,
735
+ description: meta2.description ?? "",
736
+ options: [...options, HELP_OPTION],
737
+ words: name === COMPLETION_COMMAND ? [...SHELLS] : [],
738
+ services: SERVICE_COMMANDS.includes(name)
739
+ };
740
+ });
741
+ }
742
+
743
+ // src/completion/bash.ts
744
+ function bashScript(commands2) {
745
+ const cases = (pick) => commands2.filter((command) => pick(command).length > 0).map((command) => ` ${command.name}) words="${pick(command).join(" ")}" ;;`);
746
+ const withServices = commands2.filter((command) => command.services).map(({ name }) => name);
747
+ return [
748
+ '# bash completion for probe. Load it with: eval "$(probe completion bash)"',
749
+ "_probe() {",
750
+ ` local cur="\${COMP_WORDS[COMP_CWORD]}" cmd="" words="" i`,
751
+ " for ((i = 1; i < COMP_CWORD; i++)); do",
752
+ ` case "\${COMP_WORDS[i]}" in`,
753
+ " -*) ;;",
754
+ ` *) cmd="\${COMP_WORDS[i]}"; break ;;`,
755
+ " esac",
756
+ " done",
757
+ ' if [ -z "$cmd" ]; then',
758
+ ' case "$cur" in',
759
+ ` -*) words="${ROOT_OPTIONS.join(" ")}" ;;`,
760
+ ` *) words="${commands2.map(({ name }) => name).join(" ")}" ;;`,
761
+ " esac",
762
+ ` elif [ "\${cur#-}" != "$cur" ]; then`,
763
+ ' case "$cmd" in',
764
+ ...cases((command) => command.options),
765
+ " esac",
766
+ " else",
767
+ ' case "$cmd" in',
768
+ ` ${withServices.join("|")}) words="$(${SERVICES_QUERY})" ;;`,
769
+ ...cases((command) => command.words),
770
+ " esac",
771
+ " fi",
772
+ ' COMPREPLY=($(compgen -W "$words" -- "$cur"))',
773
+ "}",
774
+ "complete -F _probe probe",
775
+ ""
776
+ ].join("\n");
777
+ }
778
+
779
+ // src/completion/fish.ts
780
+ var quoted = (text) => `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
781
+ var longOption = (option) => `-l ${option.replace(/^--/, "")}`;
782
+ function fishScript(commands2) {
783
+ const inCommand = (names) => `-n '__fish_seen_subcommand_from ${names.join(" ")}'`;
784
+ const withServices = commands2.filter((command) => command.services).map(({ name }) => name);
785
+ return [
786
+ "# fish completion for probe. Load it with: probe completion fish | source",
787
+ "function __probe_services",
788
+ ` ${SERVICES_QUERY}`,
789
+ "end",
790
+ // No file names: probe takes none.
791
+ "complete -c probe -f",
792
+ ...ROOT_OPTIONS.map(
793
+ (option) => `complete -c probe -n __fish_use_subcommand ${longOption(option)}`
794
+ ),
795
+ ...commands2.map(
796
+ ({ name, description }) => `complete -c probe -n __fish_use_subcommand -a ${name} -d ${quoted(description)}`
797
+ ),
798
+ `complete -c probe ${inCommand(withServices)} -a '(__probe_services)'`,
799
+ ...commands2.flatMap((command) => [
800
+ ...command.options.map(
801
+ (option) => `complete -c probe ${inCommand([command.name])} ${longOption(option)}`
802
+ ),
803
+ ...command.words.length > 0 ? [`complete -c probe ${inCommand([command.name])} -a ${quoted(command.words.join(" "))}`] : []
804
+ ]),
805
+ ""
806
+ ].join("\n");
807
+ }
808
+
809
+ // src/completion/zsh.ts
810
+ function described({ name, description }) {
811
+ const text = `${name}:${description.replaceAll(":", "\\:")}`;
812
+ return `'${text.replaceAll("'", "'\\''")}'`;
813
+ }
814
+ function zshScript(commands2) {
815
+ const cases = (pick) => commands2.filter((command) => pick(command).length > 0).map((command) => ` ${command.name}) compadd -- ${pick(command).join(" ")} ;;`);
816
+ const withServices = commands2.filter((command) => command.services).map(({ name }) => name);
817
+ return [
818
+ "#compdef probe",
819
+ '# zsh completion for probe. Load it after compinit with: eval "$(probe completion zsh)"',
820
+ "_probe() {",
821
+ " local -a commands services",
822
+ " commands=(",
823
+ ...commands2.map((command) => ` ${described(command)}`),
824
+ " )",
825
+ " if (( CURRENT == 2 )); then",
826
+ " if [[ $words[CURRENT] == -* ]]; then",
827
+ ` compadd -- ${ROOT_OPTIONS.join(" ")}`,
828
+ " else",
829
+ " _describe 'command' commands",
830
+ " fi",
831
+ " return",
832
+ " fi",
833
+ " if [[ $words[CURRENT] == -* ]]; then",
834
+ " case $words[2] in",
835
+ ...cases((command) => command.options).map((line) => ` ${line}`),
836
+ " esac",
837
+ " return",
838
+ " fi",
839
+ " case $words[2] in",
840
+ ` ${withServices.join("|")})`,
841
+ ` services=(\${(f)"$(${SERVICES_QUERY})"})`,
842
+ " compadd -a services ;;",
843
+ ...cases((command) => command.words),
844
+ " esac",
845
+ "}",
846
+ "if (( $+functions[compdef] )); then compdef _probe probe; fi",
847
+ "# Saved as _probe in $fpath, this file is the function itself: run it.",
848
+ 'if [[ $funcstack[1] == _probe ]]; then _probe "$@"; fi',
849
+ ""
850
+ ].join("\n");
851
+ }
852
+
853
+ // src/commands/completion.ts
854
+ var SCRIPTS = {
855
+ zsh: zshScript,
856
+ bash: bashScript,
857
+ fish: fishScript
858
+ };
859
+ var meta = {
860
+ name: COMPLETION_COMMAND,
861
+ description: `Print the shell completion script (${SHELLS.join(", ")})`
862
+ };
863
+ var args = {
864
+ shell: { type: "positional", description: SHELLS.join(", "), required: true }
865
+ };
866
+ function completion(others) {
867
+ const commands2 = { ...others, [COMPLETION_COMMAND]: { meta, args } };
868
+ return defineCommand3({
869
+ meta,
870
+ args,
871
+ run({ args: { shell } }) {
872
+ const script = Object.hasOwn(SCRIPTS, shell) ? SCRIPTS[shell] : void 0;
873
+ if (!script) {
874
+ throw new ProbeError(
875
+ "USAGE",
876
+ `no completion for the shell "${shell}". Supported: ${SHELLS.join(", ")}.`
877
+ );
878
+ }
879
+ process.stdout.write(script(completionSpec(commands2)));
880
+ }
881
+ });
882
+ }
883
+
884
+ // src/commands/curl.ts
885
+ import { defineCommand as defineCommand4 } from "citty";
886
+ var curl_default = defineCommand4({
887
+ meta: { name: "curl", description: "Print the curl command that requests a service" },
888
+ args: {
889
+ name: { type: "positional", description: "Service name", required: true }
890
+ },
891
+ async run({ args: args2 }) {
892
+ const { config } = await openProject();
893
+ console.log(curlCommand(namedService(config, args2.name)));
894
+ }
895
+ });
896
+
626
897
  // src/commands/init.ts
627
898
  import { existsSync } from "fs";
628
899
  import { mkdir, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
629
900
  import { join as join5 } from "path";
630
- import { defineCommand as defineCommand3 } from "citty";
901
+ import { defineCommand as defineCommand5 } from "citty";
631
902
  var IGNORED = [`${PROBE_DIR}/${LOCAL_FILE}`, `${PROBE_DIR}/${BACKUP_GLOB}`];
632
903
  var INITIAL_CONFIG = `# probe - local services to health-check. Edit by hand or with \`probe add\`.
633
904
  schemaVersion: ${CURRENT_SCHEMA_VERSION}
@@ -643,26 +914,29 @@ async function ignoreLocalFiles(root) {
643
914
  await writeFile5(file, `${current}${separator}${missing.join("\n")}
644
915
  `);
645
916
  }
646
- var init_default = defineCommand3({
917
+ async function initProject(root) {
918
+ const paths = pathsFor(root);
919
+ if (existsSync(paths.dir)) {
920
+ throw new ProbeError("ALREADY_INITIALIZED", `${PROBE_DIR}/ already exists here.`);
921
+ }
922
+ await mkdir(paths.dir);
923
+ await writeFile5(paths.config, INITIAL_CONFIG);
924
+ await ignoreLocalFiles(paths.root);
925
+ }
926
+ var init_default = defineCommand5({
647
927
  meta: {
648
928
  name: "init",
649
929
  description: "Create .probe/ and the .gitignore entries"
650
930
  },
651
931
  async run() {
652
- const paths = pathsFor(process.cwd());
653
- if (existsSync(paths.dir)) {
654
- throw new ProbeError("ALREADY_INITIALIZED", `${PROBE_DIR}/ already exists here.`);
655
- }
656
- await mkdir(paths.dir);
657
- await writeFile5(paths.config, INITIAL_CONFIG);
658
- await ignoreLocalFiles(paths.root);
932
+ await initProject(process.cwd());
659
933
  console.log(`Created ${PROBE_DIR}/. Next: probe add api -p 3000`);
660
934
  }
661
935
  });
662
936
 
663
937
  // src/commands/ls.ts
664
- import { defineCommand as defineCommand4 } from "citty";
665
- var ls_default = defineCommand4({
938
+ import { defineCommand as defineCommand6 } from "citty";
939
+ var ls_default = defineCommand6({
666
940
  meta: { name: "ls", description: "List services" },
667
941
  async run() {
668
942
  const { config, paths } = await openProject();
@@ -675,8 +949,8 @@ var ls_default = defineCommand4({
675
949
  });
676
950
 
677
951
  // src/commands/migrate.ts
678
- import { defineCommand as defineCommand5 } from "citty";
679
- var migrate_default = defineCommand5({
952
+ import { defineCommand as defineCommand7 } from "citty";
953
+ var migrate_default = defineCommand7({
680
954
  meta: { name: "migrate", description: "Migrate the config to the current schema version" },
681
955
  async run() {
682
956
  const paths = requireProbeDir(process.cwd());
@@ -688,27 +962,71 @@ var migrate_default = defineCommand5({
688
962
  }
689
963
  });
690
964
 
965
+ // src/commands/open.ts
966
+ import { defineCommand as defineCommand8 } from "citty";
967
+
968
+ // src/open-url.ts
969
+ import { spawn as nodeSpawn } from "child_process";
970
+ function openerCommand(platform) {
971
+ return platform === "darwin" ? "open" : "xdg-open";
972
+ }
973
+ function openUrl(url, platform = process.platform, spawn2 = nodeSpawn) {
974
+ return new Promise((resolve2) => {
975
+ const child = spawn2(openerCommand(platform), [url], { detached: true, stdio: "ignore" });
976
+ child.once("error", () => resolve2(false));
977
+ child.once("spawn", () => {
978
+ child.unref();
979
+ resolve2(true);
980
+ });
981
+ });
982
+ }
983
+
984
+ // src/commands/open.ts
985
+ var open_default = defineCommand8({
986
+ meta: { name: "open", description: "Open the URL of a service in the browser" },
987
+ args: {
988
+ name: { type: "positional", description: "Service name", required: true }
989
+ },
990
+ async run({ args: args2 }) {
991
+ const { config } = await openProject();
992
+ const url = targetUrl(namedService(config, args2.name));
993
+ if (await openUrl(url)) {
994
+ console.log(`Opened ${url}`);
995
+ return;
996
+ }
997
+ console.log(url);
998
+ console.error(`probe: ${openerCommand(process.platform)} not found, open the URL yourself.`);
999
+ process.exitCode = EXIT_DOWN;
1000
+ }
1001
+ });
1002
+
691
1003
  // src/commands/rm.ts
692
- import { defineCommand as defineCommand6 } from "citty";
693
- var rm_default = defineCommand6({
1004
+ import { defineCommand as defineCommand9 } from "citty";
1005
+ var rm_default = defineCommand9({
694
1006
  meta: { name: "rm", description: "Remove a service" },
695
1007
  args: {
696
- name: { type: "positional", description: "Service name", required: true }
1008
+ name: { type: "positional", description: "Service name", required: true },
1009
+ yes: { type: "boolean", alias: "y", description: "Remove without asking" }
697
1010
  },
698
- async run({ args }) {
1011
+ async run({ args: args2 }) {
699
1012
  const loaded = await openProject();
700
1013
  const services = rawServices(loaded);
701
- if (!Object.hasOwn(services, args.name)) {
702
- throw new ProbeError("SERVICE_UNKNOWN", `unknown service "${args.name}". See: probe ls`);
1014
+ if (!Object.hasOwn(services, args2.name)) {
1015
+ throw new ProbeError("SERVICE_UNKNOWN", `unknown service "${args2.name}". See: probe ls`);
1016
+ }
1017
+ const asks = !args2.yes && isInteractive(process.stdin, process.stdout, process.env);
1018
+ if (asks && !await confirm(`Remove ${args2.name}?`)) {
1019
+ console.log("Nothing removed.");
1020
+ return;
703
1021
  }
704
- const { [args.name]: _removed, ...rest } = services;
1022
+ const { [args2.name]: _removed, ...rest } = services;
705
1023
  await saveServices(loaded, rest);
706
- console.log(`Removed ${args.name}.`);
1024
+ console.log(`Removed ${args2.name}.`);
707
1025
  }
708
1026
  });
709
1027
 
710
1028
  // src/commands/status.ts
711
- import { defineCommand as defineCommand7 } from "citty";
1029
+ import { defineCommand as defineCommand10 } from "citty";
712
1030
 
713
1031
  // src/update/notifier.ts
714
1032
  import { spawn } from "child_process";
@@ -734,8 +1052,8 @@ function writeCache(dir, cache) {
734
1052
  writeFileSync(join6(dir, UPDATE_CACHE_FILE), JSON.stringify(cache));
735
1053
  }
736
1054
  function isNewer(latest, current) {
737
- const parse2 = (version) => (version.split("-")[0] ?? "").split(".").map(Number);
738
- const [a, b] = [parse2(latest), parse2(current)];
1055
+ const parse3 = (version) => (version.split("-")[0] ?? "").split(".").map(Number);
1056
+ const [a, b] = [parse3(latest), parse3(current)];
739
1057
  for (let index = 0; index < 3; index++) {
740
1058
  const difference = (a[index] || 0) - (b[index] || 0);
741
1059
  if (difference !== 0) return difference > 0;
@@ -797,7 +1115,7 @@ function describeUpdate(colors) {
797
1115
  if (!readCache(cacheDir())?.latest) return colors.dim(`not checked yet (${CLI_VERSION})`);
798
1116
  return colors.green(`up to date (${CLI_VERSION})`);
799
1117
  }
800
- var status_default = defineCommand7({
1118
+ var status_default = defineCommand10({
801
1119
  meta: { name: "status", description: "Show pending migration, Makefile state and updates" },
802
1120
  async run() {
803
1121
  const colors = stdoutColors();
@@ -819,8 +1137,8 @@ var status_default = defineCommand7({
819
1137
 
820
1138
  // src/commands/sync.ts
821
1139
  import { relative as relative2 } from "path";
822
- import { defineCommand as defineCommand8 } from "citty";
823
- var sync_default = defineCommand8({
1140
+ import { defineCommand as defineCommand11 } from "citty";
1141
+ var sync_default = defineCommand11({
824
1142
  meta: {
825
1143
  name: "sync",
826
1144
  description: "Add or regenerate the probe block of the Makefile (optional)"
@@ -836,7 +1154,7 @@ var sync_default = defineCommand8({
836
1154
  });
837
1155
 
838
1156
  // src/commands/wait.ts
839
- import { defineCommand as defineCommand9 } from "citty";
1157
+ import { defineCommand as defineCommand12 } from "citty";
840
1158
 
841
1159
  // src/checkers/poll.ts
842
1160
  import { setTimeout as delay } from "timers/promises";
@@ -885,7 +1203,7 @@ function progressReporter() {
885
1203
  return lines;
886
1204
  };
887
1205
  }
888
- var wait_default = defineCommand9({
1206
+ var wait_default = defineCommand12({
889
1207
  meta: {
890
1208
  name: "wait",
891
1209
  description: "Check again and again until services are up (all of them by default)"
@@ -908,14 +1226,14 @@ var wait_default = defineCommand9({
908
1226
  default: String(DEFAULT_WAIT_MAX_INTERVAL_SECONDS)
909
1227
  }
910
1228
  },
911
- async run({ args }) {
912
- const deadlineMs = secondsToMs("--timeout", args.timeout);
913
- const intervalMs = secondsToMs("--interval", args.interval);
914
- const maxIntervalMs = secondsToMs("--max-interval", args["max-interval"]);
1229
+ async run({ args: args2 }) {
1230
+ const deadlineMs = secondsToMs("--timeout", args2.timeout);
1231
+ const intervalMs = secondsToMs("--interval", args2.interval);
1232
+ const maxIntervalMs = secondsToMs("--max-interval", args2["max-interval"]);
915
1233
  if (intervalMs > maxIntervalMs) {
916
1234
  throw new ProbeError(
917
1235
  "USAGE",
918
- `--interval (${args.interval}s) cannot be longer than --max-interval (${args["max-interval"]}s).`
1236
+ `--interval (${args2.interval}s) cannot be longer than --max-interval (${args2["max-interval"]}s).`
919
1237
  );
920
1238
  }
921
1239
  const { config, paths } = await openProject();
@@ -925,7 +1243,7 @@ var wait_default = defineCommand9({
925
1243
  // A service with its own timeout keeps it here, within what is left of the total.
926
1244
  (_passMs, remainingMs) => runChecks(
927
1245
  config,
928
- args._,
1246
+ args2._,
929
1247
  { timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1e3, maxTimeoutMs: remainingMs },
930
1248
  skipped
931
1249
  ),
@@ -939,7 +1257,7 @@ var wait_default = defineCommand9({
939
1257
  }
940
1258
  }
941
1259
  );
942
- if (args.json) {
1260
+ if (args2.json) {
943
1261
  console.log(JSON.stringify(results, null, 2));
944
1262
  } else if (results.length === 0) {
945
1263
  console.log("No services yet. Add one: probe add api -p 3000");
@@ -954,6 +1272,22 @@ var wait_default = defineCommand9({
954
1272
  }
955
1273
  });
956
1274
 
1275
+ // src/completion/services.ts
1276
+ import { readFile as readFile6 } from "fs/promises";
1277
+ import { parse as parse2 } from "yaml";
1278
+ async function completableServices(cwd) {
1279
+ try {
1280
+ const paths = findProbeDir(cwd);
1281
+ if (!paths) return [];
1282
+ const raw = parse2(await readFile6(paths.config, "utf8"));
1283
+ const services = raw?.services;
1284
+ if (typeof services !== "object" || services === null) return [];
1285
+ return Object.keys(services).filter((name) => SERVICE_NAME_PATTERN.test(name));
1286
+ } catch {
1287
+ return [];
1288
+ }
1289
+ }
1290
+
957
1291
  // src/dashboard/saves.ts
958
1292
  function describe(file, error) {
959
1293
  const code = error?.code;
@@ -984,7 +1318,7 @@ function createSaveQueue() {
984
1318
  }
985
1319
 
986
1320
  // src/suggest.ts
987
- var SHORT_WORD = 3;
1321
+ var SHORT_WORD = 4;
988
1322
  var TYPOS_SHORT = 1;
989
1323
  var TYPOS_LONG = 2;
990
1324
  function typoDistance(a, b) {
@@ -1036,7 +1370,10 @@ var EXAMPLES = {
1036
1370
  { command: "probe add admin -p 8080 --expect 200,204", note: "Accept these statuses only" },
1037
1371
  { command: "probe add db -p 5432 --timeout 10", note: "Give it 10 s to answer" }
1038
1372
  ],
1039
- rm: [{ command: "probe rm api", note: "Remove the service named api" }],
1373
+ rm: [
1374
+ { command: "probe rm api", note: "Remove the service named api" },
1375
+ { command: "probe rm api --yes", note: "Remove it without the question" }
1376
+ ],
1040
1377
  ls: [{ command: "probe ls", note: "List the services and their URL" }],
1041
1378
  check: [
1042
1379
  { command: "probe check", note: "Check every service" },
@@ -1049,9 +1386,19 @@ var EXAMPLES = {
1049
1386
  { command: "probe wait api --timeout 120", note: "Wait for api, 2 minutes at most" },
1050
1387
  { command: "probe wait --interval 1 --max-interval 10", note: "Check less often" }
1051
1388
  ],
1389
+ open: [{ command: "probe open front", note: "Open the URL of front in the browser" }],
1390
+ curl: [
1391
+ { command: "probe curl api", note: "Print the curl command for api" },
1392
+ { command: "probe curl api | sh", note: "Run it" }
1393
+ ],
1052
1394
  status: [{ command: "probe status", note: "Version, migration, Makefile block" }],
1053
1395
  sync: [{ command: "probe sync", note: "Write the probe block of the Makefile" }],
1054
- migrate: [{ command: "probe migrate", note: "Bring .probe/config.yml up to date" }]
1396
+ migrate: [{ command: "probe migrate", note: "Bring .probe/config.yml up to date" }],
1397
+ completion: [
1398
+ { command: "probe completion zsh", note: 'In ~/.zshrc: eval "$(probe completion zsh)"' },
1399
+ { command: "probe completion bash", note: 'In ~/.bashrc: eval "$(probe completion bash)"' },
1400
+ { command: "probe completion fish | source", note: "In ~/.config/fish/config.fish" }
1401
+ ]
1055
1402
  };
1056
1403
  function formatExamples(name, color) {
1057
1404
  const examples = EXAMPLES[name ?? ROOT] ?? [];
@@ -1067,7 +1414,8 @@ function formatExamples(name, color) {
1067
1414
  }
1068
1415
 
1069
1416
  // src/cli.ts
1070
- var main = defineCommand10({
1417
+ var commands = { init: init_default, add: add_default, rm: rm_default, ls: ls_default, check: check_default, wait: wait_default, open: open_default, curl: curl_default, status: status_default, sync: sync_default, migrate: migrate_default };
1418
+ var main = defineCommand13({
1071
1419
  meta: {
1072
1420
  name: "probe",
1073
1421
  version: CLI_VERSION,
@@ -1081,13 +1429,13 @@ var main = defineCommand10({
1081
1429
  default: String(DASHBOARD_INTERVAL_SECONDS)
1082
1430
  }
1083
1431
  },
1084
- subCommands: { init: init_default, add: add_default, rm: rm_default, ls: ls_default, check: check_default, wait: wait_default, status: status_default, sync: sync_default, migrate: migrate_default }
1432
+ subCommands: { ...commands, completion: completion(commands) }
1085
1433
  });
1086
1434
  async function printUsage(command, parent) {
1087
1435
  const usage = await renderUsage(command, parent);
1088
1436
  const { color } = stdoutTerminal();
1089
- const meta = await (typeof command.meta === "function" ? command.meta() : command.meta);
1090
- const examples = formatExamples(command === main ? void 0 : meta?.name, color);
1437
+ const meta2 = await (typeof command.meta === "function" ? command.meta() : command.meta);
1438
+ const examples = formatExamples(command === main ? void 0 : meta2?.name, color);
1091
1439
  console.log(`${color ? usage : stripStyles(usage)}
1092
1440
  ${examples ? `
1093
1441
  ${examples}
@@ -1095,7 +1443,7 @@ ${examples}
1095
1443
  }
1096
1444
  function notifyUpdate(rawArgs) {
1097
1445
  const cliPath = process.argv[1];
1098
- if (updateCheckDisabled() || !cliPath) return;
1446
+ if (updateCheckDisabled() || !cliPath || rawArgs[0] === COMPLETION_COMMAND) return;
1099
1447
  scheduleRefresh(cacheDir(), cliPath);
1100
1448
  if (!detectTerminal(process.stderr, process.env).isTTY || rawArgs.includes("--json") || rawArgs[0] === "status")
1101
1449
  return;
@@ -1131,7 +1479,7 @@ async function openedDashboard(rawArgs) {
1131
1479
  if (!asked) return false;
1132
1480
  throw new ProbeError("USAGE", "No services yet. Add one: probe add api -p 3000");
1133
1481
  }
1134
- const { openDashboard } = await import("./open-WH6VGPQT.js");
1482
+ const { openDashboard } = await import("./open-T3OQH2O4.js");
1135
1483
  let settings = initialDashboardSettings(askedMs, await readDashboardPreferences(paths));
1136
1484
  const saves = createSaveQueue();
1137
1485
  const local = `${PROBE_DIR}/${LOCAL_FILE}`;
@@ -1155,6 +1503,23 @@ async function openedDashboard(rawArgs) {
1155
1503
  for (const failure of saves.failures()) console.error(`probe: not saved: ${failure}`);
1156
1504
  return true;
1157
1505
  }
1506
+ async function offeredInit(error, rawArgs) {
1507
+ if (!(error instanceof ProbeError) || error.code !== "NO_PROBE_DIR") return false;
1508
+ if (rawArgs.includes("--json")) return false;
1509
+ if (!isInteractive(process.stdin, process.stdout, process.env)) return false;
1510
+ const root = process.cwd();
1511
+ if (!await confirm(`No ${PROBE_DIR}/ here. Create it in ${root}?`)) return false;
1512
+ await initProject(root);
1513
+ console.log(`Created ${PROBE_DIR}/.`);
1514
+ return true;
1515
+ }
1516
+ function wantsGuidedAdd(rawArgs) {
1517
+ return rawArgs.length === 1 && rawArgs[0] === "add" && isInteractive(process.stdin, process.stdout, process.env);
1518
+ }
1519
+ async function dispatch(rawArgs) {
1520
+ if (wantsGuidedAdd(rawArgs)) return guidedAdd();
1521
+ if (!await openedDashboard(rawArgs)) await runCommand(main, { rawArgs });
1522
+ }
1158
1523
  var UNKNOWN_COMMAND = "E_UNKNOWN_COMMAND";
1159
1524
  function didYouMean(error, rawArgs) {
1160
1525
  if (error.code !== UNKNOWN_COMMAND) return "";
@@ -1167,13 +1532,23 @@ function flushed(stream) {
1167
1532
  stream.write("", () => done());
1168
1533
  });
1169
1534
  }
1535
+ async function printCompletions(kind) {
1536
+ if (kind !== "services") return;
1537
+ for (const name of await completableServices(process.cwd())) console.log(name);
1538
+ }
1170
1539
  async function run(rawArgs) {
1171
1540
  if (rawArgs[0] === REFRESH_COMMAND) return refreshCache(cacheDir());
1541
+ if (rawArgs[0] === COMPLETE_COMMAND) return printCompletions(rawArgs[1]);
1172
1542
  const wantsHelp = rawArgs.includes("--help") || rawArgs.includes("-h");
1173
1543
  const wantsVersion = rawArgs.length === 1 && ["--version", "-v"].includes(rawArgs[0]);
1174
1544
  if (wantsHelp || wantsVersion) return runMain(main, { rawArgs, showUsage: printUsage });
1175
1545
  try {
1176
- if (!await openedDashboard(rawArgs)) await runCommand(main, { rawArgs });
1546
+ try {
1547
+ await dispatch(rawArgs);
1548
+ } catch (error) {
1549
+ if (!await offeredInit(error, rawArgs)) throw error;
1550
+ await dispatch(rawArgs);
1551
+ }
1177
1552
  } catch (error) {
1178
1553
  process.exitCode = EXIT_ERROR;
1179
1554
  if (error instanceof ProbeError) {