@ninhache/probe 0.1.2 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
 
@@ -41,14 +41,44 @@ Use the full name with `npx`: `npx probe` is another package.
41
41
  | `probe rm <name>` | Remove a service |
42
42
  | `probe ls` | List services |
43
43
  | `probe check [name...] [--json] [--timeout <seconds>]` | Check all services, or the named ones |
44
+ | `probe wait [name...] [--json] [--timeout <seconds>]` | Check again until every service is up, or give up after the timeout |
44
45
  | `probe status` | CLI version, pending migration, Makefile block state, available update |
45
46
  | `probe sync` | Regenerate the Makefile block |
46
47
  | `probe migrate` | Migrate `.probe/config.yml` to the current schema |
47
48
 
48
49
  Exit codes: `0` all up, `1` a service is down, `2` usage or config error.
49
50
 
51
+ `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
52
+ and every 5 s, for 60 s in total. `--timeout` sets the total, `--interval` the first pause, `--max-interval` the
53
+ longest one. Unlike `check --timeout`, which is the time given to each service, `wait --timeout` covers the whole
54
+ 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.
55
+
50
56
  `probe` works from any sub-directory: it walks up to the nearest `.probe/`.
51
57
 
58
+ ## Dashboard
59
+
60
+ `probe` alone, on a terminal, opens a live list of your services and checks them every 2 s
61
+ (`probe --interval 5` for another pace). Off a terminal, in CI or in a pipe, nothing changes: use `probe check`.
62
+
63
+ | Key | What it does |
64
+ | --------------- | ------------------------------------------------ |
65
+ | `↑` `↓` `j` `k` | Move |
66
+ | `r` | Check now |
67
+ | `Enter` | Open the panel of the selected service (skip it) |
68
+ | `o` | Options: interval and timeout |
69
+ | `q` | Quit |
70
+
71
+ What you change there is saved in `.probe/local.yml`, for your machine only:
72
+
73
+ ```yaml
74
+ dashboard: { interval: 5, timeout: 3 }
75
+ services:
76
+ db: { skip: true }
77
+ ```
78
+
79
+ A skipped service is left out of `probe check`, `probe wait` and the dashboard, and shown as `skipped`. Name it
80
+ (`probe check db`) and it is checked anyway.
81
+
52
82
  ## Config
53
83
 
54
84
  `.probe/config.yml`:
@@ -75,7 +105,7 @@ Files in `.probe/`:
75
105
 
76
106
  - `config.yml`: services. Commit it.
77
107
  - `lock.yml`: CLI version and hash of the Makefile block. Commit it.
78
- - `local.yml`: per-machine values. Ignored by git. Unused for now.
108
+ - `local.yml`: per-machine values (dashboard settings, skipped services). Ignored by git.
79
109
 
80
110
  Never put secrets in `config.yml` or `lock.yml`.
81
111
 
@@ -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
+ };