@ninhache/probe 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +131 -2
- package/dist/cli.js +844 -0
- package/package.json +48 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ninhache
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,132 @@
|
|
|
1
|
-
#
|
|
1
|
+
# probe
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Declare your project's local services once. Check they are up with one command, or with `make check`.
|
|
4
|
+
|
|
5
|
+
```console
|
|
6
|
+
$ probe check
|
|
7
|
+
✓ api http://localhost:3000/health 200 12ms
|
|
8
|
+
✗ front http://localhost:5173/ connection refused
|
|
9
|
+
$ echo $?
|
|
10
|
+
1
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
brew install ninhache/tap/probe # macOS, Linux
|
|
17
|
+
npm install -g @ninhache/probe # anywhere with Node >= 20
|
|
18
|
+
npx @ninhache/probe check # no install
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Use the full name with `npx`: `npx probe` is another package.
|
|
22
|
+
|
|
23
|
+
## Quick start
|
|
24
|
+
|
|
25
|
+
1. `probe init` creates `.probe/` and a `probe` block in your `Makefile`.
|
|
26
|
+
2. `probe add api -p 3000 --path /health` declares a service.
|
|
27
|
+
3. `probe check` or `make check` checks everything. Exit code is 1 if a service is down.
|
|
28
|
+
4. Commit `.probe/config.yml`, `.probe/lock.yml` and the `Makefile`.
|
|
29
|
+
|
|
30
|
+
## Commands
|
|
31
|
+
|
|
32
|
+
| Command | What it does |
|
|
33
|
+
| --------------------------------------------------------- | ---------------------------------------------------------------------- |
|
|
34
|
+
| `probe init` | Create `.probe/`, the Makefile block and the `.gitignore` entries |
|
|
35
|
+
| `probe add <name> -p <port> [--host] [--path] [--expect]` | Add a service |
|
|
36
|
+
| `probe rm <name>` | Remove a service |
|
|
37
|
+
| `probe ls` | List services |
|
|
38
|
+
| `probe check [name...] [--json] [--timeout <seconds>]` | Check all services, or the named ones |
|
|
39
|
+
| `probe status` | CLI version, pending migration, Makefile block state, available update |
|
|
40
|
+
| `probe sync` | Regenerate the Makefile block |
|
|
41
|
+
| `probe migrate` | Migrate `.probe/config.yml` to the current schema |
|
|
42
|
+
|
|
43
|
+
Exit codes: `0` all up, `1` a service is down, `2` usage or config error.
|
|
44
|
+
|
|
45
|
+
`probe` works from any sub-directory: it walks up to the nearest `.probe/`.
|
|
46
|
+
|
|
47
|
+
## Config
|
|
48
|
+
|
|
49
|
+
`.probe/config.yml`:
|
|
50
|
+
|
|
51
|
+
```yaml
|
|
52
|
+
schemaVersion: 1
|
|
53
|
+
services:
|
|
54
|
+
api: { port: 3000, path: /health }
|
|
55
|
+
front: { port: 5173 }
|
|
56
|
+
admin: { host: 127.0.0.1, port: 8080, expect: [200, 204] }
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| Key | Default | Meaning |
|
|
60
|
+
| -------- | ----------- | -------------------------------------- |
|
|
61
|
+
| `port` | required | TCP port |
|
|
62
|
+
| `host` | `localhost` | Host name or IP |
|
|
63
|
+
| `path` | `/` | Path requested with `GET` |
|
|
64
|
+
| `expect` | any 2xx | Expected status, or a list of statuses |
|
|
65
|
+
|
|
66
|
+
Files in `.probe/`:
|
|
67
|
+
|
|
68
|
+
- `config.yml`: services. Commit it.
|
|
69
|
+
- `lock.yml`: CLI version and hash of the Makefile block. Commit it.
|
|
70
|
+
- `local.yml`: per-machine values. Ignored by git. Unused for now.
|
|
71
|
+
|
|
72
|
+
Never put secrets in `config.yml` or `lock.yml`.
|
|
73
|
+
|
|
74
|
+
## Makefile
|
|
75
|
+
|
|
76
|
+
`probe init` and `probe sync` write a block between two markers. Everything outside the block is yours and is
|
|
77
|
+
never touched.
|
|
78
|
+
|
|
79
|
+
```make
|
|
80
|
+
# >>> probe (generated - do not edit, run `probe sync`)
|
|
81
|
+
...
|
|
82
|
+
# <<< probe
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Targets: `help`, `check`, `status` and one `check-<name>` per service. The block uses `probe` when it is in
|
|
86
|
+
your `PATH`, else `npx -y @ninhache/probe@latest`. It works with GNU Make 3.81 (the macOS default).
|
|
87
|
+
|
|
88
|
+
Edited the block by hand? `probe status` says so, `probe sync` restores it.
|
|
89
|
+
|
|
90
|
+
## Migrations
|
|
91
|
+
|
|
92
|
+
`config.yml` carries a `schemaVersion`. When a newer `probe` needs a newer schema, the first command you run:
|
|
93
|
+
|
|
94
|
+
1. Backs up the file to `.probe/config.v<n>.bak.yml` (ignored by git).
|
|
95
|
+
2. Migrates it, keeping your comments.
|
|
96
|
+
3. Prints what it did.
|
|
97
|
+
|
|
98
|
+
Commit the migrated `config.yml` so your teammates get it.
|
|
99
|
+
|
|
100
|
+
- `probe status` shows a pending migration without applying it.
|
|
101
|
+
- A config written by a newer `probe` is refused with a message: upgrade `probe`.
|
|
102
|
+
|
|
103
|
+
## Updates
|
|
104
|
+
|
|
105
|
+
`probe` looks for a newer version at most once a day, in the background, and prints one line when there is one.
|
|
106
|
+
It never updates itself. Turn it off with `PROBE_NO_UPDATE_CHECK=1`. It is off when `CI` is set.
|
|
107
|
+
|
|
108
|
+
## Development
|
|
109
|
+
|
|
110
|
+
```sh
|
|
111
|
+
pnpm install
|
|
112
|
+
pnpm test # builds, then runs the tests
|
|
113
|
+
pnpm lint
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Try it on the demo project:
|
|
117
|
+
|
|
118
|
+
```sh
|
|
119
|
+
pnpm build
|
|
120
|
+
cd examples/demo
|
|
121
|
+
node server.mjs &
|
|
122
|
+
make check PROBE="node ../../dist/cli.js"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
Release: bump `version` in `package.json`, merge, then push a `v<version>` tag. The Release workflow publishes
|
|
126
|
+
to npm through trusted publishing: no token, the package trusts this repo's `release.yml` (set on npmjs.com,
|
|
127
|
+
package Settings, Trusted Publisher). It then opens a PR on `ninhache/homebrew-tap` with the new `url` and
|
|
128
|
+
`sha256` (secret `HOMEBREW_TAP_TOKEN`): merge it once the tap's CI is green.
|
|
129
|
+
|
|
130
|
+
## License
|
|
131
|
+
|
|
132
|
+
MIT
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,844 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { defineCommand as defineCommand9, runCommand, runMain, showUsage } from "citty";
|
|
5
|
+
|
|
6
|
+
// src/commands/add.ts
|
|
7
|
+
import { defineCommand } from "citty";
|
|
8
|
+
|
|
9
|
+
// src/constants.ts
|
|
10
|
+
var PACKAGE_NAME = "@ninhache/probe";
|
|
11
|
+
var PROBE_DIR = ".probe";
|
|
12
|
+
var CONFIG_FILE = "config.yml";
|
|
13
|
+
var LOCK_FILE = "lock.yml";
|
|
14
|
+
var LOCAL_FILE = "local.yml";
|
|
15
|
+
var BACKUP_GLOB = "*.bak.yml";
|
|
16
|
+
var DEFAULT_MAKEFILE = "Makefile";
|
|
17
|
+
var DEFAULT_CHECKER_TYPE = "http";
|
|
18
|
+
var DEFAULT_TIMEOUT_SECONDS = 3;
|
|
19
|
+
var REGISTRY_LATEST_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
|
|
20
|
+
var REGISTRY_TIMEOUT_MS = 5e3;
|
|
21
|
+
var UPDATE_CHECK_TTL_MS = 24 * 60 * 60 * 1e3;
|
|
22
|
+
var UPDATE_CACHE_FILE = "latest.json";
|
|
23
|
+
var REFRESH_COMMAND = "__refresh-update-cache";
|
|
24
|
+
var EXIT_DOWN = 1;
|
|
25
|
+
var EXIT_ERROR = 2;
|
|
26
|
+
|
|
27
|
+
// src/errors.ts
|
|
28
|
+
var ProbeError = class extends Error {
|
|
29
|
+
constructor(code, message) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.code = code;
|
|
32
|
+
this.name = "ProbeError";
|
|
33
|
+
}
|
|
34
|
+
code;
|
|
35
|
+
exitCode = EXIT_ERROR;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// src/config/discovery.ts
|
|
39
|
+
import { statSync } from "fs";
|
|
40
|
+
import { dirname, join, resolve } from "path";
|
|
41
|
+
function pathsFor(root) {
|
|
42
|
+
const dir = join(root, PROBE_DIR);
|
|
43
|
+
return {
|
|
44
|
+
root,
|
|
45
|
+
dir,
|
|
46
|
+
config: join(dir, CONFIG_FILE),
|
|
47
|
+
lock: join(dir, LOCK_FILE),
|
|
48
|
+
local: join(dir, LOCAL_FILE)
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
function isDirectory(path) {
|
|
52
|
+
return statSync(path, { throwIfNoEntry: false })?.isDirectory() ?? false;
|
|
53
|
+
}
|
|
54
|
+
function findProbeDir(startDir) {
|
|
55
|
+
let current = resolve(startDir);
|
|
56
|
+
while (true) {
|
|
57
|
+
if (isDirectory(join(current, PROBE_DIR))) return pathsFor(current);
|
|
58
|
+
const parent = dirname(current);
|
|
59
|
+
if (parent === current) return null;
|
|
60
|
+
current = parent;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function requireProbeDir(startDir) {
|
|
64
|
+
const paths = findProbeDir(startDir);
|
|
65
|
+
if (!paths) {
|
|
66
|
+
throw new ProbeError(
|
|
67
|
+
"NO_PROBE_DIR",
|
|
68
|
+
`no ${PROBE_DIR}/ found in ${resolve(startDir)} or any parent directory. Run \`probe init\` at the repo root.`
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
return paths;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/config/loader.ts
|
|
75
|
+
import { copyFile, readFile, writeFile } from "fs/promises";
|
|
76
|
+
import { join as join2, relative } from "path";
|
|
77
|
+
import { parseDocument } from "yaml";
|
|
78
|
+
|
|
79
|
+
// src/config/schema.ts
|
|
80
|
+
import { z } from "zod";
|
|
81
|
+
var CURRENT_SCHEMA_VERSION = 1;
|
|
82
|
+
var SERVICE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
|
|
83
|
+
var statusCode = z.number().int().min(100).max(599);
|
|
84
|
+
var serviceSchemaV1 = z.strictObject({
|
|
85
|
+
host: z.string().min(1).default("localhost"),
|
|
86
|
+
port: z.number().int().min(1).max(65535),
|
|
87
|
+
path: z.string().startsWith("/").default("/"),
|
|
88
|
+
expect: z.union([statusCode, z.array(statusCode).min(1)]).optional()
|
|
89
|
+
});
|
|
90
|
+
var configSchemaV1 = z.strictObject({
|
|
91
|
+
schemaVersion: z.literal(1),
|
|
92
|
+
services: z.record(
|
|
93
|
+
z.string().regex(SERVICE_NAME_PATTERN, "letters, digits, _ and - only"),
|
|
94
|
+
serviceSchemaV1
|
|
95
|
+
).nullish().transform((services) => services ?? {})
|
|
96
|
+
});
|
|
97
|
+
var schemas = {
|
|
98
|
+
1: configSchemaV1
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
// src/config/migrations.ts
|
|
102
|
+
var migrations = {};
|
|
103
|
+
function formatIssues(error) {
|
|
104
|
+
return error.issues.map((issue) => ` - ${issue.path.join(".") || "(root)"}: ${issue.message}`).join("\n");
|
|
105
|
+
}
|
|
106
|
+
function readSchemaVersion(raw) {
|
|
107
|
+
const version = raw?.schemaVersion;
|
|
108
|
+
if (typeof version !== "number" || !Number.isInteger(version) || version < 1) {
|
|
109
|
+
throw new ProbeError(
|
|
110
|
+
"CONFIG_INVALID",
|
|
111
|
+
`config has no valid \`schemaVersion\` (found: ${JSON.stringify(version)}). Expected a positive integer.`
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
return version;
|
|
115
|
+
}
|
|
116
|
+
function planMigration(from, target = CURRENT_SCHEMA_VERSION) {
|
|
117
|
+
if (from > target) {
|
|
118
|
+
throw new ProbeError(
|
|
119
|
+
"CONFIG_TOO_NEW",
|
|
120
|
+
`config uses schema v${from}, but this probe only knows up to v${target}. It was created by a newer version: upgrade probe.`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (from === target) return null;
|
|
124
|
+
const steps = Array.from({ length: target - from }, (_, index) => from + index);
|
|
125
|
+
return { from, to: target, steps };
|
|
126
|
+
}
|
|
127
|
+
function runMigrations(raw, options = {}) {
|
|
128
|
+
const registry = options.registry ?? migrations;
|
|
129
|
+
const schemas2 = options.schemas ?? schemas;
|
|
130
|
+
const plan = planMigration(readSchemaVersion(raw), options.target);
|
|
131
|
+
if (!plan) return { config: raw, plan };
|
|
132
|
+
const source = schemas2[plan.from]?.safeParse(raw);
|
|
133
|
+
if (source && !source.success) {
|
|
134
|
+
throw new ProbeError(
|
|
135
|
+
"CONFIG_INVALID",
|
|
136
|
+
`config is not a valid schema v${plan.from}, cannot migrate it:
|
|
137
|
+
${formatIssues(source.error)}`
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
let config = raw;
|
|
141
|
+
for (const step of plan.steps) {
|
|
142
|
+
const migrate = registry[step];
|
|
143
|
+
if (!migrate) {
|
|
144
|
+
throw new ProbeError(
|
|
145
|
+
"MIGRATION_MISSING",
|
|
146
|
+
`no migration from schema v${step} to v${step + 1}. This is a bug in probe.`
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
config = { ...migrate(structuredClone(config)), schemaVersion: step + 1 };
|
|
150
|
+
}
|
|
151
|
+
return { config, plan };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// src/config/yaml-document.ts
|
|
155
|
+
import { isDeepStrictEqual } from "util";
|
|
156
|
+
import { isCollection, isMap, isScalar, isSeq } from "yaml";
|
|
157
|
+
function isPlainObject(value) {
|
|
158
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
159
|
+
}
|
|
160
|
+
function isPrimitive(value) {
|
|
161
|
+
return value === null || typeof value !== "object";
|
|
162
|
+
}
|
|
163
|
+
function containsMap(node) {
|
|
164
|
+
if (!isCollection(node)) return false;
|
|
165
|
+
return node.items.some((item) => {
|
|
166
|
+
const child = isMap(node) ? item.value : item;
|
|
167
|
+
return isMap(child) || containsMap(child);
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function createNode(doc, value) {
|
|
171
|
+
const node = doc.createNode(value);
|
|
172
|
+
if (isCollection(node) && !containsMap(node)) node.flow = true;
|
|
173
|
+
return node;
|
|
174
|
+
}
|
|
175
|
+
function keyOf(key) {
|
|
176
|
+
return String(isScalar(key) ? key.value : key);
|
|
177
|
+
}
|
|
178
|
+
function reconcileMap(doc, map, value) {
|
|
179
|
+
for (const pair of [...map.items]) {
|
|
180
|
+
if (!Object.hasOwn(value, keyOf(pair.key))) map.delete(pair.key);
|
|
181
|
+
}
|
|
182
|
+
if (map.items.length === 0) map.flow = false;
|
|
183
|
+
for (const [key, next] of Object.entries(value)) {
|
|
184
|
+
const current = map.get(key, true);
|
|
185
|
+
if (isMap(current) && isPlainObject(next)) {
|
|
186
|
+
reconcileMap(doc, current, next);
|
|
187
|
+
} else if (isScalar(current) && isPrimitive(next)) {
|
|
188
|
+
if (current.value !== next) current.value = next;
|
|
189
|
+
} else if (isSeq(current) && isDeepStrictEqual(current.toJSON(), next)) {
|
|
190
|
+
} else {
|
|
191
|
+
map.set(key, createNode(doc, next));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
function reconcileDocument(doc, value) {
|
|
196
|
+
if (isMap(doc.contents) && isPlainObject(value)) {
|
|
197
|
+
reconcileMap(doc, doc.contents, value);
|
|
198
|
+
} else {
|
|
199
|
+
doc.contents = createNode(doc, value);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/config/loader.ts
|
|
204
|
+
async function readDocument(paths) {
|
|
205
|
+
let text;
|
|
206
|
+
try {
|
|
207
|
+
text = await readFile(paths.config, "utf8");
|
|
208
|
+
} catch {
|
|
209
|
+
throw new ProbeError("CONFIG_MISSING", `cannot read ${paths.config}. Run \`probe init\`.`);
|
|
210
|
+
}
|
|
211
|
+
const doc = parseDocument(text);
|
|
212
|
+
const [syntaxError] = doc.errors;
|
|
213
|
+
if (syntaxError) {
|
|
214
|
+
throw new ProbeError(
|
|
215
|
+
"CONFIG_INVALID",
|
|
216
|
+
`${paths.config} is not valid YAML: ${syntaxError.message}`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
const raw = doc.toJS();
|
|
220
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
221
|
+
throw new ProbeError("CONFIG_INVALID", `${paths.config} must be a YAML mapping.`);
|
|
222
|
+
}
|
|
223
|
+
return { doc, raw };
|
|
224
|
+
}
|
|
225
|
+
function validate(raw, paths, options) {
|
|
226
|
+
const target = options.target ?? CURRENT_SCHEMA_VERSION;
|
|
227
|
+
const schema = (options.schemas ?? schemas)[target];
|
|
228
|
+
if (!schema) throw new ProbeError("MIGRATION_MISSING", `no schema for config v${target}.`);
|
|
229
|
+
const result = schema.safeParse(raw);
|
|
230
|
+
if (!result.success) {
|
|
231
|
+
throw new ProbeError(
|
|
232
|
+
"CONFIG_INVALID",
|
|
233
|
+
`${paths.config} is invalid:
|
|
234
|
+
${formatIssues(result.error)}`
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
return result.data;
|
|
238
|
+
}
|
|
239
|
+
function backupPath(paths, version) {
|
|
240
|
+
return join2(paths.dir, `config.v${version}.bak.yml`);
|
|
241
|
+
}
|
|
242
|
+
async function pendingMigration(paths, options = {}) {
|
|
243
|
+
const { raw } = await readDocument(paths);
|
|
244
|
+
return planMigration(readSchemaVersion(raw), options.target);
|
|
245
|
+
}
|
|
246
|
+
async function loadConfig(paths, options = {}) {
|
|
247
|
+
const { doc, raw } = await readDocument(paths);
|
|
248
|
+
const { config: migrated, plan } = runMigrations(raw, options);
|
|
249
|
+
const config = validate(migrated, paths, options);
|
|
250
|
+
if (plan) {
|
|
251
|
+
const backup = backupPath(paths, plan.from);
|
|
252
|
+
await copyFile(paths.config, backup);
|
|
253
|
+
reconcileDocument(doc, migrated);
|
|
254
|
+
await writeFile(paths.config, doc.toString());
|
|
255
|
+
const log = options.log ?? ((message) => console.error(message));
|
|
256
|
+
log(
|
|
257
|
+
`probe: migrated ${relative(paths.root, paths.config)} from schema v${plan.from} to v${plan.to} (backup: ${relative(paths.root, backup)})`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
return { config, raw: migrated, doc, paths, migratedFrom: plan?.from ?? null };
|
|
261
|
+
}
|
|
262
|
+
async function saveConfig(loaded, raw, options = {}) {
|
|
263
|
+
const config = validate(raw, loaded.paths, options);
|
|
264
|
+
reconcileDocument(loaded.doc, raw);
|
|
265
|
+
await writeFile(loaded.paths.config, loaded.doc.toString());
|
|
266
|
+
return { ...loaded, config, raw };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/makefile/sync.ts
|
|
270
|
+
import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
|
|
271
|
+
import { join as join3 } from "path";
|
|
272
|
+
|
|
273
|
+
// src/config/lock.ts
|
|
274
|
+
import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
|
|
275
|
+
import { parse, stringify } from "yaml";
|
|
276
|
+
import { z as z2 } from "zod";
|
|
277
|
+
var lockSchema = z2.object({
|
|
278
|
+
cliVersion: z2.string(),
|
|
279
|
+
makefile: z2.object({ hash: z2.string() })
|
|
280
|
+
});
|
|
281
|
+
var HEADER = "# Generated by probe. Do not edit.\n";
|
|
282
|
+
async function readLock(paths) {
|
|
283
|
+
try {
|
|
284
|
+
const result = lockSchema.safeParse(parse(await readFile2(paths.lock, "utf8")));
|
|
285
|
+
return result.success ? result.data : null;
|
|
286
|
+
} catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
async function writeLock(paths, lock) {
|
|
291
|
+
await writeFile2(paths.lock, HEADER + stringify(lock));
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// src/version.ts
|
|
295
|
+
var CLI_VERSION = true ? "0.1.1" : "0.0.0-dev";
|
|
296
|
+
|
|
297
|
+
// src/makefile/generator.ts
|
|
298
|
+
import { createHash } from "crypto";
|
|
299
|
+
var BLOCK_START = "# >>> probe (generated - do not edit, run `probe sync`)";
|
|
300
|
+
var BLOCK_END = "# <<< probe";
|
|
301
|
+
var START_PATTERN = /^# >>> probe\b.*$/m;
|
|
302
|
+
var END_PATTERN = /^# <<< probe[ \t]*$/m;
|
|
303
|
+
var HELP_RECIPE = `@awk -F':.*## ' '/^[a-zA-Z0-9_-]+:.*## /{printf " %-20s %s\\n", $$1, $$2}' $(MAKEFILE_LIST)`;
|
|
304
|
+
function renderBlock(serviceNames) {
|
|
305
|
+
const targets = serviceNames.map((name) => `check-${name}`);
|
|
306
|
+
const fallback = `npx -y ${PACKAGE_NAME}@latest`;
|
|
307
|
+
return [
|
|
308
|
+
BLOCK_START,
|
|
309
|
+
`PROBE ?= $(shell command -v probe >/dev/null 2>&1 && echo probe || echo "${fallback}")`,
|
|
310
|
+
`.PHONY: ${["help", "check", "status", ...targets].join(" ")}`,
|
|
311
|
+
"help: ## List available targets",
|
|
312
|
+
` ${HELP_RECIPE}`,
|
|
313
|
+
"check: ## Check that every service responds",
|
|
314
|
+
" @$(PROBE) check",
|
|
315
|
+
"status: ## Show probe status",
|
|
316
|
+
" @$(PROBE) status",
|
|
317
|
+
...serviceNames.flatMap((name) => [
|
|
318
|
+
`check-${name}: ## Check ${name}`,
|
|
319
|
+
` @$(PROBE) check ${name}`
|
|
320
|
+
]),
|
|
321
|
+
BLOCK_END
|
|
322
|
+
].join("\n");
|
|
323
|
+
}
|
|
324
|
+
function hashBlock(block) {
|
|
325
|
+
return createHash("sha256").update(block).digest("hex");
|
|
326
|
+
}
|
|
327
|
+
function locate(makefile) {
|
|
328
|
+
const start = START_PATTERN.exec(makefile);
|
|
329
|
+
if (!start) return null;
|
|
330
|
+
const end = END_PATTERN.exec(makefile.slice(start.index));
|
|
331
|
+
if (!end) {
|
|
332
|
+
throw new ProbeError(
|
|
333
|
+
"MAKEFILE_BLOCK_CORRUPT",
|
|
334
|
+
`the Makefile has a probe start marker but no "${BLOCK_END}" line. Restore the marker or delete the block, then run \`probe sync\`.`
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
return { start: start.index, end: start.index + end.index + end[0].length };
|
|
338
|
+
}
|
|
339
|
+
function extractBlock(makefile) {
|
|
340
|
+
const found = locate(makefile);
|
|
341
|
+
return found ? makefile.slice(found.start, found.end) : null;
|
|
342
|
+
}
|
|
343
|
+
function replaceBlock(makefile, block) {
|
|
344
|
+
if (!makefile?.trim()) return `${block}
|
|
345
|
+
`;
|
|
346
|
+
const found = locate(makefile);
|
|
347
|
+
if (found) return makefile.slice(0, found.start) + block + makefile.slice(found.end);
|
|
348
|
+
return `${makefile.replace(/\n*$/, "\n\n")}${block}
|
|
349
|
+
`;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// src/makefile/sync.ts
|
|
353
|
+
function makefilePath(paths) {
|
|
354
|
+
return join3(paths.root, DEFAULT_MAKEFILE);
|
|
355
|
+
}
|
|
356
|
+
async function readMakefile(paths) {
|
|
357
|
+
try {
|
|
358
|
+
return await readFile3(makefilePath(paths), "utf8");
|
|
359
|
+
} catch (error) {
|
|
360
|
+
if (error.code === "ENOENT") return null;
|
|
361
|
+
throw error;
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
async function syncMakefile(paths, config, cliVersion = CLI_VERSION) {
|
|
365
|
+
const block = renderBlock(Object.keys(config.services));
|
|
366
|
+
const hash = hashBlock(block);
|
|
367
|
+
const current = await readMakefile(paths);
|
|
368
|
+
const next = replaceBlock(current, block);
|
|
369
|
+
const changed = next !== current;
|
|
370
|
+
if (changed) await writeFile3(makefilePath(paths), next);
|
|
371
|
+
if ((await readLock(paths))?.makefile.hash !== hash) {
|
|
372
|
+
await writeLock(paths, { cliVersion, makefile: { hash } });
|
|
373
|
+
}
|
|
374
|
+
return { created: current === null, changed, hash };
|
|
375
|
+
}
|
|
376
|
+
async function blockState(paths, config) {
|
|
377
|
+
const makefile = await readMakefile(paths);
|
|
378
|
+
const block = makefile === null ? null : extractBlock(makefile);
|
|
379
|
+
if (block === null) return "missing";
|
|
380
|
+
if (block === renderBlock(Object.keys(config.services))) return "in-sync";
|
|
381
|
+
const lock = await readLock(paths);
|
|
382
|
+
return lock?.makefile.hash === hashBlock(block) ? "outdated" : "modified";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// src/ui/colors.ts
|
|
386
|
+
function colorEnabled(stream, env) {
|
|
387
|
+
return Boolean(stream.isTTY) && !env.NO_COLOR;
|
|
388
|
+
}
|
|
389
|
+
function createColors(enabled) {
|
|
390
|
+
const wrap = (open, close) => (text) => enabled ? `\x1B[${open}m${text}\x1B[${close}m` : text;
|
|
391
|
+
return {
|
|
392
|
+
green: wrap(32, 39),
|
|
393
|
+
red: wrap(31, 39),
|
|
394
|
+
yellow: wrap(33, 39),
|
|
395
|
+
dim: wrap(2, 22),
|
|
396
|
+
bold: wrap(1, 22)
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// src/commands/shared.ts
|
|
401
|
+
function openProject() {
|
|
402
|
+
return loadConfig(requireProbeDir(process.cwd()));
|
|
403
|
+
}
|
|
404
|
+
function stdoutColors() {
|
|
405
|
+
return createColors(colorEnabled(process.stdout, process.env));
|
|
406
|
+
}
|
|
407
|
+
function rawServices(loaded) {
|
|
408
|
+
return loaded.raw.services ?? {};
|
|
409
|
+
}
|
|
410
|
+
async function saveServices(loaded, services) {
|
|
411
|
+
const saved = await saveConfig(loaded, { ...loaded.raw, services });
|
|
412
|
+
await syncMakefile(saved.paths, saved.config);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// src/commands/add.ts
|
|
416
|
+
function parseInteger(value, flag) {
|
|
417
|
+
if (!/^\d+$/.test(value)) {
|
|
418
|
+
throw new ProbeError("USAGE", `${flag} expects a number, got "${value}".`);
|
|
419
|
+
}
|
|
420
|
+
return Number(value);
|
|
421
|
+
}
|
|
422
|
+
function parseExpect(value) {
|
|
423
|
+
const codes = value.split(",").map((code) => parseInteger(code.trim(), "--expect"));
|
|
424
|
+
return codes.length === 1 ? codes[0] : codes;
|
|
425
|
+
}
|
|
426
|
+
var add_default = defineCommand({
|
|
427
|
+
meta: { name: "add", description: "Add a service" },
|
|
428
|
+
args: {
|
|
429
|
+
name: { type: "positional", description: "Service name", required: true },
|
|
430
|
+
port: { type: "string", alias: "p", description: "Port", required: true },
|
|
431
|
+
host: { type: "string", description: "Host (default: localhost)" },
|
|
432
|
+
path: { type: "string", description: "Path to request (default: /)" },
|
|
433
|
+
expect: { type: "string", description: "Expected status, e.g. 200 or 200,204 (default: 2xx)" }
|
|
434
|
+
},
|
|
435
|
+
async run({ args }) {
|
|
436
|
+
const loaded = await openProject();
|
|
437
|
+
const services = rawServices(loaded);
|
|
438
|
+
if (Object.hasOwn(services, args.name)) {
|
|
439
|
+
throw new ProbeError(
|
|
440
|
+
"SERVICE_EXISTS",
|
|
441
|
+
`service "${args.name}" already exists. Remove it first: probe rm ${args.name}`
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
const service = { port: parseInteger(String(args.port), "--port") };
|
|
445
|
+
if (args.host) service.host = args.host;
|
|
446
|
+
if (args.path) service.path = args.path;
|
|
447
|
+
if (args.expect) service.expect = parseExpect(String(args.expect));
|
|
448
|
+
await saveServices(loaded, { ...services, [args.name]: service });
|
|
449
|
+
console.log(`Added ${args.name}. Try: probe check ${args.name}`);
|
|
450
|
+
}
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
// src/commands/check.ts
|
|
454
|
+
import { defineCommand as defineCommand2 } from "citty";
|
|
455
|
+
|
|
456
|
+
// src/checkers/http.ts
|
|
457
|
+
var REASONS = {
|
|
458
|
+
ECONNREFUSED: "connection refused",
|
|
459
|
+
ECONNRESET: "connection reset",
|
|
460
|
+
ENOTFOUND: "host not found",
|
|
461
|
+
EAI_AGAIN: "host not found",
|
|
462
|
+
EHOSTUNREACH: "host unreachable"
|
|
463
|
+
};
|
|
464
|
+
function targetUrl(service) {
|
|
465
|
+
const bareIpv6 = service.host.includes(":") && !service.host.startsWith("[");
|
|
466
|
+
const host = bareIpv6 ? `[${service.host}]` : service.host;
|
|
467
|
+
return `http://${host}:${service.port}${service.path}`;
|
|
468
|
+
}
|
|
469
|
+
function isExpected(expect, status) {
|
|
470
|
+
if (expect === void 0) return status >= 200 && status < 300;
|
|
471
|
+
return Array.isArray(expect) ? expect.includes(status) : expect === status;
|
|
472
|
+
}
|
|
473
|
+
function describeExpected(expect) {
|
|
474
|
+
if (expect === void 0) return "2xx";
|
|
475
|
+
return Array.isArray(expect) ? expect.join(" or ") : String(expect);
|
|
476
|
+
}
|
|
477
|
+
function failureReason(error, timeoutMs) {
|
|
478
|
+
if (error instanceof Error && error.name === "TimeoutError") {
|
|
479
|
+
return `timeout after ${timeoutMs / 1e3}s`;
|
|
480
|
+
}
|
|
481
|
+
const cause = error?.cause;
|
|
482
|
+
const code = cause?.code ?? cause?.errors?.[0]?.code;
|
|
483
|
+
if (code) return REASONS[code] ?? code;
|
|
484
|
+
return error instanceof Error ? error.message : String(error);
|
|
485
|
+
}
|
|
486
|
+
var httpChecker = {
|
|
487
|
+
type: "http",
|
|
488
|
+
async check(name, service, { timeoutMs }) {
|
|
489
|
+
const target = targetUrl(service);
|
|
490
|
+
const base = { name, type: "http", target };
|
|
491
|
+
const started = performance.now();
|
|
492
|
+
const elapsed = () => Math.round(performance.now() - started);
|
|
493
|
+
try {
|
|
494
|
+
const response = await fetch(target, { signal: AbortSignal.timeout(timeoutMs) });
|
|
495
|
+
const latencyMs = elapsed();
|
|
496
|
+
await response.body?.cancel();
|
|
497
|
+
const { status } = response;
|
|
498
|
+
if (isExpected(service.expect, status)) return { ...base, ok: true, status, latencyMs };
|
|
499
|
+
const error = `expected ${describeExpected(service.expect)}, got ${status}`;
|
|
500
|
+
return { ...base, ok: false, status, latencyMs, error };
|
|
501
|
+
} catch (error) {
|
|
502
|
+
return { ...base, ok: false, latencyMs: elapsed(), error: failureReason(error, timeoutMs) };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
// src/checkers/registry.ts
|
|
508
|
+
var checkers = new Map([httpChecker].map((checker) => [checker.type, checker]));
|
|
509
|
+
function getChecker(type) {
|
|
510
|
+
const checker = checkers.get(type);
|
|
511
|
+
if (!checker) {
|
|
512
|
+
const known = [...checkers.keys()].join(", ");
|
|
513
|
+
throw new ProbeError("CHECKER_UNKNOWN", `unknown service type "${type}". Known: ${known}.`);
|
|
514
|
+
}
|
|
515
|
+
return checker;
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/checkers/run.ts
|
|
519
|
+
async function runChecks(config, names, context) {
|
|
520
|
+
const known = Object.keys(config.services);
|
|
521
|
+
const unknown = names.filter((name) => !known.includes(name));
|
|
522
|
+
if (unknown.length > 0) {
|
|
523
|
+
throw new ProbeError(
|
|
524
|
+
"SERVICE_UNKNOWN",
|
|
525
|
+
`unknown service: ${unknown.join(", ")}. Known: ${known.join(", ") || "(none)"}.`
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
const selected = names.length > 0 ? [...new Set(names)] : known;
|
|
529
|
+
return Promise.all(
|
|
530
|
+
selected.map((name) => {
|
|
531
|
+
const service = config.services[name];
|
|
532
|
+
const type = service.type ?? DEFAULT_CHECKER_TYPE;
|
|
533
|
+
return getChecker(type).check(name, service, context);
|
|
534
|
+
})
|
|
535
|
+
);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// src/ui/report.ts
|
|
539
|
+
function width(values) {
|
|
540
|
+
return Math.max(0, ...values.map((value) => value.length));
|
|
541
|
+
}
|
|
542
|
+
function formatCheckResults(results, colors) {
|
|
543
|
+
const nameWidth = width(results.map((result) => result.name));
|
|
544
|
+
const targetWidth = width(results.map((result) => result.target));
|
|
545
|
+
return results.map((result) => {
|
|
546
|
+
const mark = result.ok ? colors.green("\u2713") : colors.red("\u2717");
|
|
547
|
+
const name = colors.bold(result.name.padEnd(nameWidth));
|
|
548
|
+
const target = colors.dim(result.target.padEnd(targetWidth));
|
|
549
|
+
const outcome = result.ok ? `${colors.green(String(result.status))} ${colors.dim(`${result.latencyMs}ms`)}` : colors.red(result.error ?? "down");
|
|
550
|
+
return `${mark} ${name} ${target} ${outcome}`;
|
|
551
|
+
}).join("\n");
|
|
552
|
+
}
|
|
553
|
+
function formatServices(config, colors) {
|
|
554
|
+
const entries = Object.entries(config.services);
|
|
555
|
+
const nameWidth = width(entries.map(([name]) => name));
|
|
556
|
+
return entries.map(([name, service]) => {
|
|
557
|
+
const expect = service.expect === void 0 ? "" : colors.dim(` expect ${service.expect}`);
|
|
558
|
+
return `${colors.bold(name.padEnd(nameWidth))} ${targetUrl(service)}${expect}`;
|
|
559
|
+
}).join("\n");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// src/commands/check.ts
|
|
563
|
+
var MAX_TIMEOUT_SECONDS = 2147483;
|
|
564
|
+
var check_default = defineCommand2({
|
|
565
|
+
meta: { name: "check", description: "Check that services respond (all of them by default)" },
|
|
566
|
+
args: {
|
|
567
|
+
json: { type: "boolean", description: "Print results as JSON" },
|
|
568
|
+
timeout: {
|
|
569
|
+
type: "string",
|
|
570
|
+
description: "Seconds to wait for each service",
|
|
571
|
+
default: String(DEFAULT_TIMEOUT_SECONDS)
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
async run({ args }) {
|
|
575
|
+
const seconds = Number(args.timeout);
|
|
576
|
+
if (!Number.isFinite(seconds) || seconds <= 0 || seconds > MAX_TIMEOUT_SECONDS) {
|
|
577
|
+
throw new ProbeError(
|
|
578
|
+
"USAGE",
|
|
579
|
+
`--timeout expects seconds between 0 and ${MAX_TIMEOUT_SECONDS}, got "${args.timeout}".`
|
|
580
|
+
);
|
|
581
|
+
}
|
|
582
|
+
const { config } = await openProject();
|
|
583
|
+
const results = await runChecks(config, args._, { timeoutMs: Math.ceil(seconds * 1e3) });
|
|
584
|
+
if (args.json) {
|
|
585
|
+
console.log(JSON.stringify(results, null, 2));
|
|
586
|
+
} else if (results.length === 0) {
|
|
587
|
+
console.log("No services yet. Add one: probe add api -p 3000");
|
|
588
|
+
} else {
|
|
589
|
+
console.log(formatCheckResults(results, stdoutColors()));
|
|
590
|
+
}
|
|
591
|
+
if (results.some((result) => !result.ok)) process.exitCode = EXIT_DOWN;
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
// src/commands/init.ts
|
|
596
|
+
import { existsSync } from "fs";
|
|
597
|
+
import { mkdir, readFile as readFile4, writeFile as writeFile4 } from "fs/promises";
|
|
598
|
+
import { join as join4 } from "path";
|
|
599
|
+
import { defineCommand as defineCommand3 } from "citty";
|
|
600
|
+
var IGNORED = [`${PROBE_DIR}/${LOCAL_FILE}`, `${PROBE_DIR}/${BACKUP_GLOB}`];
|
|
601
|
+
var INITIAL_CONFIG = `# probe - local services to health-check. Edit by hand or with \`probe add\`.
|
|
602
|
+
schemaVersion: ${CURRENT_SCHEMA_VERSION}
|
|
603
|
+
services: {}
|
|
604
|
+
`;
|
|
605
|
+
async function ignoreLocalFiles(root) {
|
|
606
|
+
const file = join4(root, ".gitignore");
|
|
607
|
+
const current = existsSync(file) ? await readFile4(file, "utf8") : "";
|
|
608
|
+
const lines = current.split("\n").map((line) => line.trim());
|
|
609
|
+
const missing = IGNORED.filter((entry) => !lines.includes(entry));
|
|
610
|
+
if (missing.length === 0) return;
|
|
611
|
+
const separator = current === "" || current.endsWith("\n") ? "" : "\n";
|
|
612
|
+
await writeFile4(file, `${current}${separator}${missing.join("\n")}
|
|
613
|
+
`);
|
|
614
|
+
}
|
|
615
|
+
var init_default = defineCommand3({
|
|
616
|
+
meta: {
|
|
617
|
+
name: "init",
|
|
618
|
+
description: "Create .probe/, the .gitignore entries and the Makefile block"
|
|
619
|
+
},
|
|
620
|
+
async run() {
|
|
621
|
+
const paths = pathsFor(process.cwd());
|
|
622
|
+
if (existsSync(paths.dir)) {
|
|
623
|
+
throw new ProbeError("ALREADY_INITIALIZED", `${PROBE_DIR}/ already exists here.`);
|
|
624
|
+
}
|
|
625
|
+
await mkdir(paths.dir);
|
|
626
|
+
await writeFile4(paths.config, INITIAL_CONFIG);
|
|
627
|
+
await ignoreLocalFiles(paths.root);
|
|
628
|
+
const { config } = await loadConfig(paths);
|
|
629
|
+
await syncMakefile(paths, config);
|
|
630
|
+
console.log(`Created ${PROBE_DIR}/ and the Makefile block. Next: probe add api -p 3000`);
|
|
631
|
+
}
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
// src/commands/ls.ts
|
|
635
|
+
import { defineCommand as defineCommand4 } from "citty";
|
|
636
|
+
var ls_default = defineCommand4({
|
|
637
|
+
meta: { name: "ls", description: "List services" },
|
|
638
|
+
async run() {
|
|
639
|
+
const { config } = await openProject();
|
|
640
|
+
if (Object.keys(config.services).length === 0) {
|
|
641
|
+
console.log("No services yet. Add one: probe add api -p 3000");
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
console.log(formatServices(config, stdoutColors()));
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
// src/commands/migrate.ts
|
|
649
|
+
import { defineCommand as defineCommand5 } from "citty";
|
|
650
|
+
var migrate_default = defineCommand5({
|
|
651
|
+
meta: { name: "migrate", description: "Migrate the config to the current schema version" },
|
|
652
|
+
async run() {
|
|
653
|
+
const paths = requireProbeDir(process.cwd());
|
|
654
|
+
if (!await pendingMigration(paths)) {
|
|
655
|
+
console.log(`Config is already at schema v${CURRENT_SCHEMA_VERSION}. Nothing to do.`);
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
await loadConfig(paths);
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// src/commands/rm.ts
|
|
663
|
+
import { defineCommand as defineCommand6 } from "citty";
|
|
664
|
+
var rm_default = defineCommand6({
|
|
665
|
+
meta: { name: "rm", description: "Remove a service" },
|
|
666
|
+
args: {
|
|
667
|
+
name: { type: "positional", description: "Service name", required: true }
|
|
668
|
+
},
|
|
669
|
+
async run({ args }) {
|
|
670
|
+
const loaded = await openProject();
|
|
671
|
+
const services = rawServices(loaded);
|
|
672
|
+
if (!Object.hasOwn(services, args.name)) {
|
|
673
|
+
throw new ProbeError("SERVICE_UNKNOWN", `unknown service "${args.name}". See: probe ls`);
|
|
674
|
+
}
|
|
675
|
+
const { [args.name]: _removed, ...rest } = services;
|
|
676
|
+
await saveServices(loaded, rest);
|
|
677
|
+
console.log(`Removed ${args.name}.`);
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// src/commands/status.ts
|
|
682
|
+
import { defineCommand as defineCommand7 } from "citty";
|
|
683
|
+
|
|
684
|
+
// src/update/notifier.ts
|
|
685
|
+
import { spawn } from "child_process";
|
|
686
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
687
|
+
import { homedir } from "os";
|
|
688
|
+
import { join as join5 } from "path";
|
|
689
|
+
function cacheDir(env = process.env) {
|
|
690
|
+
return join5(env.XDG_CACHE_HOME || join5(homedir(), ".cache"), "probe");
|
|
691
|
+
}
|
|
692
|
+
function updateCheckDisabled(env = process.env) {
|
|
693
|
+
return env.PROBE_NO_UPDATE_CHECK === "1" || Boolean(env.CI);
|
|
694
|
+
}
|
|
695
|
+
function readCache(dir) {
|
|
696
|
+
try {
|
|
697
|
+
const cache = JSON.parse(readFileSync(join5(dir, UPDATE_CACHE_FILE), "utf8"));
|
|
698
|
+
return typeof cache.checkedAt === "number" ? cache : null;
|
|
699
|
+
} catch {
|
|
700
|
+
return null;
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
function writeCache(dir, cache) {
|
|
704
|
+
mkdirSync(dir, { recursive: true });
|
|
705
|
+
writeFileSync(join5(dir, UPDATE_CACHE_FILE), JSON.stringify(cache));
|
|
706
|
+
}
|
|
707
|
+
function isNewer(latest, current) {
|
|
708
|
+
const parse2 = (version) => (version.split("-")[0] ?? "").split(".").map(Number);
|
|
709
|
+
const [a, b] = [parse2(latest), parse2(current)];
|
|
710
|
+
for (let index = 0; index < 3; index++) {
|
|
711
|
+
const difference = (a[index] || 0) - (b[index] || 0);
|
|
712
|
+
if (difference !== 0) return difference > 0;
|
|
713
|
+
}
|
|
714
|
+
return false;
|
|
715
|
+
}
|
|
716
|
+
function updateNotice(current, dir) {
|
|
717
|
+
const latest = readCache(dir)?.latest;
|
|
718
|
+
if (!latest || !isNewer(latest, current)) return null;
|
|
719
|
+
return `probe ${latest} available (you have ${current}) \u2192 \`brew upgrade probe\` or \`npm i -g ${PACKAGE_NAME}\``;
|
|
720
|
+
}
|
|
721
|
+
function scheduleRefresh(dir, cliPath, now = Date.now()) {
|
|
722
|
+
try {
|
|
723
|
+
const cache = readCache(dir);
|
|
724
|
+
if (cache && now - cache.checkedAt < UPDATE_CHECK_TTL_MS) return false;
|
|
725
|
+
writeCache(dir, { latest: cache?.latest ?? null, checkedAt: now });
|
|
726
|
+
spawn(process.execPath, [cliPath, REFRESH_COMMAND], {
|
|
727
|
+
detached: true,
|
|
728
|
+
stdio: "ignore"
|
|
729
|
+
}).unref();
|
|
730
|
+
return true;
|
|
731
|
+
} catch {
|
|
732
|
+
return false;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
async function fetchLatestVersion() {
|
|
736
|
+
const response = await fetch(REGISTRY_LATEST_URL, {
|
|
737
|
+
signal: AbortSignal.timeout(REGISTRY_TIMEOUT_MS)
|
|
738
|
+
});
|
|
739
|
+
if (!response.ok) throw new Error(`registry answered ${response.status}`);
|
|
740
|
+
const { version } = await response.json();
|
|
741
|
+
if (typeof version !== "string") throw new Error("registry answer has no version");
|
|
742
|
+
return version;
|
|
743
|
+
}
|
|
744
|
+
async function refreshCache(dir, fetchLatest = fetchLatestVersion, now = Date.now()) {
|
|
745
|
+
try {
|
|
746
|
+
writeCache(dir, { latest: await fetchLatest(), checkedAt: now });
|
|
747
|
+
} catch {
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// src/commands/status.ts
|
|
752
|
+
function describeBlock(state, colors) {
|
|
753
|
+
switch (state) {
|
|
754
|
+
case "in-sync":
|
|
755
|
+
return colors.green("in sync");
|
|
756
|
+
case "modified":
|
|
757
|
+
return colors.yellow("block edited by hand \u2192 run `probe sync` to restore it");
|
|
758
|
+
case "outdated":
|
|
759
|
+
return colors.yellow("out of date \u2192 run `probe sync`");
|
|
760
|
+
case "missing":
|
|
761
|
+
return colors.yellow("no probe block \u2192 run `probe sync`");
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
function describeUpdate(colors) {
|
|
765
|
+
if (updateCheckDisabled()) return colors.dim(`check disabled (${CLI_VERSION})`);
|
|
766
|
+
const notice = updateNotice(CLI_VERSION, cacheDir());
|
|
767
|
+
if (notice) return colors.yellow(notice);
|
|
768
|
+
if (!readCache(cacheDir())?.latest) return colors.dim(`not checked yet (${CLI_VERSION})`);
|
|
769
|
+
return colors.green(`up to date (${CLI_VERSION})`);
|
|
770
|
+
}
|
|
771
|
+
var status_default = defineCommand7({
|
|
772
|
+
meta: { name: "status", description: "Show pending migration, Makefile state and updates" },
|
|
773
|
+
async run() {
|
|
774
|
+
const colors = stdoutColors();
|
|
775
|
+
const paths = requireProbeDir(process.cwd());
|
|
776
|
+
const pending = await pendingMigration(paths);
|
|
777
|
+
const line = (label, value) => console.log(`${label.padEnd(10)} ${value}`);
|
|
778
|
+
if (pending) {
|
|
779
|
+
const change = `schema v${pending.from} \u2192 v${pending.to}`;
|
|
780
|
+
line("migration", colors.yellow(`pending (${change}) \u2192 run \`probe migrate\``));
|
|
781
|
+
line("makefile", colors.dim("checked after the migration"));
|
|
782
|
+
} else {
|
|
783
|
+
const { config } = await loadConfig(paths);
|
|
784
|
+
line("migration", colors.green("none pending"));
|
|
785
|
+
line("makefile", describeBlock(await blockState(paths, config), colors));
|
|
786
|
+
}
|
|
787
|
+
line("update", describeUpdate(colors));
|
|
788
|
+
}
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
// src/commands/sync.ts
|
|
792
|
+
import { relative as relative2 } from "path";
|
|
793
|
+
import { defineCommand as defineCommand8 } from "citty";
|
|
794
|
+
var sync_default = defineCommand8({
|
|
795
|
+
meta: { name: "sync", description: "Regenerate the probe block of the Makefile" },
|
|
796
|
+
async run() {
|
|
797
|
+
const { config, paths } = await openProject();
|
|
798
|
+
const { created, changed } = await syncMakefile(paths, config);
|
|
799
|
+
const file = relative2(process.cwd(), makefilePath(paths)) || "Makefile";
|
|
800
|
+
if (created) console.log(`Created ${file}. Try: make help`);
|
|
801
|
+
else if (changed) console.log(`Updated the probe block in ${file}.`);
|
|
802
|
+
else console.log(`${file} is already in sync.`);
|
|
803
|
+
}
|
|
804
|
+
});
|
|
805
|
+
|
|
806
|
+
// src/cli.ts
|
|
807
|
+
var main = defineCommand9({
|
|
808
|
+
meta: {
|
|
809
|
+
name: "probe",
|
|
810
|
+
version: CLI_VERSION,
|
|
811
|
+
description: "Health checks for your local dev services"
|
|
812
|
+
},
|
|
813
|
+
subCommands: { init: init_default, add: add_default, rm: rm_default, ls: ls_default, check: check_default, status: status_default, sync: sync_default, migrate: migrate_default }
|
|
814
|
+
});
|
|
815
|
+
function notifyUpdate(rawArgs) {
|
|
816
|
+
const cliPath = process.argv[1];
|
|
817
|
+
if (updateCheckDisabled() || !cliPath) return;
|
|
818
|
+
scheduleRefresh(cacheDir(), cliPath);
|
|
819
|
+
if (!process.stderr.isTTY || rawArgs.includes("--json") || rawArgs[0] === "status") return;
|
|
820
|
+
const notice = updateNotice(CLI_VERSION, cacheDir());
|
|
821
|
+
if (notice) console.error(`
|
|
822
|
+
${notice}`);
|
|
823
|
+
}
|
|
824
|
+
async function run(rawArgs) {
|
|
825
|
+
if (rawArgs[0] === REFRESH_COMMAND) return refreshCache(cacheDir());
|
|
826
|
+
const wantsHelp = rawArgs.includes("--help") || rawArgs.includes("-h");
|
|
827
|
+
const wantsVersion = rawArgs.length === 1 && ["--version", "-v"].includes(rawArgs[0]);
|
|
828
|
+
if (wantsHelp || wantsVersion) return runMain(main, { rawArgs });
|
|
829
|
+
try {
|
|
830
|
+
await runCommand(main, { rawArgs });
|
|
831
|
+
} catch (error) {
|
|
832
|
+
process.exitCode = EXIT_ERROR;
|
|
833
|
+
if (error instanceof ProbeError) {
|
|
834
|
+
console.error(`probe: ${error.message}`);
|
|
835
|
+
} else if (error instanceof Error && error.name === "CLIError") {
|
|
836
|
+
if (rawArgs.length === 0) await showUsage(main);
|
|
837
|
+
console.error(`probe: ${error.message.replace(/\.$/, "")}. See: probe --help`);
|
|
838
|
+
} else {
|
|
839
|
+
console.error(`probe: ${error instanceof Error ? error.message : String(error)}`);
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
notifyUpdate(rawArgs);
|
|
843
|
+
}
|
|
844
|
+
await run(process.argv.slice(2));
|
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ninhache/probe",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Health checks for your local dev services, from the CLI or a generated Makefile",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Ninhache",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/Ninhache/probe.git"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"probe": "dist/cli.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=20"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"packageManager": "pnpm@10.33.0",
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "tsup",
|
|
27
|
+
"dev": "tsup --watch",
|
|
28
|
+
"test": "vitest run",
|
|
29
|
+
"typecheck": "tsc --noEmit",
|
|
30
|
+
"lint": "biome check .",
|
|
31
|
+
"format": "biome check --write ."
|
|
32
|
+
},
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"citty": "^0.2.2",
|
|
35
|
+
"yaml": "^2.9.1",
|
|
36
|
+
"zod": "^4.6.7"
|
|
37
|
+
},
|
|
38
|
+
"devDependencies": {
|
|
39
|
+
"@biomejs/biome": "^2.6.7",
|
|
40
|
+
"@types/node": "^20.19.43",
|
|
41
|
+
"tsup": "^9.0.0",
|
|
42
|
+
"typescript": "^7.0.2",
|
|
43
|
+
"vitest": "^5.1.0"
|
|
44
|
+
},
|
|
45
|
+
"pnpm": {
|
|
46
|
+
"onlyBuiltDependencies": [
|
|
47
|
+
"esbuild"
|
|
48
|
+
]
|
|
49
|
+
}
|
|
50
|
+
}
|