@ninhache/probe 0.4.1 → 0.6.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
@@ -11,6 +11,7 @@ Declare your project's local services once. Check they are up with one command,
11
11
  $ probe check
12
12
  ✓ api http://localhost:3000/health 200 12ms
13
13
  ✗ front http://localhost:5173/ connection refused
14
+ nothing listens on :5173: is the service started?
14
15
  $ echo $?
15
16
  1
16
17
  ```
@@ -44,20 +45,44 @@ Want `make check` too? See [Makefile](#makefile): it is optional.
44
45
  | `probe ls` | List services |
45
46
  | `probe check [name...] [--json] [--timeout <seconds>]` | Check all services, or the named ones |
46
47
  | `probe wait [name...] [--json] [--timeout <seconds>]` | Check again until every service is up, or give up after the timeout |
48
+ | `probe open <name>` | Open the URL of a service in the browser |
49
+ | `probe curl <name>` | Print the curl command that requests a service |
47
50
  | `probe status` | CLI version, pending migration, Makefile block state, available update |
48
51
  | `probe sync` | Add or regenerate the Makefile block (optional) |
49
52
  | `probe migrate` | Migrate `.probe/config.yml` to the current schema |
53
+ | `probe completion <zsh\|bash\|fish>` | Print the shell completion script |
50
54
 
51
55
  Exit codes: `0` all up, `1` a service is down, `2` usage or config error.
52
56
 
57
+ A service that is down comes with a hint when the cause is a known one: connection refused, timeout, host not
58
+ found, unexpected status, certificate rejected after a redirect to https. With `--json` it is the `hint` field,
59
+ next to `error`.
60
+
53
61
  `probe wait` is for scripts: `probe wait && pnpm test`. It checks at once, then again after 0.5 s, 1 s, 2 s, 4 s
54
62
  and every 5 s, for 60 s in total. `--timeout` sets the total, `--interval` the first pause, `--max-interval` the
55
63
  longest one. Unlike `check --timeout`, which is the time given to each service, `wait --timeout` covers the whole
56
64
  wait; each check inside it still gets 3 s, or the service's own `timeout`. Progress goes to stderr, the final
57
65
  report to stdout; exit code `1` names the services still down.
58
66
 
67
+ `probe open` uses `open` on macOS and `xdg-open` elsewhere. Without it, the URL is printed and the exit code is
68
+ `1`. `probe curl api` prints a command to copy, or to run: `probe curl api | sh`.
69
+
59
70
  `probe` works from any sub-directory: it walks up to the nearest `.probe/`.
60
71
 
72
+ ## Completion
73
+
74
+ Tab completes the commands, their options and the names of your services (`probe check a<Tab>`).
75
+
76
+ ```sh
77
+ eval "$(probe completion zsh)" # in ~/.zshrc, after compinit
78
+ eval "$(probe completion bash)" # in ~/.bashrc (macOS: ~/.bash_profile)
79
+ probe completion fish | source # in ~/.config/fish/config.fish
80
+ ```
81
+
82
+ In zsh you can also save it once instead: `probe completion zsh > ~/.zfunc/_probe`, with `~/.zfunc` in your
83
+ `fpath`; in fish, `probe completion fish > ~/.config/fish/completions/probe.fish`. It works with the bash 3.2
84
+ of macOS.
85
+
61
86
  ## Dashboard
62
87
 
63
88
  `probe` alone, on a terminal, opens a live list of your services and checks them every 2 s
@@ -137,12 +162,16 @@ outside the block is yours and is never touched. Without a block, no command cre
137
162
  Once the block is there, `probe add` and `probe rm` keep it up to date. Commit the `Makefile` and
138
163
  `.probe/lock.yml`.
139
164
 
140
- Targets: `help`, `check`, `status` and one `check-<name>` per service. The block uses `probe` when it is in
165
+ Targets: `help`, `check`, `status`, `probe` (opens the [dashboard](#dashboard)) and one `check-<name>` per
166
+ service. The block uses `probe` when it is in
141
167
  your `PATH`, else `npx -y @ninhache/probe@latest`. It works with GNU Make 3.81 (the macOS default).
142
168
 
143
169
  Edited the block by hand? `probe status` says so and `probe add` / `probe rm` leave it alone. `probe sync`
144
170
  restores it.
145
171
 
172
+ A newer `probe` can add targets to the block: `make probe` came with 0.6.0. A block written before that is
173
+ reported as `outdated` by `probe status`. Run `probe sync` once to get the new targets.
174
+
146
175
  ## Migrations
147
176
 
148
177
  `config.yml` carries a `schemaVersion`. When a newer `probe` needs a newer schema, the first command you run:
@@ -27,6 +27,7 @@ var REGISTRY_TIMEOUT_MS = 5e3;
27
27
  var UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
28
28
  var UPDATE_CACHE_FILE = "latest.json";
29
29
  var REFRESH_COMMAND = "__refresh-update-cache";
30
+ var COMPLETE_COMMAND = "__complete";
30
31
  var EXIT_DOWN = 1;
31
32
  var EXIT_ERROR = 2;
32
33
 
@@ -93,19 +94,83 @@ function stripStyles(text) {
93
94
  return text.replace(SGR_PATTERN, "");
94
95
  }
95
96
 
96
- // src/checkers/http.ts
97
- var REASONS = {
98
- ECONNREFUSED: "connection refused",
99
- ECONNRESET: "connection reset",
100
- ENOTFOUND: "host not found",
101
- EAI_AGAIN: "host not found",
102
- EHOSTUNREACH: "host unreachable"
97
+ // src/checkers/diagnosis.ts
98
+ var CONFIG = `${PROBE_DIR}/${CONFIG_FILE}`;
99
+ var CERTIFICATE_CODES = /* @__PURE__ */ new Set([
100
+ "CERT_HAS_EXPIRED",
101
+ "CERT_NOT_YET_VALID",
102
+ "CERT_UNTRUSTED",
103
+ "DEPTH_ZERO_SELF_SIGNED_CERT",
104
+ "SELF_SIGNED_CERT_IN_CHAIN",
105
+ "UNABLE_TO_GET_ISSUER_CERT",
106
+ "UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
107
+ "UNABLE_TO_VERIFY_LEAF_SIGNATURE",
108
+ "ERR_TLS_CERT_ALTNAME_INVALID"
109
+ ]);
110
+ var hostNotFound = (service) => ({
111
+ error: "host not found",
112
+ hint: `"${service.host}" does not resolve: check \`host\` in ${CONFIG}`
113
+ });
114
+ var NETWORK = {
115
+ ECONNREFUSED: (service) => ({
116
+ error: "connection refused",
117
+ hint: `nothing listens on :${service.port}: is the service started?`
118
+ }),
119
+ ECONNRESET: (service) => ({
120
+ error: "connection reset",
121
+ hint: `:${service.port} closed the connection without answering: does it speak plain http?`
122
+ }),
123
+ ENOTFOUND: hostNotFound,
124
+ EAI_AGAIN: hostNotFound,
125
+ EHOSTUNREACH: (service) => ({
126
+ error: "host unreachable",
127
+ hint: `no route to ${service.host}: check the network or the VPN`
128
+ })
103
129
  };
