@ninhache/probe 0.8.0 → 0.10.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
@@ -81,7 +81,7 @@ tick. Nothing is ticked at first, and nothing is written unless you tick it. The
81
81
  each, redirects not followed).
82
82
  `PROBE_DETECT_PORTS=4000,4001` tries other ports, `PROBE_DETECT_PORTS=` none.
83
83
 
84
- `probe rm` is the one command that deletes something. On a terminal it asks first, with "No" selected: `--yes`
84
+ `probe rm` deletes something, so on a terminal it asks first, with "No" selected: `--yes`
85
85
  (`-y`) skips the question. Off a terminal, in a script or in CI, it removes at once, as before.
86
86
 
87
87
  `probe open` uses `open` on macOS and `xdg-open` elsewhere. Without it, the URL is printed and the exit code is
@@ -109,15 +109,38 @@ of macOS.
109
109
  `probe` alone, on a terminal, opens a live list of your services and checks them every 2 s
110
110
  (`probe --interval 5` for another pace). Off a terminal, in CI or in a pipe, nothing changes: use `probe check`.
111
111
 
112
- | Key | What it does |
113
- | --------------- | -------------------------------------- |
114
- | `↑` `↓` `j` `k` | Move |
115
- | `r` | Check now |
116
- | `Enter` | Open the panel of the selected service |
117
- | `o` | Options: interval and timeout |
118
- | `q` | Quit |
119
-
120
- What you change there is saved in `.probe/local.yml`, for your machine only:
112
+ | Key | What it does |
113
+ | --------------- | ---------------------------------------------- |
114
+ | `↑` `↓` `j` `k` | Move |
115
+ | `r` | Check now |
116
+ | `Enter` | Open the detail of the selected service |
117
+ | `u` | Open its URL in the browser |
118
+ | `c` | Copy its curl command |
119
+ | `/` | Filter by name; `Enter` keeps it, `Esc` clears |
120
+ | `a` | Add a service |
121
+ | `e` | Edit the selected service |
122
+ | `d` | Remove the selected service, after a question |
123
+ | `:` | Find a command by what it does; `Enter` runs |
124
+ | `o` | Options: interval and timeout |
125
+ | `?` | Every key; `?` or `Esc` closes |
126
+ | `q` | Quit |
127
+
128
+ The bar at the bottom shows the keys you need first; `?` lists them all.
129
+
130
+ `c` uses `pbcopy` on macOS, `wl-copy` or `xclip` on Linux. Without one of them, the command is shown instead,
131
+ to copy by hand.
132
+
133
+ `a` and `e` open a form: name, port, host, path, expected status. `↑` `↓` or `Tab` change field, `Enter` saves,
134
+ `Esc` cancels. A field left empty takes its default. `d` asks first: only `y` removes. All three write
135
+ `.probe/config.yml` the way `probe add` and `probe rm` do: comments kept, [Makefile](#makefile) block kept up to
136
+ date. While you type, in the filter, the form or the command palette, `q` and `?` are letters.
137
+
138
+ `Enter` shows the last answer of the selected service above its two settings: status, latencies of this
139
+ session, headers and the first lines of the body; for a service that is down, what failed and what to try. The
140
+ value of a header that can carry a secret (`Authorization`, `Set-Cookie`, anything named after a token or a key)
141
+ is replaced by `(hidden)`. None of it is written anywhere, and `probe check --json` prints what it printed before.
142
+
143
+ The panels save what you change in `.probe/local.yml`, for your machine only:
121
144
 
122
145
  ```yaml
123
146
  dashboard: { interval: 5, timeout: 3 }
@@ -180,7 +203,7 @@ outside the block is yours and is never touched. Without a block, no command cre
180
203
  # <<< probe
181
204
  ```
182
205
 
183
- Once the block is there, `probe add` and `probe rm` keep it up to date. Commit the `Makefile` and
206
+ Once the block is there, `probe add`, `probe rm` and the dashboard keep it up to date. Commit the `Makefile` and
184
207
  `.probe/lock.yml`.
185
208
 
186
209
  Targets: `help`, `check`, `status`, `probe` (opens the [dashboard](#dashboard)) and one `check-<name>` per
@@ -14,6 +14,9 @@ var DEFAULT_MAKEFILE = "Makefile";
14
14
  var DEFAULT_CHECKER_TYPE = "http";
15
15
  var DEFAULT_TIMEOUT_SECONDS = 3;
16
16
  var DASHBOARD_INTERVAL_SECONDS = 2;
17
+ var DASHBOARD_HISTORY_LENGTH = 30;
18
+ var RESPONSE_BODY_BYTES = 2048;
19
+ var RESPONSE_BODY_WAIT_MS = 500;
17
20
  var DASHBOARD_INTERVAL_CHOICES_SECONDS = [0.5, 1, 2, 5, 10, 30, 60];
18
21
  var DASHBOARD_TIMEOUT_CHOICES_SECONDS = [1, 2, 3, 5, 10, 30];
19
22
  var MAX_TIMEOUT_SECONDS = 2147483;
@@ -162,6 +165,79 @@ function diagnoseStatus(service, status) {
162
165
  return { error, hint: `the service answers: check \`path\` and \`expect\` in ${CONFIG}` };
163
166
  }
164
167
 
168
+ // src/checkers/response.ts
169
+ var HIDDEN_VALUE = "(hidden)";
170
+ var SENSITIVE_HEADER = /auth|cookie|token|secret|key|session|password|credential/i;
171
+ var TEXT_TYPE = /^text\/|json|xml|javascript|yaml|x-www-form-urlencoded/i;
172
+ var NUL = 0;
173
+ var FIRST_PRINTABLE = 32;
174
+ var DELETE = 127;
175
+ var LAST_C1_CONTROL = 159;
176
+ var REPLACEMENT = "\uFFFD";
177
+ function isControl(character) {
178
+ const code = character.codePointAt(0) ?? NUL;
179
+ return code < FIRST_PRINTABLE || code >= DELETE && code <= LAST_C1_CONTROL;
180
+ }
181
+ function printable(text, kept = "") {
182
+ return Array.from(text).filter((character) => kept.includes(character) || !isControl(character)).join("");
183
+ }
184
+ async function readStart(body) {
185
+ const reader = body.getReader();
186
+ const chunks = [];
187
+ let size = 0;
188
+ let ended = false;
189
+ let timer;
190
+ const late = new Promise((resolve) => {
191
+ timer = setTimeout(() => resolve("late"), RESPONSE_BODY_WAIT_MS);
192
+ });
193
+ try {
194
+ while (size < RESPONSE_BODY_BYTES) {
195
+ const read = reader.read();
196
+ read.catch(() => {
197
+ });
198
+ const next = await Promise.race([read, late]);
199
+ if (next === "late") break;
200
+ if (next.done) {
201
+ ended = true;
202
+ break;
203
+ }
204
+ chunks.push(next.value);
205
+ size += next.value.length;
206
+ }
207
+ } catch {
208
+ } finally {
209
+ clearTimeout(timer);
210
+ void reader.cancel().catch(() => {
211
+ });
212
+ }
213
+ return {
214
+ bytes: Buffer.concat(chunks).subarray(0, RESPONSE_BODY_BYTES),
215
+ cut: !ended || size > RESPONSE_BODY_BYTES
216
+ };
217
+ }
218
+ async function describeResponse(response) {
219
+ const headers = [...response.headers].map(([name, value]) => [
220
+ printable(name),
221
+ SENSITIVE_HEADER.test(name) ? HIDDEN_VALUE : printable(value)
222
+ ]);
223
+ const detail = { statusText: printable(response.statusText), headers };
224
+ if (!response.body) return detail;
225
+ const type = printable(response.headers.get("content-type")?.split(";")[0] ?? "").trim();
226
+ if (type && !TEXT_TYPE.test(type)) {
227
+ void response.body.cancel().catch(() => {
228
+ });
229
+ return { ...detail, bodyNote: `${type} body, not shown` };
230
+ }
231
+ const { bytes, cut } = await readStart(response.body);
232
+ if (bytes.length === 0) return detail;
233
+ if (bytes.includes(NUL)) return { ...detail, bodyNote: "binary body, not shown" };
234
+ const decoded = new TextDecoder().decode(bytes).replaceAll(" ", " ");
235
+ const text = printable(cut ? decoded.replace(new RegExp(`${REPLACEMENT}+$`), "") : decoded, "\n");
236
+ const body = text.trimEnd();
237
+ if (!body) return detail;
238
+ return cut ? { ...detail, body, bodyCut: true } : { ...detail, body };
239
+ }
240
+
165
241
  // src/checkers/http.ts
166
242
  function targetUrl(service) {
167
243
  const bareIpv6 = service.host.includes(":") && !service.host.startsWith("[");
@@ -183,7 +259,7 @@ function expectsRedirect(expect) {
183
259
  }
184
260
  var httpChecker = {
185
261
  type: "http",
186
- async check(name, service, { timeoutMs, signal }) {
262
+ async check(name, service, { timeoutMs, signal, detail }) {
187
263
  const target = targetUrl(service);
188
264
  const base = { name, type: "http", target };
189
265
  const started = performance.now();
@@ -196,10 +272,20 @@ var httpChecker = {
196
272
  redirect: expectsRedirect(service.expect) ? "manual" : "follow"
197
273
  });
198
274
  const latencyMs = elapsed();
199
- await response.body?.cancel();
200
275
  const { status } = response;
201
- if (isExpected(service.expect, status)) return { ...base, ok: true, status, latencyMs };
202
- return { ...base, ok: false, status, latencyMs, ...diagnoseStatus(service, status) };
276
+ const described = detail ? { response: await describeResponse(response) } : {};
277
+ if (!detail) await response.body?.cancel();
278
+ if (isExpected(service.expect, status)) {
279
+ return { ...base, ok: true, status, latencyMs, ...described };
280
+ }
281
+ return {
282
+ ...base,
283
+ ok: false,
284
+ status,
285
+ latencyMs,
286
+ ...diagnoseStatus(service, status),
287
+ ...described
288
+ };
203
289
  } catch (error) {
204
290
  const diagnosis = diagnoseFailure(error, service, timeoutMs);
205
291
  return { ...base, ok: false, latencyMs: elapsed(), ...diagnosis };
@@ -251,12 +337,68 @@ async function runChecks(config, names, options, skipped = /* @__PURE__ */ new S
251
337
  }
252
338
  return getChecker(type).check(name, service, {
253
339
  timeoutMs: resolveTimeoutMs(service, options),
254
- signal: options.signal
340
+ signal: options.signal,
341
+ detail: options.detail
255
342
  });
256
343
  })
257
344
  );
258
345
  }
259
346
 
347
+ // src/config/schema.ts
348
+ import { z } from "zod";
349
+ var CURRENT_SCHEMA_VERSION = 1;
350
+ var SERVICE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
351
+ var statusCode = z.number().int().min(100).max(599);
352
+ var serviceSchemaV1 = z.strictObject({
353
+ host: z.string().min(1).default("localhost"),
354
+ port: z.number().int().min(1).max(65535),
355
+ path: z.string().startsWith("/").default("/"),
356
+ expect: z.union([statusCode, z.array(statusCode).min(1)]).optional(),
357
+ /** Seconds given to this service to answer, instead of the default. */
358
+ timeout: z.number().positive().max(MAX_TIMEOUT_SECONDS).optional()
359
+ });
360
+ var configSchemaV1 = z.strictObject({
361
+ schemaVersion: z.literal(1),
362
+ services: z.record(
363
+ z.string().regex(SERVICE_NAME_PATTERN, "letters, digits, _ and - only"),
364
+ serviceSchemaV1
365
+ ).nullish().transform((services) => services ?? {})
366
+ });
367
+ var schemas = {
368
+ 1: configSchemaV1
369
+ };
370
+
371
+ // src/config/service-input.ts
372
+ var INTEGER = /^\d+$/;
373
+ function schemaProblem(schema, value) {
374
+ const result = schema.safeParse(value);
375
+ return result.success ? void 0 : result.error.issues[0]?.message;
376
+ }
377
+ function parseExpectList(value) {
378
+ const codes = value.split(",").map((code) => Number(code.trim()));
379
+ return codes.length === 1 ? codes[0] : codes;
380
+ }
381
+ function nameProblem(name, taken) {
382
+ if (!SERVICE_NAME_PATTERN.test(name)) return "Letters, digits, _ and - only, e.g. api";
383
+ if ([...taken].includes(name)) return `${name} already exists`;
384
+ return void 0;
385
+ }
386
+ function portProblem(port) {
387
+ if (!INTEGER.test(port)) return "A port number, e.g. 3000";
388
+ return schemaProblem(serviceSchemaV1.shape.port, Number(port)) && "A port between 1 and 65535";
389
+ }
390
+ function hostProblem(host) {
391
+ return /\s|\//.test(host) || host === "" ? "A host name or an IP, e.g. localhost" : void 0;
392
+ }
393
+ function pathProblem(path) {
394
+ return schemaProblem(serviceSchemaV1.shape.path, path) && "A path that starts with /";
395
+ }
396
+ function expectProblem(expect) {
397
+ const codes = expect.split(",").map((code) => code.trim());
398
+ if (!codes.every((code) => INTEGER.test(code))) return "A status, or several: 200 or 200,204";
399
+ return schemaProblem(serviceSchemaV1.shape.expect, parseExpectList(expect)) && "Statuses go from 100 to 599";
400
+ }
401
+
260
402
  // src/ui/colors.ts
261
403
  function createColors(enabled) {
262
404
  const wrap = (open, close) => (text) => enabled ? `\x1B[${open}m${text}\x1B[${close}m` : text;
@@ -334,6 +476,22 @@ function formatServices(config, terminal, skipped = /* @__PURE__ */ new Set()) {
334
476
  }).join("\n");
335
477
  }
336
478
 
479
+ // src/open-url.ts
480
+ import { spawn as nodeSpawn } from "child_process";
481
+ function openerCommand(platform) {
482
+ return platform === "darwin" ? "open" : "xdg-open";
483
+ }
484
+ function openUrl(url, platform = process.platform, spawn = nodeSpawn) {
485
+ return new Promise((resolve) => {
486
+ const child = spawn(openerCommand(platform), [url], { detached: true, stdio: "ignore" });
487
+ child.once("error", () => resolve(false));
488
+ child.once("spawn", () => {
489
+ child.unref();
490
+ resolve(true);
491
+ });
492
+ });
493
+ }
494
+
337
495
  export {
338
496
  PACKAGE_NAME,
339
497
  PROBE_DIR,
@@ -346,6 +504,7 @@ export {
346
504
  DEFAULT_MAKEFILE,
347
505
  DEFAULT_TIMEOUT_SECONDS,
348
506
  DASHBOARD_INTERVAL_SECONDS,
507
+ DASHBOARD_HISTORY_LENGTH,
349
508
  DASHBOARD_INTERVAL_CHOICES_SECONDS,
350
509
  DASHBOARD_TIMEOUT_CHOICES_SECONDS,
351
510
  MAX_TIMEOUT_SECONDS,
@@ -365,6 +524,16 @@ export {
365
524
  DETECT_PORTS,
366
525
  DETECT_PORTS_ENV,
367
526
  DETECT_TIMEOUT_MS,
527
+ CURRENT_SCHEMA_VERSION,
528
+ SERVICE_NAME_PATTERN,
529
+ serviceSchemaV1,
530
+ schemas,
531
+ parseExpectList,
532
+ nameProblem,
533
+ portProblem,
534
+ hostProblem,
535
+ pathProblem,
536
+ expectProblem,
368
537
  ProbeError,
369
538
  targetUrl,
370
539
  curlCommand,
@@ -375,5 +544,7 @@ export {
375
544
  stripStyles,
376
545
  runChecks,
377
546
  formatCheckResults,
378
- formatServices
547
+ formatServices,
548
+ openerCommand,
549
+ openUrl
379
550
  };
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  BACKUP_GLOB,
6
6
  COMPLETE_COMMAND,
7
7
  CONFIG_FILE,
8
+ CURRENT_SCHEMA_VERSION,
8
9
  DASHBOARD_INTERVAL_SECONDS,
9
10
  DEFAULT_MAKEFILE,
10
11
  DEFAULT_TIMEOUT_SECONDS,
@@ -27,6 +28,7 @@ import {
27
28
  REFRESH_COMMAND,
28
29
  REGISTRY_LATEST_URL,
29
30
  REGISTRY_TIMEOUT_MS,
31
+ SERVICE_NAME_PATTERN,
30
32
  UPDATE_CACHE_FILE,
31
33
  UPDATE_CHECK_TTL_MS,
32
34
  WAIT_BACKOFF_FACTOR,
@@ -34,13 +36,21 @@ import {
34
36
  createColors,
35
37
  curlCommand,
36
38
  detectTerminal,
39
+ expectProblem,
37
40
  formatCheckResults,
38
41
  formatServices,
39
42
  isInteractive,
43
+ nameProblem,
44
+ openUrl,
45
+ openerCommand,
46
+ pathProblem,
47
+ portProblem,
40
48
  runChecks,
49
+ schemas,
50
+ serviceSchemaV1,
41
51
  stripStyles,
42
52
  targetUrl
43
- } from "./chunk-Z4F2X5IS.js";
53
+ } from "./chunk-VSQVZK7F.js";
44
54
  import "./chunk-M3ASEG5F.js";
45
55
 
46
56
  // src/cli.ts
@@ -54,30 +64,6 @@ import {
54
64
  // src/commands/add.ts
55
65
  import { defineCommand } from "citty";
56
66
 
57
- // src/config/schema.ts
58
- import { z } from "zod";
59
- var CURRENT_SCHEMA_VERSION = 1;
60
- var SERVICE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
61
- var statusCode = z.number().int().min(100).max(599);
62
- var serviceSchemaV1 = z.strictObject({
63
- host: z.string().min(1).default("localhost"),
64
- port: z.number().int().min(1).max(65535),
65
- path: z.string().startsWith("/").default("/"),
66
- expect: z.union([statusCode, z.array(statusCode).min(1)]).optional(),
67
- /** Seconds given to this service to answer, instead of the default. */
68
- timeout: z.number().positive().max(MAX_TIMEOUT_SECONDS).optional()
69
- });
70
- var configSchemaV1 = z.strictObject({
71
- schemaVersion: z.literal(1),
72
- services: z.record(
73
- z.string().regex(SERVICE_NAME_PATTERN, "letters, digits, _ and - only"),
74
- serviceSchemaV1
75
- ).nullish().transform((services) => services ?? {})
76
- });
77
- var schemas = {
78
- 1: configSchemaV1
79
- };
80
-
81
67
  // src/ui/prompt.ts
82
68
  async function confirm(message, streams = {}) {
83
69
  const { confirm: ask2, isCancel } = await import("./dist-SITQEC3X.js");
@@ -619,10 +605,10 @@ import { join as join4 } from "path";
619
605
  // src/config/lock.ts
620
606
  import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
621
607
  import { parse as parse2, stringify } from "yaml";
622
- import { z as z2 } from "zod";
623
- var lockSchema = z2.object({
624
- cliVersion: z2.string(),
625
- makefile: z2.object({ hash: z2.string() })
608
+ import { z } from "zod";
609
+ var lockSchema = z.object({
610
+ cliVersion: z.string(),
611
+ makefile: z.object({ hash: z.string() })
626
612
  });
627
613
  var HEADER = "# Generated by probe. Do not edit.\n";
628
614
  async function readLock(paths) {
@@ -638,7 +624,7 @@ async function writeLock(paths, lock) {
638
624
  }
639
625
 
640
626
  // src/version.ts
641
- var CLI_VERSION = true ? "0.8.0" : "0.0.0-dev";
627
+ var CLI_VERSION = true ? "0.10.0" : "0.0.0-dev";
642
628
 
643
629
  // src/makefile/generator.ts
644
630
  import { createHash } from "crypto";
@@ -759,16 +745,21 @@ function rawServices(loaded) {
759
745
  return loaded.raw.services ?? {};
760
746
  }
761
747
  async function saveServices(loaded, services) {
748
+ const { warning } = await writeServices(loaded, services);
749
+ if (warning) console.error(`probe: ${warning}`);
750
+ }
751
+ async function writeServices(loaded, services) {
762
752
  const block = await blockState(loaded.paths, loaded.config);
763
753
  const saved = await saveConfig(loaded, { ...loaded.raw, services });
764
- if (block === "missing") return;
754
+ if (block === "missing") return { saved };
765
755
  if (block !== "modified") {
766
756
  await syncMakefile(saved.paths, saved.config);
767
- return;
757
+ return { saved };
768
758
  }
769
- console.error(
770
- "probe: the Makefile block was edited by hand, left as is. Run `probe sync` to regenerate it."
771
- );
759
+ return {
760
+ saved,
761
+ warning: "the Makefile block was edited by hand, left as is. Run `probe sync` to regenerate it."
762
+ };
772
763
  }
773
764
 
774
765
  // src/commands/detected.ts
@@ -804,26 +795,7 @@ function parseExpect(value) {
804
795
  const codes = value.split(",").map((code) => parseInteger(code.trim(), "--expect"));
805
796
  return codes.length === 1 ? codes[0] : codes;
806
797
  }
807
- var INTEGER = /^\d+$/;
808
798
  var DEFAULT_PATH = "/";
809
- function schemaProblem(schema, value) {
810
- const result = schema.safeParse(value);
811
- return result.success ? void 0 : result.error.issues[0]?.message;
812
- }
813
- function nameProblem(name, services) {
814
- if (!SERVICE_NAME_PATTERN.test(name)) return "Letters, digits, _ and - only, e.g. api";
815
- if (Object.hasOwn(services, name)) return `${name} already exists`;
816
- return void 0;
817
- }
818
- function portProblem(port) {
819
- if (!INTEGER.test(port)) return "A port number, e.g. 3000";
820
- return schemaProblem(serviceSchemaV1.shape.port, Number(port)) && "A port between 1 and 65535";
821
- }
822
- function expectProblem(expect) {
823
- const codes = expect.split(",").map((code) => code.trim());
824
- if (!codes.every((code) => INTEGER.test(code))) return "A status, or several: 200 or 200,204";
825
- return schemaProblem(serviceSchemaV1.shape.expect, parseExpect(expect)) && "Statuses go from 100 to 599";
826
- }
827
799
  async function guidedAdd() {
828
800
  const loaded = await openProject();
829
801
  const services = rawServices(loaded);
@@ -838,14 +810,14 @@ async function guidedAdd() {
838
810
  {
839
811
  message: "Service name",
840
812
  placeholder: "api",
841
- validate: (v) => nameProblem(v, services)
813
+ validate: (v) => nameProblem(v, Object.keys(services))
842
814
  },
843
815
  { message: "Port", placeholder: "3000", validate: portProblem },
844
816
  {
845
817
  message: "Path to request",
846
818
  placeholder: DEFAULT_PATH,
847
819
  defaultValue: DEFAULT_PATH,
848
- validate: (v) => schemaProblem(serviceSchemaV1.shape.path, v)
820
+ validate: pathProblem
849
821
  },
850
822
  {
851
823
  message: "Expected status",
@@ -1295,24 +1267,6 @@ var migrate_default = defineCommand7({
1295
1267
 
1296
1268
  // src/commands/open.ts
1297
1269
  import { defineCommand as defineCommand8 } from "citty";
1298
-
1299
- // src/open-url.ts
1300
- import { spawn as nodeSpawn } from "child_process";
1301
- function openerCommand(platform) {
1302
- return platform === "darwin" ? "open" : "xdg-open";
1303
- }
1304
- function openUrl(url, platform = process.platform, spawn2 = nodeSpawn) {
1305
- return new Promise((resolve2) => {
1306
- const child = spawn2(openerCommand(platform), [url], { detached: true, stdio: "ignore" });
1307
- child.once("error", () => resolve2(false));
1308
- child.once("spawn", () => {
1309
- child.unref();
1310
- resolve2(true);
1311
- });
1312
- });
1313
- }
1314
-
1315
- // src/commands/open.ts
1316
1270
  var open_default = defineCommand8({
1317
1271
  meta: { name: "open", description: "Open the URL of a service in the browser" },
1318
1272
  args: {
@@ -1356,6 +1310,38 @@ var rm_default = defineCommand9({
1356
1310
  }
1357
1311
  });
1358
1312
 
1313
+ // src/commands/service-writes.ts
1314
+ import { isDeepStrictEqual as isDeepStrictEqual2 } from "util";
1315
+ var DEFAULTS = { host: "localhost", path: "/", expect: void 0 };
1316
+ var FIELD_KEYS = ["host", "port", "path", "expect"];
1317
+ var sameValue = (a, b) => isDeepStrictEqual2([a].flat(), [b].flat());
1318
+ function withFields(service, fields, current) {
1319
+ const next = { ...service };
1320
+ for (const key of FIELD_KEYS) {
1321
+ if (current && sameValue(fields[key], current[key])) continue;
1322
+ if (isDeepStrictEqual2(fields[key], DEFAULTS[key])) delete next[key];
1323
+ else next[key] = fields[key];
1324
+ }
1325
+ return next;
1326
+ }
1327
+ async function writeService(paths, write) {
1328
+ const loaded = await loadConfig(paths);
1329
+ const services = rawServices(loaded);
1330
+ const exists = Object.hasOwn(services, write.name);
1331
+ if (exists === (write.kind === "add")) return null;
1332
+ const { [write.name]: previous, ...others } = services;
1333
+ const next = write.kind === "remove" ? others : {
1334
+ ...services,
1335
+ [write.name]: withFields(
1336
+ previous ?? {},
1337
+ write.fields,
1338
+ loaded.config.services[write.name]
1339
+ )
1340
+ };
1341
+ const { saved, warning } = await writeServices(loaded, next);
1342
+ return { config: saved.config, warning };
1343
+ }
1344
+
1359
1345
  // src/commands/status.ts
1360
1346
  import { defineCommand as defineCommand10 } from "citty";
1361
1347
 
@@ -1810,7 +1796,7 @@ async function openedDashboard(rawArgs) {
1810
1796
  if (!asked) return false;
1811
1797
  throw new ProbeError("USAGE", "No services yet. Add one: probe add api -p 3000");
1812
1798
  }
1813
- const { openDashboard } = await import("./open-2H7PHDYL.js");
1799
+ const { openDashboard } = await import("./open-W2TWG636.js");
1814
1800
  let settings = initialDashboardSettings(askedMs, await readDashboardPreferences(paths));
1815
1801
  const saves = createSaveQueue();
1816
1802
  const local = `${PROBE_DIR}/${LOCAL_FILE}`;
@@ -1828,7 +1814,19 @@ async function openedDashboard(rawArgs) {
1828
1814
  `${PROBE_DIR}/${CONFIG_FILE}`,
1829
1815
  () => writeServiceTimeout(paths, name, seconds2),
1830
1816
  `${name} is no longer in ${PROBE_DIR}/${CONFIG_FILE}`
1831
- )
1817
+ ),
1818
+ onServiceWrite: async (write) => {
1819
+ let written = null;
1820
+ const failure = await saves.save(
1821
+ `${PROBE_DIR}/${CONFIG_FILE}`,
1822
+ async () => {
1823
+ written = await writeService(paths, write);
1824
+ return written !== null;
1825
+ },
1826
+ write.kind === "add" ? `${write.name} is already in ${PROBE_DIR}/${CONFIG_FILE}` : `${write.name} is no longer in ${PROBE_DIR}/${CONFIG_FILE}`
1827
+ );
1828
+ return { failure, ...written };
1829
+ }
1832
1830
  });
1833
1831
  await saves.idle();
1834
1832
  for (const failure of saves.failures()) console.error(`probe: not saved: ${failure}`);