@ninhache/probe 0.1.2 → 0.3.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
@@ -19,7 +19,7 @@ $ echo $?
19
19
 
20
20
  ```sh
21
21
  brew install ninhache/tap/probe # macOS, Linux
22
- npm install -g @ninhache/probe # anywhere with Node >= 20
22
+ npm install -g @ninhache/probe # anywhere with Node >= 22
23
23
  npx @ninhache/probe check # no install
24
24
  ```
25
25
 
@@ -27,28 +27,60 @@ Use the full name with `npx`: `npx probe` is another package.
27
27
 
28
28
  ## Quick start
29
29
 
30
- 1. `probe init` creates `.probe/` and a `probe` block in your `Makefile`.
30
+ 1. `probe init` creates `.probe/`.
31
31
  2. `probe add api -p 3000 --path /health` declares a service.
32
- 3. `probe check` or `make check` checks everything. Exit code is 1 if a service is down.
33
- 4. Commit `.probe/config.yml`, `.probe/lock.yml` and the `Makefile`.
32
+ 3. `probe check` checks everything. Exit code is 1 if a service is down.
33
+ 4. Commit `.probe/config.yml`.
34
+
35
+ Want `make check` too? See [Makefile](#makefile): it is optional.
34
36
 
35
37
  ## Commands
36
38
 
37
39
  | Command | What it does |
38
40
  | --------------------------------------------------------- | ---------------------------------------------------------------------- |
39
- | `probe init` | Create `.probe/`, the Makefile block and the `.gitignore` entries |
41
+ | `probe init` | Create `.probe/` and the `.gitignore` entries |
40
42
  | `probe add <name> -p <port> [--host] [--path] [--expect]` | Add a service |
41
43
  | `probe rm <name>` | Remove a service |
42
44
  | `probe ls` | List services |
43
45
  | `probe check [name...] [--json] [--timeout <seconds>]` | Check all services, or the named ones |
46
+ | `probe wait [name...] [--json] [--timeout <seconds>]` | Check again until every service is up, or give up after the timeout |
44
47
  | `probe status` | CLI version, pending migration, Makefile block state, available update |
45
- | `probe sync` | Regenerate the Makefile block |
48
+ | `probe sync` | Add or regenerate the Makefile block (optional) |
46
49
  | `probe migrate` | Migrate `.probe/config.yml` to the current schema |
47
50
 
48
51
  Exit codes: `0` all up, `1` a service is down, `2` usage or config error.
49
52
 
53
+ `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
+ and every 5 s, for 60 s in total. `--timeout` sets the total, `--interval` the first pause, `--max-interval` the
55
+ longest one. Unlike `check --timeout`, which is the time given to each service, `wait --timeout` covers the whole
56
+ wait; each check inside it still gets 3 s. Progress goes to stderr, the final report to stdout; exit code `1` names the services still down.
57
+
50
58
  `probe` works from any sub-directory: it walks up to the nearest `.probe/`.
51
59
 
60
+ ## Dashboard
61
+
62
+ `probe` alone, on a terminal, opens a live list of your services and checks them every 2 s
63
+ (`probe --interval 5` for another pace). Off a terminal, in CI or in a pipe, nothing changes: use `probe check`.
64
+
65
+ | Key | What it does |
66
+ | --------------- | ------------------------------------------------ |
67
+ | `↑` `↓` `j` `k` | Move |
68
+ | `r` | Check now |
69
+ | `Enter` | Open the panel of the selected service (skip it) |
70
+ | `o` | Options: interval and timeout |
71
+ | `q` | Quit |
72
+
73
+ What you change there is saved in `.probe/local.yml`, for your machine only:
74
+
75
+ ```yaml
76
+ dashboard: { interval: 5, timeout: 3 }
77
+ services:
78
+ db: { skip: true }
79
+ ```
80
+
81
+ A skipped service is left out of `probe check`, `probe wait` and the dashboard, and shown as `skipped`. Name it
82
+ (`probe check db`) and it is checked anyway.
83
+
52
84
  ## Config
53
85
 
54
86
  `.probe/config.yml`:
@@ -74,15 +106,15 @@ is judged as is.
74
106
  Files in `.probe/`:
75
107
 
76
108
  - `config.yml`: services. Commit it.
77
- - `lock.yml`: CLI version and hash of the Makefile block. Commit it.
78
- - `local.yml`: per-machine values. Ignored by git. Unused for now.
109
+ - `lock.yml`: CLI version and hash of the Makefile block. Only there once `probe sync` has run. Commit it.
110
+ - `local.yml`: per-machine values (dashboard settings, skipped services). Ignored by git.
79
111
 
80
112
  Never put secrets in `config.yml` or `lock.yml`.
81
113
 
82
114
  ## Makefile
83
115
 
84
- `probe init` and `probe sync` write a block between two markers. Everything outside the block is yours and is
85
- never touched.
116
+ Optional. `probe sync` writes a block between two markers, in a new `Makefile` or at the end of yours. Everything
117
+ outside the block is yours and is never touched. Without a block, no command creates or changes a `Makefile`.
86
118
 
87
119
  ```make
88
120
  # >>> probe (generated - do not edit, run `probe sync`)
@@ -90,6 +122,9 @@ never touched.
90
122
  # <<< probe
91
123
  ```
92
124
 
125
+ Once the block is there, `probe add` and `probe rm` keep it up to date. Commit the `Makefile` and
126
+ `.probe/lock.yml`.
127
+
93
128
  Targets: `help`, `check`, `status` and one `check-<name>` per service. The block uses `probe` when it is in
94
129
  your `PATH`, else `npx -y @ninhache/probe@latest`. It works with GNU Make 3.81 (the macOS default).
95
130
 
@@ -0,0 +1,48 @@
1
+ import { createRequire as __createRequire } from "node:module";
2
+ const require = __createRequire(import.meta.url);
3
+ var __create = Object.create;
4
+ var __defProp = Object.defineProperty;
5
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __getProtoOf = Object.getPrototypeOf;
8
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
9
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
10
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
11
+ }) : x)(function(x) {
12
+ if (typeof require !== "undefined") return require.apply(this, arguments);
13
+ throw Error('Dynamic require of "' + x + '" is not supported');
14
+ });
15
+ var __commonJS = (cb, mod) => function __require2() {
16
+ try {
17
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
18
+ } catch (e) {
19
+ throw mod = 0, e;
20
+ }
21
+ };
22
+ var __export = (target, all) => {
23
+ for (var name in all)
24
+ __defProp(target, name, { get: all[name], enumerable: true });
25
+ };
26
+ var __copyProps = (to, from, except, desc) => {
27
+ if (from && typeof from === "object" || typeof from === "function") {
28
+ for (let key of __getOwnPropNames(from))
29
+ if (!__hasOwnProp.call(to, key) && key !== except)
30
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
31
+ }
32
+ return to;
33
+ };
34
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
35
+ // If the importer is in node compatibility mode or this is not an ESM
36
+ // file that has been converted to a CommonJS file using a Babel-
37
+ // compatible transform (i.e. "__esModule" has not been set), then set
38
+ // "default" to the CommonJS "module.exports" for node compatibility.
39
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
40
+ mod
41
+ ));
42
+
43
+ export {
44
+ __require,
45
+ __commonJS,
46
+ __export,
47
+ __toESM
48
+ };
@@ -0,0 +1,305 @@
1
+ import { createRequire as __createRequire } from "node:module";
2
+ const require = __createRequire(import.meta.url);
3
+
4
+ // src/constants.ts
5
+ var PACKAGE_NAME = "@ninhache/probe";
6
+ var PROBE_DIR = ".probe";
7
+ var CONFIG_FILE = "config.yml";
8
+ var LOCK_FILE = "lock.yml";
9
+ var LOCAL_FILE = "local.yml";
10
+ var BACKUP_GLOB = "*.bak.yml";
11
+ var LOCAL_DRAFT_FILE = "local.draft.bak.yml";
12
+ var LOCAL_FILE_MODE = 384;
13
+ var DEFAULT_MAKEFILE = "Makefile";
14
+ var DEFAULT_CHECKER_TYPE = "http";
15
+ var DEFAULT_TIMEOUT_SECONDS = 3;
16
+ var DASHBOARD_INTERVAL_SECONDS = 2;
17
+ var DASHBOARD_INTERVAL_CHOICES_SECONDS = [0.5, 1, 2, 5, 10, 30, 60];
18
+ var DASHBOARD_TIMEOUT_CHOICES_SECONDS = [1, 2, 3, 5, 10, 30];
19
+ var MAX_TIMEOUT_SECONDS = 2147483;
20
+ var DEFAULT_WAIT_TIMEOUT_SECONDS = 60;
21
+ var DEFAULT_WAIT_INTERVAL_SECONDS = 0.5;
22
+ var DEFAULT_WAIT_MAX_INTERVAL_SECONDS = 5;
23
+ var WAIT_BACKOFF_FACTOR = 2;
24
+ var WAIT_LAST_PASS_MS = 1e3;
25
+ var REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
26
+ var REGISTRY_TIMEOUT_MS = 5e3;
27
+ var UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
28
+ var UPDATE_CACHE_FILE = "latest.json";
29
+ var REFRESH_COMMAND = "__refresh-update-cache";
30
+ var EXIT_DOWN = 1;
31
+ var EXIT_ERROR = 2;
32
+
33
+ // src/errors.ts
34
+ var ProbeError = class extends Error {
35
+ constructor(code, message) {
36
+ super(message);
37
+ this.code = code;
38
+ this.name = "ProbeError";
39
+ }
40
+ code;
41
+ exitCode = EXIT_ERROR;
42
+ };
43
+
44
+ // src/ui/terminal.ts
45
+ function version(text) {
46
+ const [major, minor] = (text ?? "").split(".").map((part) => Number.parseInt(part, 10));
47
+ return [major || 0, minor || 0];
48
+ }
49
+ function supportsHyperlinks(env) {
50
+ if (env.TMUX || /^(screen|tmux)/.test(env.TERM ?? "")) return false;
51
+ const [major, minor] = version(env.TERM_PROGRAM_VERSION);
52
+ switch (env.TERM_PROGRAM) {
53
+ case "iTerm.app":
54
+ return major > 3 || major === 3 && minor >= 1;
55
+ case "vscode":
56
+ return major > 1 || major === 1 && minor >= 72;
57
+ case "WezTerm":
58
+ case "ghostty":
59
+ case "WarpTerminal":
60
+ case "zed":
61
+ return true;
62
+ }
63
+ if (/^\d{4,}$/.test(env.VTE_VERSION ?? "")) return Number(env.VTE_VERSION) >= 5001;
64
+ return env.TERM === "alacritty" || env.TERM === "xterm-kitty";
65
+ }
66
+ function supportsUnicode(env) {
67
+ if (env.TERM === "linux") return false;
68
+ return /UTF-?8/i.test(env.LC_ALL || env.LC_CTYPE || env.LANG || "");
69
+ }
70
+ function detectTerminal(stream, env) {
71
+ const isTTY = Boolean(stream.isTTY);
72
+ const capable = isTTY && env.TERM !== "dumb";
73
+ const columns = isTTY && stream.columns && stream.columns > 0 ? stream.columns : void 0;
74
+ return {
75
+ isTTY,
76
+ color: capable && !env.NO_COLOR,
77
+ columns,
78
+ hyperlinks: capable && supportsHyperlinks(env),
79
+ unicode: capable && supportsUnicode(env)
80
+ };
81
+ }
82
+ function isInteractive(stdin, stdout, env) {
83
+ return Boolean(stdin.isTTY) && Boolean(stdout.isTTY) && env.TERM !== "dumb" && !env.CI;
84
+ }
85
+ var ELLIPSIS = "\u2026";
86
+ function truncate(text, max) {
87
+ const characters = Array.from(text);
88
+ if (characters.length <= max) return text;
89
+ return max <= 1 ? ELLIPSIS : `${characters.slice(0, max - 1).join("")}${ELLIPSIS}`;
90
+ }
91
+ var SGR_PATTERN = new RegExp(`${String.fromCharCode(27)}\\[[0-9;]*m`, "g");
92
+ function stripStyles(text) {
93
+ return text.replace(SGR_PATTERN, "");
94
+ }
95
+
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"
103
+ };
104
+ function targetUrl(service) {
105
+ const bareIpv6 = service.host.includes(":") && !service.host.startsWith("[");
106
+ const host = bareIpv6 ? `[${service.host}]` : service.host;
107
+ return `http://${host}:${service.port}${service.path}`;
108
+ }
109
+ function isExpected(expect, status) {
110
+ if (expect === void 0) return status >= 200 && status < 300;
111
+ return Array.isArray(expect) ? expect.includes(status) : expect === status;
112
+ }
113
+ function expectsRedirect(expect) {
114
+ return [expect ?? []].flat().some((status) => status >= 300 && status < 400);
115
+ }
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
+ var httpChecker = {
130
+ type: "http",
131
+ async check(name, service, { timeoutMs, signal }) {
132
+ const target = targetUrl(service);
133
+ const base = { name, type: "http", target };
134
+ const started = performance.now();
135
+ const elapsed = () => Math.round(performance.now() - started);
136
+ const timeout = AbortSignal.timeout(timeoutMs);
137
+ try {
138
+ const response = await fetch(target, {
139
+ signal: signal ? AbortSignal.any([timeout, signal]) : timeout,
140
+ // Following a redirect hides its status: an expected 3xx could never match.
141
+ redirect: expectsRedirect(service.expect) ? "manual" : "follow"
142
+ });
143
+ const latencyMs = elapsed();
144
+ await response.body?.cancel();
145
+ const { status } = response;
146
+ 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 };
149
+ } catch (error) {
150
+ return { ...base, ok: false, latencyMs: elapsed(), error: failureReason(error, timeoutMs) };
151
+ }
152
+ }
153
+ };
154
+
155
+ // src/checkers/registry.ts
156
+ var checkers = new Map([httpChecker].map((checker) => [checker.type, checker]));
157
+ function getChecker(type) {
158
+ const checker = checkers.get(type);
159
+ if (!checker) {
160
+ const known = [...checkers.keys()].join(", ");
161
+ throw new ProbeError("CHECKER_UNKNOWN", `unknown service type "${type}". Known: ${known}.`);
162
+ }
163
+ return checker;
164
+ }
165
+
166
+ // src/checkers/run.ts
167
+ async function runChecks(config, names, context, skipped = /* @__PURE__ */ new Set()) {
168
+ const known = Object.keys(config.services);
169
+ const unknown = names.filter((name) => !known.includes(name));
170
+ if (unknown.length > 0) {
171
+ throw new ProbeError(
172
+ "SERVICE_UNKNOWN",
173
+ `unknown service: ${unknown.join(", ")}. Known: ${known.join(", ") || "(none)"}.`
174
+ );
175
+ }
176
+ const selected = names.length > 0 ? [...new Set(names)] : known;
177
+ return Promise.all(
178
+ selected.map((name) => {
179
+ const service = config.services[name];
180
+ const type = service.type ?? DEFAULT_CHECKER_TYPE;
181
+ if (names.length === 0 && skipped.has(name)) {
182
+ const result = {
183
+ name,
184
+ type,
185
+ ok: true,
186
+ skipped: true,
187
+ target: targetUrl(service),
188
+ latencyMs: 0
189
+ };
190
+ return result;
191
+ }
192
+ return getChecker(type).check(name, service, context);
193
+ })
194
+ );
195
+ }
196
+
197
+ // src/ui/colors.ts
198
+ function createColors(enabled) {
199
+ const wrap = (open, close) => (text) => enabled ? `\x1B[${open}m${text}\x1B[${close}m` : text;
200
+ return {
201
+ green: wrap(32, 39),
202
+ red: wrap(31, 39),
203
+ yellow: wrap(33, 39),
204
+ dim: wrap(2, 22),
205
+ bold: wrap(1, 22)
206
+ };
207
+ }
208
+
209
+ // src/ui/report.ts
210
+ var GAP = " ";
211
+ var SKIPPED = "skipped";
212
+ function width(values) {
213
+ return Math.max(0, ...values.map((value) => value.length));
214
+ }
215
+ function nameBudget(nameWidth, columns, reserved) {
216
+ return Math.max(1, Math.min(nameWidth, Math.floor((columns - reserved) / 2)));
217
+ }
218
+ function formatCheckResults(results, terminal) {
219
+ const colors = createColors(terminal.color);
220
+ const nameWidth = width(results.map((result) => result.name));
221
+ const targetWidth = width(results.map((result) => result.target));
222
+ const outcomes = results.map((result) => {
223
+ if (result.skipped) return SKIPPED;
224
+ return result.ok ? `${result.status}${GAP}${result.latencyMs}ms` : result.error ?? "down";
225
+ });
226
+ const fullWidth = 2 + nameWidth + GAP.length + targetWidth + GAP.length + width(outcomes);
227
+ const { columns } = terminal;
228
+ const compact = columns !== void 0 && fullWidth > columns;
229
+ const nameMax = compact ? nameBudget(nameWidth, columns, 2 + GAP.length) : nameWidth;
230
+ const outcomeMax = compact ? Math.max(1, columns - 2 - nameMax - GAP.length) : void 0;
231
+ return results.map((result, index) => {
232
+ const mark = result.skipped ? colors.dim("-") : result.ok ? colors.green("\u2713") : colors.red("\u2717");
233
+ const name = colors.bold(truncate(result.name, nameMax).padEnd(nameMax));
234
+ const target = compact ? "" : `${colors.dim(result.target.padEnd(targetWidth))}${GAP}`;
235
+ const plain = outcomes[index];
236
+ let outcome;
237
+ if (result.skipped) {
238
+ outcome = colors.dim(outcomeMax === void 0 ? plain : truncate(plain, outcomeMax));
239
+ } else if (!result.ok) {
240
+ outcome = colors.red(outcomeMax === void 0 ? plain : truncate(plain, outcomeMax));
241
+ } else if (outcomeMax !== void 0 && plain.length > outcomeMax) {
242
+ outcome = colors.green(truncate(String(result.status), outcomeMax));
243
+ } else {
244
+ outcome = `${colors.green(String(result.status))}${GAP}${colors.dim(`${result.latencyMs}ms`)}`;
245
+ }
246
+ return `${mark} ${name}${GAP}${target}${outcome}`;
247
+ }).join("\n");
248
+ }
249
+ function formatServices(config, terminal, skipped = /* @__PURE__ */ new Set()) {
250
+ const colors = createColors(terminal.color);
251
+ const rows = Object.entries(config.services).map(([name, service]) => ({
252
+ name,
253
+ url: targetUrl(service),
254
+ notes: (service.expect === void 0 ? "" : `${GAP}expect ${service.expect}`) + (skipped.has(name) ? `${GAP}${SKIPPED}` : "")
255
+ }));
256
+ const nameWidth = width(rows.map((row) => row.name));
257
+ const fullWidth = width(rows.map((row) => " ".repeat(nameWidth) + GAP + row.url + row.notes));
258
+ const { columns } = terminal;
259
+ const compact = columns !== void 0 && fullWidth > columns;
260
+ const nameMax = compact ? nameBudget(nameWidth, columns, GAP.length) : nameWidth;
261
+ const urlMax = compact ? Math.max(1, columns - nameMax - GAP.length) : void 0;
262
+ return rows.map((row) => {
263
+ const name = colors.bold(truncate(row.name, nameMax).padEnd(nameMax));
264
+ const url = urlMax === void 0 ? row.url : truncate(row.url, urlMax);
265
+ return `${name}${GAP}${url}${compact || !row.notes ? "" : colors.dim(row.notes)}`;
266
+ }).join("\n");
267
+ }
268
+
269
+ export {
270
+ PACKAGE_NAME,
271
+ PROBE_DIR,
272
+ CONFIG_FILE,
273
+ LOCK_FILE,
274
+ LOCAL_FILE,
275
+ BACKUP_GLOB,
276
+ LOCAL_DRAFT_FILE,
277
+ LOCAL_FILE_MODE,
278
+ DEFAULT_MAKEFILE,
279
+ DEFAULT_TIMEOUT_SECONDS,
280
+ DASHBOARD_INTERVAL_SECONDS,
281
+ DASHBOARD_INTERVAL_CHOICES_SECONDS,
282
+ DASHBOARD_TIMEOUT_CHOICES_SECONDS,
283
+ MAX_TIMEOUT_SECONDS,
284
+ DEFAULT_WAIT_TIMEOUT_SECONDS,
285
+ DEFAULT_WAIT_INTERVAL_SECONDS,
286
+ DEFAULT_WAIT_MAX_INTERVAL_SECONDS,
287
+ WAIT_BACKOFF_FACTOR,
288
+ WAIT_LAST_PASS_MS,
289
+ REGISTRY_LATEST_URL,
290
+ REGISTRY_TIMEOUT_MS,
291
+ UPDATE_CHECK_TTL_MS,
292
+ UPDATE_CACHE_FILE,
293
+ REFRESH_COMMAND,
294
+ EXIT_DOWN,
295
+ EXIT_ERROR,
296
+ ProbeError,
297
+ createColors,
298
+ detectTerminal,
299
+ isInteractive,
300
+ truncate,
301
+ stripStyles,
302
+ runChecks,
303
+ formatCheckResults,
304
+ formatServices
305
+ };