130
+ function diagnoseFailure(error, service, timeoutMs) {
131
+ if (error instanceof Error && error.name === "TimeoutError") {
132
+ return {
133
+ error: `timeout after ${timeoutMs / 1e3}s`,
134
+ hint: `still starting, or stuck? Give it more time: \`timeout\` in ${CONFIG}`
135
+ };
136
+ }
137
+ const cause = error?.cause;
138
+ const code = cause?.code ?? cause?.errors?.[0]?.code;
139
+ if (!code) return { error: error instanceof Error ? error.message : String(error) };
140
+ if (CERTIFICATE_CODES.has(code)) {
141
+ return {
142
+ error: `certificate rejected (${code})`,
143
+ // Services are checked over http: a certificate can only come from a redirect to https.
144
+ hint: "a redirect led to https with a certificate that is not trusted: renew it, or add its CA with NODE_EXTRA_CA_CERTS"
145
+ };
146
+ }
147
+ return NETWORK[code]?.(service) ?? { error: code };
148
+ }
149
+ function describeExpected(expect) {
150
+ if (expect === void 0) return "2xx";
151
+ return Array.isArray(expect) ? expect.join(" or ") : String(expect);
152
+ }
153
+ function diagnoseStatus(service, status) {
154
+ const error = `expected ${describeExpected(service.expect)}, got ${status}`;
155
+ if (status >= 500) return { error, hint: "the service answers with an error: look at its logs" };
156
+ if (status === 404) {
157
+ return { error, hint: `nothing at ${service.path}: check \`path\` in ${CONFIG}` };
158
+ }
159
+ return { error, hint: `the service answers: check \`path\` and \`expect\` in ${CONFIG}` };
160
+ }
161
+
162
+ // src/checkers/http.ts
104
163
  function targetUrl(service) {
105
164
  const bareIpv6 = service.host.includes(":") && !service.host.startsWith("[");
106
165
  const host = bareIpv6 ? `[${service.host}]` : service.host;
107
166
  return `http://${host}:${service.port}${service.path}`;
108
167
  }
168
+ var shellQuoted = (text) => `'${text.replaceAll("'", "'\\''")}'`;
169
+ function curlCommand(service) {
170
+ const follow = expectsRedirect(service.expect) ? "" : " -L";
171
+ const seconds = service.timeout ?? DEFAULT_TIMEOUT_SECONDS;
172
+ return `curl -i${follow} --max-time ${seconds} ${shellQuoted(targetUrl(service))}`;
173
+ }
109
174
  function isExpected(expect, status) {
110
175
  if (expect === void 0) return status >= 200 && status < 300;
111
176
  return Array.isArray(expect) ? expect.includes(status) : expect === status;
@@ -113,19 +178,6 @@ function isExpected(expect, status) {
113
178
  function expectsRedirect(expect) {
114
179
  return [expect ?? []].flat().some((status) => status >= 300 && status < 400);
115
180
  }
116
- function describeExpected(expect) {
117
- if (expect === void 0) return "2xx";
118
- return Array.isArray(expect) ? expect.join(" or ") : String(expect);
119
- }
120
- function failureReason(error, timeoutMs) {
121
- if (error instanceof Error && error.name === "TimeoutError") {
122
- return `timeout after ${timeoutMs / 1e3}s`;
123
- }
124
- const cause = error?.cause;
125
- const code = cause?.code ?? cause?.errors?.[0]?.code;
126
- if (code) return REASONS[code] ?? code;
127
- return error instanceof Error ? error.message : String(error);
128
- }
129
181
  var httpChecker = {
130
182
  type: "http",
131
183
  async check(name, service, { timeoutMs, signal }) {
@@ -144,10 +196,10 @@ var httpChecker = {
144
196
  await response.body?.cancel();
145
197
  const { status } = response;
146
198
  if (isExpected(service.expect, status)) return { ...base, ok: true, status, latencyMs };
147
- const error = `expected ${describeExpected(service.expect)}, got ${status}`;
148
- return { ...base, ok: false, status, latencyMs, error };
199
+ return { ...base, ok: false, status, latencyMs, ...diagnoseStatus(service, status) };
149
200
  } catch (error) {
150
- return { ...base, ok: false, latencyMs: elapsed(), error: failureReason(error, timeoutMs) };
201
+ const diagnosis = diagnoseFailure(error, service, timeoutMs);
202
+ return { ...base, ok: false, latencyMs: elapsed(), ...diagnosis };
151
203
  }
152
204
  }
153
205
  };
@@ -251,7 +303,12 @@ function formatCheckResults(results, terminal) {
251
303
  } else {
252
304
  outcome = `${colors.green(String(result.status))}${GAP}${colors.dim(`${result.latencyMs}ms`)}`;
253
305
  }
254
- return `${mark} ${name}${GAP}${target}${outcome}`;
306
+ const line = `${mark} ${name}${GAP}${target}${outcome}`;
307
+ if (!result.hint) return line;
308
+ const room = columns === void 0 ? void 0 : Math.max(1, columns - GAP.length);
309
+ const hint = room === void 0 ? result.hint : truncate(result.hint, room);
310
+ return `${line}
311
+ ${GAP}${colors.dim(hint)}`;
255
312
  }).join("\n");
256
313
  }
257
314
  function formatServices(config, terminal, skipped = /* @__PURE__ */ new Set()) {
@@ -299,6 +356,7 @@ export {
299
356
  UPDATE_CHECK_TTL_MS,
300
357
  UPDATE_CACHE_FILE,
301
358
  REFRESH_COMMAND,
359
+ COMPLETE_COMMAND,
302
360
  EXIT_DOWN,
303
361
  EXIT_ERROR,
304
362
  ProbeError,
@@ -307,6 +365,8 @@ export {
307
365
  isInteractive,
308
366
  truncate,
309
367
  stripStyles,
368
+ targetUrl,
369
+ curlCommand,
310
370
  runChecks,
311
371
  formatCheckResults,
312
372
  formatServices
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-HHTOVIAQ.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
@@ -316,7 +319,7 @@ async function writeLock(paths, lock) {
316
319
  }
317
320
 
318
321
  // src/version.ts
319
- var CLI_VERSION = true ? "0.4.1" : "0.0.0-dev";
322
+ var CLI_VERSION = true ? "0.6.0" : "0.0.0-dev";
320
323
 
321
324
  // src/makefile/generator.ts
322
325
  import { createHash } from "crypto";
@@ -331,13 +334,15 @@ function renderBlock(serviceNames) {
331
334
  return [
332
335
  BLOCK_START,
333
336
  `PROBE ?= $(shell command -v probe >/dev/null 2>&1 && echo probe || echo "${fallback}")`,
334
- `.PHONY: ${["help", "check", "status", ...targets].join(" ")}`,
337
+ `.PHONY: ${["help", "check", "status", "probe", ...targets].join(" ")}`,
335
338
  "help: ## List available targets",
336
339
  ` ${HELP_RECIPE}`,
337
340
  "check: ## Check that every service responds",
338
341
  " @$(PROBE) check",
339
342
  "status: ## Show probe status",
340
343
  " @$(PROBE) status",
344
+ "probe: ## Open the dashboard",
345
+ " @$(PROBE)",
341
346
  ...serviceNames.flatMap((name) => [
342
347
  `check-${name}: ## Check ${name}`,
343
348
  ` @$(PROBE) check ${name}`
@@ -426,6 +431,11 @@ function secondsToMs(option, value) {
426
431
  }
427
432
  return Math.ceil(seconds2 * 1e3);
428
433
  }
434
+ function namedService(config, name) {
435
+ const service = Object.hasOwn(config.services, name) ? config.services[name] : void 0;
436
+ if (!service) throw new ProbeError("SERVICE_UNKNOWN", `unknown service "${name}". See: probe ls`);
437
+ return service;
438
+ }
429
439
  function rawServices(loaded) {
430
440
  return loaded.raw.services ?? {};
431
441
  }
@@ -466,25 +476,25 @@ var add_default = defineCommand({
466
476
  description: `Seconds to wait for this service (default: ${DEFAULT_TIMEOUT_SECONDS})`
467
477
  }
468
478
  },
469
- async run({ args }) {
479
+ async run({ args: args2 }) {
470
480
  const loaded = await openProject();
471
481
  const services = rawServices(loaded);
472
- if (Object.hasOwn(services, args.name)) {
482
+ if (Object.hasOwn(services, args2.name)) {
473
483
  throw new ProbeError(
474
484
  "SERVICE_EXISTS",
475
- `service "${args.name}" already exists. Remove it first: probe rm ${args.name}`
485
+ `service "${args2.name}" already exists. Remove it first: probe rm ${args2.name}`
476
486
  );
477
487
  }
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);
488
+ const service = { port: parseInteger(String(args2.port), "--port") };
489
+ if (args2.host) service.host = args2.host;
490
+ if (args2.path) service.path = args2.path;
491
+ if (args2.expect) service.expect = parseExpect(String(args2.expect));
492
+ if (args2.timeout !== void 0) {
493
+ secondsToMs("--timeout", String(args2.timeout));
494
+ service.timeout = Number(args2.timeout);
485
495
  }
486
- await saveServices(loaded, { ...services, [args.name]: service });
487
- console.log(`Added ${args.name}. Try: probe check ${args.name}`);
496
+ await saveServices(loaded, { ...services, [args2.name]: service });
497
+ console.log(`Added ${args2.name}. Try: probe check ${args2.name}`);
488
498
  }
489
499
  });
490
500
 
@@ -606,13 +616,13 @@ var check_default = defineCommand2({
606
616
  description: `Seconds to wait for each service (default: its own timeout, else ${DEFAULT_TIMEOUT_SECONDS})`
607
617
  }
608
618
  },
609
- async run({ args }) {
610
- const forceTimeout = args.timeout !== void 0;
611
- const timeoutMs = secondsToMs("--timeout", args.timeout ?? String(DEFAULT_TIMEOUT_SECONDS));
619
+ async run({ args: args2 }) {
620
+ const forceTimeout = args2.timeout !== void 0;
621
+ const timeoutMs = secondsToMs("--timeout", args2.timeout ?? String(DEFAULT_TIMEOUT_SECONDS));
612
622
  const { config, paths } = await openProject();
613
623
  const skipped = await readSkippedServices(paths);
614
- const results = await runChecks(config, args._, { timeoutMs, forceTimeout }, skipped);
615
- if (args.json) {
624
+ const results = await runChecks(config, args2._, { timeoutMs, forceTimeout }, skipped);
625
+ if (args2.json) {
616
626
  console.log(JSON.stringify(results, null, 2));
617
627
  } else if (results.length === 0) {
618
628
  console.log("No services yet. Add one: probe add api -p 3000");
@@ -623,11 +633,190 @@ var check_default = defineCommand2({
623
633
  }
624
634
  });
625
635
 
636
+ // src/commands/completion.ts
637
+ import { defineCommand as defineCommand3 } from "citty";
638
+
639
+ // src/completion/spec.ts
640
+ var SHELLS = ["zsh", "bash", "fish"];
641
+ var COMPLETION_COMMAND = "completion";
642
+ var SERVICE_COMMANDS = ["rm", "check", "wait", "open", "curl"];
643
+ var ROOT_OPTIONS = ["--interval", "--help", "--version"];
644
+ var SERVICES_QUERY = `probe ${COMPLETE_COMMAND} services 2>/dev/null`;
645
+ var HELP_OPTION = "--help";
646
+ function completionSpec(commands2) {
647
+ return Object.entries(commands2).map(([name, command]) => {
648
+ const meta2 = command.meta ?? {};
649
+ const args2 = command.args ?? {};
650
+ const options = Object.entries(args2).filter(([, arg]) => arg.type !== "positional").map(([option]) => `--${option}`);
651
+ return {
652
+ name,
653
+ description: meta2.description ?? "",
654
+ options: [...options, HELP_OPTION],
655
+ words: name === COMPLETION_COMMAND ? [...SHELLS] : [],
656
+ services: SERVICE_COMMANDS.includes(name)
657
+ };
658
+ });
659
+ }
660
+
661
+ // src/completion/bash.ts
662
+ function bashScript(commands2) {
663
+ const cases = (pick) => commands2.filter((command) => pick(command).length > 0).map((command) => ` ${command.name}) words="${pick(command).join(" ")}" ;;`);
664
+ const withServices = commands2.filter((command) => command.services).map(({ name }) => name);
665
+ return [
666
+ '# bash completion for probe. Load it with: eval "$(probe completion bash)"',
667
+ "_probe() {",
668
+ ` local cur="\${COMP_WORDS[COMP_CWORD]}" cmd="" words="" i`,
669
+ " for ((i = 1; i < COMP_CWORD; i++)); do",
670
+ ` case "\${COMP_WORDS[i]}" in`,
671
+ " -*) ;;",
672
+ ` *) cmd="\${COMP_WORDS[i]}"; break ;;`,
673
+ " esac",
674
+ " done",
675
+ ' if [ -z "$cmd" ]; then',
676
+ ' case "$cur" in',
677
+ ` -*) words="${ROOT_OPTIONS.join(" ")}" ;;`,
678
+ ` *) words="${commands2.map(({ name }) => name).join(" ")}" ;;`,
679
+ " esac",
680
+ ` elif [ "\${cur#-}" != "$cur" ]; then`,
681
+ ' case "$cmd" in',
682
+ ...cases((command) => command.options),
683
+ " esac",
684
+ " else",
685
+ ' case "$cmd" in',
686
+ ` ${withServices.join("|")}) words="$(${SERVICES_QUERY})" ;;`,
687
+ ...cases((command) => command.words),
688
+ " esac",
689
+ " fi",
690
+ ' COMPREPLY=($(compgen -W "$words" -- "$cur"))',
691
+ "}",
692
+ "complete -F _probe probe",
693
+ ""
694
+ ].join("\n");
695
+ }
696
+
697
+ // src/completion/fish.ts
698
+ var quoted = (text) => `'${text.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
699
+ var longOption = (option) => `-l ${option.replace(/^--/, "")}`;
700
+ function fishScript(commands2) {
701
+ const inCommand = (names) => `-n '__fish_seen_subcommand_from ${names.join(" ")}'`;
702
+ const withServices = commands2.filter((command) => command.services).map(({ name }) => name);
703
+ return [
704
+ "# fish completion for probe. Load it with: probe completion fish | source",
705
+ "function __probe_services",
706
+ ` ${SERVICES_QUERY}`,
707
+ "end",
708
+ // No file names: probe takes none.
709
+ "complete -c probe -f",
710
+ ...ROOT_OPTIONS.map(
711
+ (option) => `complete -c probe -n __fish_use_subcommand ${longOption(option)}`
712
+ ),
713
+ ...commands2.map(
714
+ ({ name, description }) => `complete -c probe -n __fish_use_subcommand -a ${name} -d ${quoted(description)}`
715
+ ),
716
+ `complete -c probe ${inCommand(withServices)} -a '(__probe_services)'`,
717
+ ...commands2.flatMap((command) => [
718
+ ...command.options.map(
719
+ (option) => `complete -c probe ${inCommand([command.name])} ${longOption(option)}`
720
+ ),
721
+ ...command.words.length > 0 ? [`complete -c probe ${inCommand([command.name])} -a ${quoted(command.words.join(" "))}`] : []
722
+ ]),
723
+ ""
724
+ ].join("\n");
725
+ }
726
+
727
+ // src/completion/zsh.ts
728
+ function described({ name, description }) {
729
+ const text = `${name}:${description.replaceAll(":", "\\:")}`;
730
+ return `'${text.replaceAll("'", "'\\''")}'`;
731
+ }
732
+ function zshScript(commands2) {
733
+ const cases = (pick) => commands2.filter((command) => pick(command).length > 0).map((command) => ` ${command.name}) compadd -- ${pick(command).join(" ")} ;;`);
734
+ const withServices = commands2.filter((command) => command.services).map(({ name }) => name);
735
+ return [
736
+ "#compdef probe",
737
+ '# zsh completion for probe. Load it after compinit with: eval "$(probe completion zsh)"',
738
+ "_probe() {",
739
+ " local -a commands services",
740
+ " commands=(",
741
+ ...commands2.map((command) => ` ${described(command)}`),
742
+ " )",
743
+ " if (( CURRENT == 2 )); then",
744
+ " if [[ $words[CURRENT] == -* ]]; then",
745
+ ` compadd -- ${ROOT_OPTIONS.join(" ")}`,
746
+ " else",
747
+ " _describe 'command' commands",
748
+ " fi",
749
+ " return",
750
+ " fi",
751
+ " if [[ $words[CURRENT] == -* ]]; then",
752
+ " case $words[2] in",
753
+ ...cases((command) => command.options).map((line) => ` ${line}`),
754
+ " esac",
755
+ " return",
756
+ " fi",
757
+ " case $words[2] in",
758
+ ` ${withServices.join("|")})`,
759
+ ` services=(\${(f)"$(${SERVICES_QUERY})"})`,
760
+ " compadd -a services ;;",
761
+ ...cases((command) => command.words),
762
+ " esac",
763
+ "}",
764
+ "if (( $+functions[compdef] )); then compdef _probe probe; fi",
765
+ "# Saved as _probe in $fpath, this file is the function itself: run it.",
766
+ 'if [[ $funcstack[1] == _probe ]]; then _probe "$@"; fi',
767
+ ""
768
+ ].join("\n");
769
+ }
770
+
771
+ // src/commands/completion.ts
772
+ var SCRIPTS = {
773
+ zsh: zshScript,
774
+ bash: bashScript,
775
+ fish: fishScript
776
+ };
777
+ var meta = {
778
+ name: COMPLETION_COMMAND,
779
+ description: `Print the shell completion script (${SHELLS.join(", ")})`
780
+ };
781
+ var args = {
782
+ shell: { type: "positional", description: SHELLS.join(", "), required: true }
783
+ };
784
+ function completion(others) {
785
+ const commands2 = { ...others, [COMPLETION_COMMAND]: { meta, args } };
786
+ return defineCommand3({
787
+ meta,
788
+ args,
789
+ run({ args: { shell } }) {
790
+ const script = Object.hasOwn(SCRIPTS, shell) ? SCRIPTS[shell] : void 0;
791
+ if (!script) {
792
+ throw new ProbeError(
793
+ "USAGE",
794
+ `no completion for the shell "${shell}". Supported: ${SHELLS.join(", ")}.`
795
+ );
796
+ }
797
+ process.stdout.write(script(completionSpec(commands2)));
798
+ }
799
+ });
800
+ }
801
+
802
+ // src/commands/curl.ts
803
+ import { defineCommand as defineCommand4 } from "citty";
804
+ var curl_default = defineCommand4({
805
+ meta: { name: "curl", description: "Print the curl command that requests a service" },
806
+ args: {
807
+ name: { type: "positional", description: "Service name", required: true }
808
+ },
809
+ async run({ args: args2 }) {
810
+ const { config } = await openProject();
811
+ console.log(curlCommand(namedService(config, args2.name)));
812
+ }
813
+ });
814
+
626
815
  // src/commands/init.ts
627
816
  import { existsSync } from "fs";
628
817
  import { mkdir, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
629
818
  import { join as join5 } from "path";
630
- import { defineCommand as defineCommand3 } from "citty";
819
+ import { defineCommand as defineCommand5 } from "citty";
631
820
  var IGNORED = [`${PROBE_DIR}/${LOCAL_FILE}`, `${PROBE_DIR}/${BACKUP_GLOB}`];
632
821
  var INITIAL_CONFIG = `# probe - local services to health-check. Edit by hand or with \`probe add\`.
633
822
  schemaVersion: ${CURRENT_SCHEMA_VERSION}
@@ -643,7 +832,7 @@ async function ignoreLocalFiles(root) {
643
832
  await writeFile5(file, `${current}${separator}${missing.join("\n")}
644
833
  `);
645
834
  }
646
- var init_default = defineCommand3({
835
+ var init_default = defineCommand5({
647
836
  meta: {
648
837
  name: "init",
649
838
  description: "Create .probe/ and the .gitignore entries"
@@ -661,8 +850,8 @@ var init_default = defineCommand3({
661
850
  });
662
851
 
663
852
  // src/commands/ls.ts
664
- import { defineCommand as defineCommand4 } from "citty";
665
- var ls_default = defineCommand4({
853
+ import { defineCommand as defineCommand6 } from "citty";
854
+ var ls_default = defineCommand6({
666
855
  meta: { name: "ls", description: "List services" },
667
856
  async run() {
668
857
  const { config, paths } = await openProject();
@@ -675,8 +864,8 @@ var ls_default = defineCommand4({
675
864
  });
676
865
 
677
866
  // src/commands/migrate.ts
678
- import { defineCommand as defineCommand5 } from "citty";
679
- var migrate_default = defineCommand5({
867
+ import { defineCommand as defineCommand7 } from "citty";
868
+ var migrate_default = defineCommand7({
680
869
  meta: { name: "migrate", description: "Migrate the config to the current schema version" },
681
870
  async run() {
682
871
  const paths = requireProbeDir(process.cwd());
@@ -688,27 +877,65 @@ var migrate_default = defineCommand5({
688
877
  }
689
878
  });
690
879
 
880
+ // src/commands/open.ts
881
+ import { defineCommand as defineCommand8 } from "citty";
882
+
883
+ // src/open-url.ts
884
+ import { spawn as nodeSpawn } from "child_process";
885
+ function openerCommand(platform) {
886
+ return platform === "darwin" ? "open" : "xdg-open";
887
+ }
888
+ function openUrl(url, platform = process.platform, spawn2 = nodeSpawn) {
889
+ return new Promise((resolve2) => {
890
+ const child = spawn2(openerCommand(platform), [url], { detached: true, stdio: "ignore" });
891
+ child.once("error", () => resolve2(false));
892
+ child.once("spawn", () => {
893
+ child.unref();
894
+ resolve2(true);
895
+ });
896
+ });
897
+ }
898
+
899
+ // src/commands/open.ts
900
+ var open_default = defineCommand8({
901
+ meta: { name: "open", description: "Open the URL of a service in the browser" },
902
+ args: {
903
+ name: { type: "positional", description: "Service name", required: true }
904
+ },
905
+ async run({ args: args2 }) {
906
+ const { config } = await openProject();
907
+ const url = targetUrl(namedService(config, args2.name));
908
+ if (await openUrl(url)) {
909
+ console.log(`Opened ${url}`);
910
+ return;
911
+ }
912
+ console.log(url);
913
+ console.error(`probe: ${openerCommand(process.platform)} not found, open the URL yourself.`);
914
+ process.exitCode = EXIT_DOWN;
915
+ }
916
+ });
917
+
691
918
  // src/commands/rm.ts
692
- import { defineCommand as defineCommand6 } from "citty";
693
- var rm_default = defineCommand6({
919
+ import { defineCommand as defineCommand9 } from "citty";
920
+ var rm_default = defineCommand9({
694
921
  meta: { name: "rm", description: "Remove a service" },
695
922
  args: {
696
923
  name: { type: "positional", description: "Service name", required: true }
697
924
  },
698
- async run({ args }) {
925
+ async run({ args: args2 }) {
699
926
  const loaded = await openProject();
700
927
  const services = rawServices(loaded);
701
- if (!Object.hasOwn(services, args.name)) {
702
- throw new ProbeError("SERVICE_UNKNOWN", `unknown service "${args.name}". See: probe ls`);
928
+ if (!Object.hasOwn(services, args2.name)) {
929
+ throw new ProbeError("SERVICE_UNKNOWN", `unknown service "${args2.name}". See: probe ls`);
703
930
  }
704
- const { [args.name]: _removed, ...rest } = services;
931
+ const { [args2.name]: _removed, ...rest } = services;
705
932
  await saveServices(loaded, rest);
706
- console.log(`Removed ${args.name}.`);
933
+ console.log(`Removed ${args2.name}.`);
707
934
  }
708
935
  });
709
936
 
710
937
  // src/commands/status.ts
711
- import { defineCommand as defineCommand7 } from "citty";
938
+ import { defineCommand as defineCommand10 } from "citty";
712
939
 
713
940
  // src/update/notifier.ts
714
941
  import { spawn } from "child_process";
@@ -734,8 +961,8 @@ function writeCache(dir, cache) {
734
961
  writeFileSync(join6(dir, UPDATE_CACHE_FILE), JSON.stringify(cache));
735
962
  }
736
963
  function isNewer(latest, current) {
737
- const parse2 = (version) => (version.split("-")[0] ?? "").split(".").map(Number);
738
- const [a, b] = [parse2(latest), parse2(current)];
964
+ const parse3 = (version) => (version.split("-")[0] ?? "").split(".").map(Number);
965
+ const [a, b] = [parse3(latest), parse3(current)];
739
966
  for (let index = 0; index < 3; index++) {
740
967
  const difference = (a[index] || 0) - (b[index] || 0);
741
968
  if (difference !== 0) return difference > 0;
@@ -797,7 +1024,7 @@ function describeUpdate(colors) {
797
1024
  if (!readCache(cacheDir())?.latest) return colors.dim(`not checked yet (${CLI_VERSION})`);
798
1025
  return colors.green(`up to date (${CLI_VERSION})`);
799
1026
  }
800
- var status_default = defineCommand7({
1027
+ var status_default = defineCommand10({
801
1028
  meta: { name: "status", description: "Show pending migration, Makefile state and updates" },
802
1029
  async run() {
803
1030
  const colors = stdoutColors();
@@ -819,8 +1046,8 @@ var status_default = defineCommand7({
819
1046
 
820
1047
  // src/commands/sync.ts
821
1048
  import { relative as relative2 } from "path";
822
- import { defineCommand as defineCommand8 } from "citty";
823
- var sync_default = defineCommand8({
1049
+ import { defineCommand as defineCommand11 } from "citty";
1050
+ var sync_default = defineCommand11({
824
1051
  meta: {
825
1052
  name: "sync",
826
1053
  description: "Add or regenerate the probe block of the Makefile (optional)"
@@ -836,7 +1063,7 @@ var sync_default = defineCommand8({
836
1063
  });
837
1064
 
838
1065
  // src/commands/wait.ts
839
- import { defineCommand as defineCommand9 } from "citty";
1066
+ import { defineCommand as defineCommand12 } from "citty";
840
1067
 
841
1068
  // src/checkers/poll.ts
842
1069
  import { setTimeout as delay } from "timers/promises";
@@ -885,7 +1112,7 @@ function progressReporter() {
885
1112
  return lines;
886
1113
  };
887
1114
  }
888
- var wait_default = defineCommand9({
1115
+ var wait_default = defineCommand12({
889
1116
  meta: {
890
1117
  name: "wait",
891
1118
  description: "Check again and again until services are up (all of them by default)"
@@ -908,14 +1135,14 @@ var wait_default = defineCommand9({
908
1135
  default: String(DEFAULT_WAIT_MAX_INTERVAL_SECONDS)
909
1136
  }
910
1137
  },
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"]);
1138
+ async run({ args: args2 }) {
1139
+ const deadlineMs = secondsToMs("--timeout", args2.timeout);
1140
+ const intervalMs = secondsToMs("--interval", args2.interval);
1141
+ const maxIntervalMs = secondsToMs("--max-interval", args2["max-interval"]);
915
1142
  if (intervalMs > maxIntervalMs) {
916
1143
  throw new ProbeError(
917
1144
  "USAGE",
918
- `--interval (${args.interval}s) cannot be longer than --max-interval (${args["max-interval"]}s).`
1145
+ `--interval (${args2.interval}s) cannot be longer than --max-interval (${args2["max-interval"]}s).`
919
1146
  );
920
1147
  }
921
1148
  const { config, paths } = await openProject();
@@ -925,7 +1152,7 @@ var wait_default = defineCommand9({
925
1152
  // A service with its own timeout keeps it here, within what is left of the total.
926
1153
  (_passMs, remainingMs) => runChecks(
927
1154
  config,
928
- args._,
1155
+ args2._,
929
1156
  { timeoutMs: DEFAULT_TIMEOUT_SECONDS * 1e3, maxTimeoutMs: remainingMs },
930
1157
  skipped
931
1158
  ),
@@ -939,7 +1166,7 @@ var wait_default = defineCommand9({
939
1166
  }
940
1167
  }
941
1168
  );
942
- if (args.json) {
1169
+ if (args2.json) {
943
1170
  console.log(JSON.stringify(results, null, 2));
944
1171
  } else if (results.length === 0) {
945
1172
  console.log("No services yet. Add one: probe add api -p 3000");
@@ -954,6 +1181,22 @@ var wait_default = defineCommand9({
954
1181
  }
955
1182
  });
956
1183
 
1184
+ // src/completion/services.ts
1185
+ import { readFile as readFile6 } from "fs/promises";
1186
+ import { parse as parse2 } from "yaml";
1187
+ async function completableServices(cwd) {
1188
+ try {
1189
+ const paths = findProbeDir(cwd);
1190
+ if (!paths) return [];
1191
+ const raw = parse2(await readFile6(paths.config, "utf8"));
1192
+ const services = raw?.services;
1193
+ if (typeof services !== "object" || services === null) return [];
1194
+ return Object.keys(services).filter((name) => SERVICE_NAME_PATTERN.test(name));
1195
+ } catch {
1196
+ return [];
1197
+ }
1198
+ }
1199
+
957
1200
  // src/dashboard/saves.ts
958
1201
  function describe(file, error) {
959
1202
  const code = error?.code;
@@ -983,8 +1226,102 @@ function createSaveQueue() {
983
1226
  };
984
1227
  }
985
1228
 
1229
+ // src/suggest.ts
1230
+ var SHORT_WORD = 4;
1231
+ var TYPOS_SHORT = 1;
1232
+ var TYPOS_LONG = 2;
1233
+ function typoDistance(a, b) {
1234
+ const rows = Array.from(
1235
+ { length: a.length + 1 },
1236
+ (_, i) => Array.from({ length: b.length + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0)
1237
+ );
1238
+ const at = (i, j) => rows[i]?.[j] ?? 0;
1239
+ for (let i = 1; i <= a.length; i++) {
1240
+ for (let j = 1; j <= b.length; j++) {
1241
+ const same = a[i - 1] === b[j - 1];
1242
+ let best = Math.min(at(i - 1, j) + 1, at(i, j - 1) + 1, at(i - 1, j - 1) + (same ? 0 : 1));
1243
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
1244
+ best = Math.min(best, at(i - 2, j - 2) + 1);
1245
+ }
1246
+ rows[i][j] = best;
1247
+ }
1248
+ }
1249
+ return at(a.length, b.length);
1250
+ }
1251
+ function closest(word, candidates) {
1252
+ let found;
1253
+ for (const candidate of candidates) {
1254
+ const distance = typoDistance(word.toLowerCase(), candidate);
1255
+ const allowed = candidate.length <= SHORT_WORD ? TYPOS_SHORT : TYPOS_LONG;
1256
+ if (distance > allowed || distance >= candidate.length) continue;
1257
+ if (!found || distance < found.distance) found = { candidate, distance };
1258
+ }
1259
+ return found?.candidate;
1260
+ }
1261
+
1262
+ // src/ui/examples.ts
1263
+ var ROOT = "probe";
1264
+ var GAP = " ";
1265
+ var INDENT = " ";
1266
+ var ESC = "\x1B";
1267
+ var EXAMPLES = {
1268
+ [ROOT]: [
1269
+ { command: "probe init", note: "Start in this project" },
1270
+ { command: "probe add api -p 3000 --path /health", note: "Declare a service" },
1271
+ { command: "probe check", note: "Check every service" },
1272
+ { command: "probe wait && pnpm test", note: "Run the tests once all are up" },
1273
+ { command: "probe", note: "Open the dashboard (on a terminal)" }
1274
+ ],
1275
+ init: [{ command: "probe init", note: "Create .probe/ in this directory" }],
1276
+ add: [
1277
+ { command: "probe add api -p 3000", note: "http://localhost:3000/" },
1278
+ { command: "probe add api -p 3000 --path /health", note: "Check another path" },
1279
+ { command: "probe add admin -p 8080 --expect 200,204", note: "Accept these statuses only" },
1280
+ { command: "probe add db -p 5432 --timeout 10", note: "Give it 10 s to answer" }
1281
+ ],
1282
+ rm: [{ command: "probe rm api", note: "Remove the service named api" }],
1283
+ ls: [{ command: "probe ls", note: "List the services and their URL" }],
1284
+ check: [
1285
+ { command: "probe check", note: "Check every service" },
1286
+ { command: "probe check api db", note: "Check these two only" },
1287
+ { command: "probe check --json", note: "Print the results as JSON" },
1288
+ { command: "probe check --timeout 10", note: "Give each service 10 s" }
1289
+ ],
1290
+ wait: [
1291
+ { command: "probe wait && pnpm test", note: "Run the tests once all are up" },
1292
+ { command: "probe wait api --timeout 120", note: "Wait for api, 2 minutes at most" },
1293
+ { command: "probe wait --interval 1 --max-interval 10", note: "Check less often" }
1294
+ ],
1295
+ open: [{ command: "probe open front", note: "Open the URL of front in the browser" }],
1296
+ curl: [
1297
+ { command: "probe curl api", note: "Print the curl command for api" },
1298
+ { command: "probe curl api | sh", note: "Run it" }
1299
+ ],
1300
+ status: [{ command: "probe status", note: "Version, migration, Makefile block" }],
1301
+ sync: [{ command: "probe sync", note: "Write the probe block of the Makefile" }],
1302
+ migrate: [{ command: "probe migrate", note: "Bring .probe/config.yml up to date" }],
1303
+ completion: [
1304
+ { command: "probe completion zsh", note: 'In ~/.zshrc: eval "$(probe completion zsh)"' },
1305
+ { command: "probe completion bash", note: 'In ~/.bashrc: eval "$(probe completion bash)"' },
1306
+ { command: "probe completion fish | source", note: "In ~/.config/fish/config.fish" }
1307
+ ]
1308
+ };
1309
+ function formatExamples(name, color) {
1310
+ const examples = EXAMPLES[name ?? ROOT] ?? [];
1311
+ if (examples.length === 0) return "";
1312
+ const style = (open, close) => (text) => color ? `${ESC}[${open}m${text}${ESC}[${close}m` : text;
1313
+ const title = style("1;4", "22;24");
1314
+ const cyan = style("36", "39");
1315
+ const width = Math.max(...examples.map((example) => example.command.length));
1316
+ const lines = examples.map(
1317
+ ({ command, note }) => `${INDENT}${cyan(command.padEnd(width))}${GAP}${note}`
1318
+ );
1319
+ return [title("EXAMPLES"), "", ...lines].join("\n");
1320
+ }
1321
+
986
1322
  // src/cli.ts
987
- var main = defineCommand10({
1323
+ 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 };
1324
+ var main = defineCommand13({
988
1325
  meta: {
989
1326
  name: "probe",
990
1327
  version: CLI_VERSION,
@@ -998,16 +1335,21 @@ var main = defineCommand10({
998
1335
  default: String(DASHBOARD_INTERVAL_SECONDS)
999
1336
  }
1000
1337
  },
1001
- 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 }
1338
+ subCommands: { ...commands, completion: completion(commands) }
1002
1339
  });
1003
1340
  async function printUsage(command, parent) {
1004
1341
  const usage = await renderUsage(command, parent);
1005
- console.log(`${stdoutTerminal().color ? usage : stripStyles(usage)}
1006
- `);
1342
+ const { color } = stdoutTerminal();
1343
+ const meta2 = await (typeof command.meta === "function" ? command.meta() : command.meta);
1344
+ const examples = formatExamples(command === main ? void 0 : meta2?.name, color);
1345
+ console.log(`${color ? usage : stripStyles(usage)}
1346
+ ${examples ? `
1347
+ ${examples}
1348
+ ` : ""}`);
1007
1349
  }
1008
1350
  function notifyUpdate(rawArgs) {
1009
1351
  const cliPath = process.argv[1];
1010
- if (updateCheckDisabled() || !cliPath) return;
1352
+ if (updateCheckDisabled() || !cliPath || rawArgs[0] === COMPLETION_COMMAND) return;
1011
1353
  scheduleRefresh(cacheDir(), cliPath);
1012
1354
  if (!detectTerminal(process.stderr, process.env).isTTY || rawArgs.includes("--json") || rawArgs[0] === "status")
1013
1355
  return;
@@ -1043,7 +1385,7 @@ async function openedDashboard(rawArgs) {
1043
1385
  if (!asked) return false;
1044
1386
  throw new ProbeError("USAGE", "No services yet. Add one: probe add api -p 3000");
1045
1387
  }
1046
- const { openDashboard } = await import("./open-7LCTV2ZW.js");
1388
+ const { openDashboard } = await import("./open-T3OQH2O4.js");
1047
1389
  let settings = initialDashboardSettings(askedMs, await readDashboardPreferences(paths));
1048
1390
  const saves = createSaveQueue();
1049
1391
  const local = `${PROBE_DIR}/${LOCAL_FILE}`;
@@ -1067,13 +1409,25 @@ async function openedDashboard(rawArgs) {
1067
1409
  for (const failure of saves.failures()) console.error(`probe: not saved: ${failure}`);
1068
1410
  return true;
1069
1411
  }
1412
+ var UNKNOWN_COMMAND = "E_UNKNOWN_COMMAND";
1413
+ function didYouMean(error, rawArgs) {
1414
+ if (error.code !== UNKNOWN_COMMAND) return "";
1415
+ const typed = rawArgs.find((arg) => !arg.startsWith("-"));
1416
+ const command = typed && closest(typed, Object.keys(main.subCommands ?? {}));
1417
+ return command ? ` Did you mean: probe ${command}?` : "";
1418
+ }
1070
1419
  function flushed(stream) {
1071
1420
  return new Promise((done) => {
1072
1421
  stream.write("", () => done());
1073
1422
  });
1074
1423
  }
1424
+ async function printCompletions(kind) {
1425
+ if (kind !== "services") return;
1426
+ for (const name of await completableServices(process.cwd())) console.log(name);
1427
+ }
1075
1428
  async function run(rawArgs) {
1076
1429
  if (rawArgs[0] === REFRESH_COMMAND) return refreshCache(cacheDir());
1430
+ if (rawArgs[0] === COMPLETE_COMMAND) return printCompletions(rawArgs[1]);
1077
1431
  const wantsHelp = rawArgs.includes("--help") || rawArgs.includes("-h");
1078
1432
  const wantsVersion = rawArgs.length === 1 && ["--version", "-v"].includes(rawArgs[0]);
1079
1433
  if (wantsHelp || wantsVersion) return runMain(main, { rawArgs, showUsage: printUsage });
@@ -1085,7 +1439,8 @@ async function run(rawArgs) {
1085
1439
  console.error(`probe: ${error.message}`);
1086
1440
  } else if (error instanceof Error && error.name === "CLIError") {
1087
1441
  if (rawArgs.length === 0) await printUsage(main);
1088
- console.error(`probe: ${error.message.replace(/\.$/, "")}. See: probe --help`);
1442
+ const message = stripStyles(error.message).replace(/\.$/, "");
1443
+ console.error(`probe: ${message}.${didYouMean(error, rawArgs)} See: probe --help`);
1089
1444
  } else {
1090
1445
  console.error(`probe: ${error instanceof Error ? error.message : String(error)}`);
1091
1446
  }
@@ -7,7 +7,7 @@ import {
7
7
  formatCheckResults,
8
8
  runChecks,
9
9
  truncate
10
- } from "./chunk-HHTOVIAQ.js";
10
+ } from "./chunk-YTTYVEYF.js";
11
11
  import {
12
12
  __commonJS,
13
13
  __export,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ninhache/probe",
3
- "version": "0.4.1",
3
+ "version": "0.6.0",
4
4
  "description": "Health checks for your local dev services, from the CLI or a generated Makefile",
5
5
  "license": "MIT",
6
6
  "author": "Ninhache",