@ninhache/probe 0.6.0 → 0.8.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/dist/cli.js CHANGED
@@ -11,6 +11,9 @@ import {
11
11
  DEFAULT_WAIT_INTERVAL_SECONDS,
12
12
  DEFAULT_WAIT_MAX_INTERVAL_SECONDS,
13
13
  DEFAULT_WAIT_TIMEOUT_SECONDS,
14
+ DETECT_PORTS,
15
+ DETECT_PORTS_ENV,
16
+ DETECT_TIMEOUT_MS,
14
17
  EXIT_DOWN,
15
18
  EXIT_ERROR,
16
19
  LOCAL_DRAFT_FILE,
@@ -37,7 +40,7 @@ import {
37
40
  runChecks,
38
41
  stripStyles,
39
42
  targetUrl
40
- } from "./chunk-YTTYVEYF.js";
43
+ } from "./chunk-Z4F2X5IS.js";
41
44
  import "./chunk-M3ASEG5F.js";
42
45
 
43
46
  // src/cli.ts
@@ -51,17 +54,357 @@ import {
51
54
  // src/commands/add.ts
52
55
  import { defineCommand } from "citty";
53
56
 
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
+ // src/ui/prompt.ts
82
+ async function confirm(message, streams = {}) {
83
+ const { confirm: ask2, isCancel } = await import("./dist-SITQEC3X.js");
84
+ const answer = await ask2({ message, initialValue: false, ...streams });
85
+ return !isCancel(answer) && answer;
86
+ }
87
+ async function ask(message, question = {}, streams = {}) {
88
+ const { text, isCancel } = await import("./dist-SITQEC3X.js");
89
+ const { placeholder, defaultValue, validate: validate2 } = question;
90
+ const answer = await text({
91
+ message,
92
+ placeholder,
93
+ defaultValue,
94
+ validate: (typed) => {
95
+ const value = typed ?? "";
96
+ return value === "" && defaultValue !== void 0 ? void 0 : validate2?.(value);
97
+ },
98
+ ...streams
99
+ });
100
+ if (isCancel(answer)) return null;
101
+ return answer || (defaultValue ?? "");
102
+ }
103
+ async function choose(message, choices, streams = {}) {
104
+ const { multiselect, isCancel } = await import("./dist-SITQEC3X.js");
105
+ const options = choices.map((choice, value) => ({ value, ...choice }));
106
+ const picked = await multiselect({ message, options, required: false, ...streams });
107
+ return isCancel(picked) ? null : picked;
108
+ }
109
+
110
+ // src/detect/index.ts
111
+ import { readFile } from "fs/promises";
112
+ import { join } from "path";
113
+
114
+ // src/detect/sources/compose.ts
115
+ import { parse } from "yaml";
116
+
117
+ // src/detect/shared.ts
118
+ var DEFAULT_HOST = "localhost";
119
+ var DEFAULT_NAME = "app";
120
+ var NOT_HTTP_PORTS = [
121
+ 25,
122
+ 443,
123
+ 465,
124
+ 587,
125
+ 1025,
126
+ 1433,
127
+ 2181,
128
+ 3306,
129
+ 5432,
130
+ 5672,
131
+ 6379,
132
+ 8443,
133
+ 9092,
134
+ 11211,
135
+ 27017
136
+ ];
137
+ var isHttpPort = (port) => !NOT_HTTP_PORTS.includes(port);
138
+ var address = (service) => `${service.host ?? DEFAULT_HOST}:${service.port}`;
139
+ function serviceName(text) {
140
+ const name = text.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^[-_]+|[-_]+$/g, "");
141
+ return name || DEFAULT_NAME;
142
+ }
143
+ var ENV_LINE = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*?)\s*$/;
144
+ function unquoted(value) {
145
+ const quote = value[0];
146
+ const quoted2 = (quote === '"' || quote === "'") && value.endsWith(quote) && value.length > 1;
147
+ return quoted2 ? value.slice(1, -1) : value;
148
+ }
149
+ function parseEnvFile(text) {
150
+ const entries = [];
151
+ for (const line of text.split("\n")) {
152
+ const [, key, raw] = ENV_LINE.exec(line) ?? [];
153
+ if (key && raw !== void 0) entries.push([key, unquoted(raw)]);
154
+ }
155
+ return entries;
156
+ }
157
+
158
+ // src/detect/sources/compose.ts
159
+ var COMPOSE_FILES = ["compose.yaml", "compose.yml", "docker-compose.yaml", "docker-compose.yml"];
160
+ var OVERRIDE_FILES = [
161
+ "compose.override.yaml",
162
+ "compose.override.yml",
163
+ "docker-compose.override.yaml",
164
+ "docker-compose.override.yml"
165
+ ];
166
+ var VARIABLES_FILE = ".env";
167
+ var VARIABLE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?:(:?-)([^}]*))?\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
168
+ var PORT = /^\d+$/;
169
+ var substituted = (text, variables) => text.replace(VARIABLE, (_match, braced, operator, fallback, bare) => {
170
+ const value = variables.get(braced ?? bare);
171
+ const missing = value === void 0 || operator === ":-" && value === "";
172
+ return missing ? fallback ?? "" : value;
173
+ });
174
+ function published(entry, variables) {
175
+ let host;
176
+ let container;
177
+ if (typeof entry === "string") {
178
+ const [mapping = "", protocol = "tcp"] = substituted(entry, variables).split("/");
179
+ if (protocol !== "tcp") return null;
180
+ const parts = mapping.split(":");
181
+ if (parts.length < 2) return null;
182
+ [host = "", container = ""] = parts.slice(-2);
183
+ } else if (entry && typeof entry === "object") {
184
+ const long = entry;
185
+ if ((long.protocol ?? "tcp") !== "tcp") return null;
186
+ host = substituted(String(long.published ?? ""), variables);
187
+ container = String(long.target ?? "");
188
+ } else {
189
+ return null;
190
+ }
191
+ if (!PORT.test(host) || !PORT.test(container)) return null;
192
+ return { host: Number(host), container: Number(container) };
193
+ }
194
+ function inFile(file, text, variables) {
195
+ let document;
196
+ try {
197
+ document = parse(text, { merge: true }) ?? {};
198
+ } catch {
199
+ return [];
200
+ }
201
+ if (!document.services || typeof document.services !== "object") return [];
202
+ const found = [];
203
+ for (const [name, definition] of Object.entries(document.services)) {
204
+ const ports = definition?.ports;
205
+ if (!Array.isArray(ports)) continue;
206
+ const reachable = ports.map((entry) => published(entry, variables)).filter((port) => port !== null).filter((port) => isHttpPort(port.host) && isHttpPort(port.container));
207
+ reachable.forEach(({ host }, index) => {
208
+ found.push({
209
+ // A service that publishes several ports: the first keeps its name.
210
+ name: serviceName(index === 0 ? name : `${name}-${host}`),
211
+ service: { port: host },
212
+ source: `${file} (${name} service)`
213
+ });
214
+ });
215
+ }
216
+ return found;
217
+ }
218
+ var composeSource = {
219
+ id: "compose",
220
+ async find({ read }) {
221
+ const variables = new Map(parseEnvFile(await read(VARIABLES_FILE) ?? ""));
222
+ const found = [];
223
+ for (const names of [COMPOSE_FILES, OVERRIDE_FILES]) {
224
+ for (const file of names) {
225
+ const text = await read(file);
226
+ if (text === null) continue;
227
+ found.push(...inFile(file, text, variables));
228
+ break;
229
+ }
230
+ }
231
+ return found;
232
+ }
233
+ };
234
+
235
+ // src/detect/sources/env.ts
236
+ var ENV_FILES = [".env", ".env.local"];
237
+ var LOCAL_HOSTS = [DEFAULT_HOST, "127.0.0.1", "::1"];
238
+ var URL_KEY = /^(?:VITE_|NEXT_PUBLIC_|REACT_APP_|PUBLIC_)?(.*)_URL$/;
239
+ function serviceOfUrl(value) {
240
+ let url;
241
+ try {
242
+ url = new URL(value);
243
+ } catch {
244
+ return null;
245
+ }
246
+ const host = url.hostname.replace(/^\[|\]$/g, "");
247
+ if (url.protocol !== "http:" || !LOCAL_HOSTS.includes(host)) return null;
248
+ const service = { port: Number(url.port || 80) };
249
+ if (host !== DEFAULT_HOST) service.host = host;
250
+ if (url.pathname !== "/") service.path = url.pathname;
251
+ return service;
252
+ }
253
+ function inFile2(file, text) {
254
+ const found = [];
255
+ for (const [key, value] of parseEnvFile(text)) {
256
+ const source = `${file} (${key})`;
257
+ const urlName = URL_KEY.exec(key)?.[1];
258
+ if (key === "PORT" && /^\d+$/.test(value)) {
259
+ found.push({ name: DEFAULT_NAME, service: { port: Number(value) }, source });
260
+ } else if (urlName !== void 0) {
261
+ const service = serviceOfUrl(value);
262
+ if (service) found.push({ name: serviceName(urlName), service, source });
263
+ }
264
+ }
265
+ return found;
266
+ }
267
+ var envSource = {
268
+ id: "env",
269
+ async find({ read }) {
270
+ const found = [];
271
+ for (const file of ENV_FILES) {
272
+ const text = await read(file);
273
+ if (text !== null) found.push(...inFile2(file, text));
274
+ }
275
+ return found;
276
+ }
277
+ };
278
+
279
+ // src/detect/sources/package-json.ts
280
+ var MANIFEST = "package.json";
281
+ var SCRIPT_PORT = /(?:--port[= ]|-p |\bPORT=)(\d{2,5})\b/;
282
+ var TOOL_PORTS = [
283
+ [/\bvite\b/, 5173],
284
+ [/\bnext\b/, 3e3],
285
+ [/\bnuxt\b/, 3e3],
286
+ [/\bastro\b/, 4321],
287
+ [/\bng serve\b/, 4200]
288
+ ];
289
+ var MAIN_SCRIPTS = ["dev", "start", "serve"];
290
+ function inManifest(text) {
291
+ let manifest;
292
+ try {
293
+ manifest = JSON.parse(text);
294
+ } catch {
295
+ return [];
296
+ }
297
+ if (!manifest.scripts || typeof manifest.scripts !== "object") return [];
298
+ const project = typeof manifest.name === "string" ? manifest.name.replace(/^@[^/]*\//, "") : "";
299
+ const found = [];
300
+ for (const [script, command] of Object.entries(manifest.scripts)) {
301
+ if (typeof command !== "string") continue;
302
+ const main2 = MAIN_SCRIPTS.includes(script);
303
+ const named = SCRIPT_PORT.exec(command)?.[1];
304
+ const usual = main2 ? TOOL_PORTS.find(([tool]) => tool.test(command))?.[1] : void 0;
305
+ const port = named ? Number(named) : usual;
306
+ if (port === void 0) continue;
307
+ const name = serviceName(main2 ? project : script);
308
+ found.push({ name, service: { port }, source: `${MANIFEST} (${script} script)` });
309
+ }
310
+ return found;
311
+ }
312
+ var packageJsonSource = {
313
+ id: "package-json",
314
+ async find({ read }) {
315
+ const text = await read(MANIFEST);
316
+ return text === null ? [] : inManifest(text);
317
+ }
318
+ };
319
+
320
+ // src/detect/sources/ports.ts
321
+ function detectPorts(env) {
322
+ const listed = env[DETECT_PORTS_ENV];
323
+ if (listed === void 0) return [...DETECT_PORTS];
324
+ return listed.split(",").map((port) => Number(port.trim())).filter((port) => Number.isInteger(port) && port > 0 && port <= 65535);
325
+ }
326
+ async function answersHttp(port) {
327
+ const url = targetUrl(serviceSchemaV1.parse({ port }));
328
+ try {
329
+ const response = await fetch(url, {
330
+ signal: AbortSignal.timeout(DETECT_TIMEOUT_MS),
331
+ redirect: "manual"
332
+ });
333
+ await response.body?.cancel();
334
+ return true;
335
+ } catch {
336
+ return false;
337
+ }
338
+ }
339
+ var portsSource = {
340
+ id: "ports",
341
+ async find({ ports, answers, isKnown }) {
342
+ const tried = ports.filter((port) => !isKnown({ port }));
343
+ const open = await Promise.all(
344
+ tried.map(async (port) => await answers(port) ? port : null)
345
+ );
346
+ return open.filter((port) => port !== null).map(
347
+ (port) => ({
348
+ name: `port-${port}`,
349
+ service: { port },
350
+ source: `answers on ${DEFAULT_HOST}:${port}`
351
+ })
352
+ );
353
+ }
354
+ };
355
+
356
+ // src/detect/registry.ts
357
+ var SOURCES = [
358
+ composeSource,
359
+ envSource,
360
+ packageJsonSource,
361
+ portsSource
362
+ ];
363
+
364
+ // src/detect/index.ts
365
+ async function detectServices(root, existing, { read, sources, ports, answers }) {
366
+ const names = new Set(Object.keys(existing));
367
+ const addresses = new Set(Object.values(existing).map(address));
368
+ const kept = [];
369
+ const context = {
370
+ ports,
371
+ answers,
372
+ read: (file) => read(join(root, file)),
373
+ isKnown: (service) => addresses.has(address(service))
374
+ };
375
+ for (const source of sources) {
376
+ for (const { name, service, source: origin } of await source.find(context)) {
377
+ const valid = serviceSchemaV1.safeParse(service).success && SERVICE_NAME_PATTERN.test(name);
378
+ if (!valid || !isHttpPort(service.port) || context.isKnown(service)) continue;
379
+ let free = name;
380
+ for (let suffix = 2; names.has(free); suffix++) free = `${name}-${suffix}`;
381
+ names.add(free);
382
+ addresses.add(address(service));
383
+ kept.push({ name: free, service, source: origin });
384
+ }
385
+ }
386
+ return kept;
387
+ }
388
+ function systemDeps(env) {
389
+ return {
390
+ read: (path) => readFile(path, "utf8").catch(() => null),
391
+ sources: SOURCES,
392
+ ports: detectPorts(env),
393
+ answers: answersHttp
394
+ };
395
+ }
396
+
54
397
  // src/config/discovery.ts
55
398
  import { statSync } from "fs";
56
- import { dirname, join, resolve } from "path";
399
+ import { dirname, join as join2, resolve } from "path";
57
400
  function pathsFor(root) {
58
- const dir = join(root, PROBE_DIR);
401
+ const dir = join2(root, PROBE_DIR);
59
402
  return {
60
403
  root,
61
404
  dir,
62
- config: join(dir, CONFIG_FILE),
63
- lock: join(dir, LOCK_FILE),
64
- local: join(dir, LOCAL_FILE)
405
+ config: join2(dir, CONFIG_FILE),
406
+ lock: join2(dir, LOCK_FILE),
407
+ local: join2(dir, LOCAL_FILE)
65
408
  };
66
409
  }
67
410
  function isDirectory(path) {
@@ -70,7 +413,7 @@ function isDirectory(path) {
70
413
  function findProbeDir(startDir) {
71
414
  let current = resolve(startDir);
72
415
  while (true) {
73
- if (isDirectory(join(current, PROBE_DIR))) return pathsFor(current);
416
+ if (isDirectory(join2(current, PROBE_DIR))) return pathsFor(current);
74
417
  const parent = dirname(current);
75
418
  if (parent === current) return null;
76
419
  current = parent;
@@ -88,34 +431,10 @@ function requireProbeDir(startDir) {
88
431
  }
89
432
 
90
433
  // src/config/loader.ts
91
- import { copyFile, readFile, writeFile } from "fs/promises";
92
- import { join as join2, relative } from "path";
434
+ import { copyFile, readFile as readFile2, writeFile } from "fs/promises";
435
+ import { join as join3, relative } from "path";
93
436
  import { parseDocument } from "yaml";
94
437
 
95
- // src/config/schema.ts
96
- import { z } from "zod";
97
- var CURRENT_SCHEMA_VERSION = 1;
98
- var SERVICE_NAME_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9_-]*$/;
99
- var statusCode = z.number().int().min(100).max(599);
100
- var serviceSchemaV1 = z.strictObject({
101
- host: z.string().min(1).default("localhost"),
102
- port: z.number().int().min(1).max(65535),
103
- path: z.string().startsWith("/").default("/"),
104
- expect: z.union([statusCode, z.array(statusCode).min(1)]).optional(),
105
- /** Seconds given to this service to answer, instead of the default. */
106
- timeout: z.number().positive().max(MAX_TIMEOUT_SECONDS).optional()
107
- });
108
- var configSchemaV1 = z.strictObject({
109
- schemaVersion: z.literal(1),
110
- services: z.record(
111
- z.string().regex(SERVICE_NAME_PATTERN, "letters, digits, _ and - only"),
112
- serviceSchemaV1
113
- ).nullish().transform((services) => services ?? {})
114
- });
115
- var schemas = {
116
- 1: configSchemaV1
117
- };
118
-
119
438
  // src/config/migrations.ts
120
439
  var migrations = {};
121
440
  function formatIssues(error) {
@@ -222,7 +541,7 @@ function reconcileDocument(doc, value) {
222
541
  async function readDocument(paths) {
223
542
  let text;
224
543
  try {
225
- text = await readFile(paths.config, "utf8");
544
+ text = await readFile2(paths.config, "utf8");
226
545
  } catch {
227
546
  throw new ProbeError("CONFIG_MISSING", `cannot read ${paths.config}. Run \`probe init\`.`);
228
547
  }
@@ -255,7 +574,7 @@ ${formatIssues(result.error)}`
255
574
  return result.data;
256
575
  }
257
576
  function backupPath(paths, version) {
258
- return join2(paths.dir, `config.v${version}.bak.yml`);
577
+ return join3(paths.dir, `config.v${version}.bak.yml`);
259
578
  }
260
579
  async function pendingMigration(paths, options = {}) {
261
580
  const { raw } = await readDocument(paths);
@@ -294,12 +613,12 @@ async function writeServiceTimeout(paths, name, seconds2) {
294
613
  }
295
614
 
296
615
  // src/makefile/sync.ts
297
- import { readFile as readFile3, writeFile as writeFile3 } from "fs/promises";
298
- import { join as join3 } from "path";
616
+ import { readFile as readFile4, writeFile as writeFile3 } from "fs/promises";
617
+ import { join as join4 } from "path";
299
618
 
300
619
  // src/config/lock.ts
301
- import { readFile as readFile2, writeFile as writeFile2 } from "fs/promises";
302
- import { parse, stringify } from "yaml";
620
+ import { readFile as readFile3, writeFile as writeFile2 } from "fs/promises";
621
+ import { parse as parse2, stringify } from "yaml";
303
622
  import { z as z2 } from "zod";
304
623
  var lockSchema = z2.object({
305
624
  cliVersion: z2.string(),
@@ -308,7 +627,7 @@ var lockSchema = z2.object({
308
627
  var HEADER = "# Generated by probe. Do not edit.\n";
309
628
  async function readLock(paths) {
310
629
  try {
311
- const result = lockSchema.safeParse(parse(await readFile2(paths.lock, "utf8")));
630
+ const result = lockSchema.safeParse(parse2(await readFile3(paths.lock, "utf8")));
312
631
  return result.success ? result.data : null;
313
632
  } catch {
314
633
  return null;
@@ -319,7 +638,7 @@ async function writeLock(paths, lock) {
319
638
  }
320
639
 
321
640
  // src/version.ts
322
- var CLI_VERSION = true ? "0.6.0" : "0.0.0-dev";
641
+ var CLI_VERSION = true ? "0.8.0" : "0.0.0-dev";
323
642
 
324
643
  // src/makefile/generator.ts
325
644
  import { createHash } from "crypto";
@@ -380,11 +699,11 @@ function replaceBlock(makefile, block) {
380
699
 
381
700
  // src/makefile/sync.ts
382
701
  function makefilePath(paths) {
383
- return join3(paths.root, DEFAULT_MAKEFILE);
702
+ return join4(paths.root, DEFAULT_MAKEFILE);
384
703
  }
385
704
  async function readMakefile(paths) {
386
705
  try {
387
- return await readFile3(makefilePath(paths), "utf8");
706
+ return await readFile4(makefilePath(paths), "utf8");
388
707
  } catch (error) {
389
708
  if (error.code === "ENOENT") return null;
390
709
  throw error;
@@ -452,6 +771,28 @@ async function saveServices(loaded, services) {
452
771
  );
453
772
  }
454
773
 
774
+ // src/commands/detected.ts
775
+ async function offerDetected(loaded) {
776
+ const services = rawServices(loaded);
777
+ const candidates = await detectServices(loaded.paths.root, services, systemDeps(process.env));
778
+ if (candidates.length === 0) return [];
779
+ const urls = candidates.map(({ service }) => targetUrl(serviceSchemaV1.parse(service)));
780
+ const nameWidth = Math.max(...candidates.map(({ name }) => name.length));
781
+ const urlWidth = Math.max(...urls.map((url) => url.length));
782
+ const choices = candidates.map(({ name, source }, index) => ({
783
+ label: `${name.padEnd(nameWidth)} ${(urls[index] ?? "").padEnd(urlWidth)} ${source}`
784
+ }));
785
+ const picked = await choose("Services found here: which ones to add?", choices);
786
+ if (picked === null) return null;
787
+ const added = picked.map((index) => candidates[index]).filter((candidate) => !!candidate);
788
+ if (added.length === 0) return [];
789
+ const entries = Object.fromEntries(added.map(({ name, service }) => [name, service]));
790
+ await saveServices(loaded, { ...services, ...entries });
791
+ const names = added.map(({ name }) => name);
792
+ console.log(`Added ${names.join(", ")}. Try: probe check`);
793
+ return names;
794
+ }
795
+
455
796
  // src/commands/add.ts
456
797
  function parseInteger(value, flag) {
457
798
  if (!/^\d+$/.test(value)) {
@@ -463,6 +804,71 @@ function parseExpect(value) {
463
804
  const codes = value.split(",").map((code) => parseInteger(code.trim(), "--expect"));
464
805
  return codes.length === 1 ? codes[0] : codes;
465
806
  }
807
+ var INTEGER = /^\d+$/;
808
+ 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
+ async function guidedAdd() {
828
+ const loaded = await openProject();
829
+ const services = rawServices(loaded);
830
+ const detected = await offerDetected(loaded);
831
+ if (detected === null) {
832
+ console.log("Nothing added.");
833
+ return;
834
+ }
835
+ if (detected.length > 0) return;
836
+ const answers = [];
837
+ const questions = [
838
+ {
839
+ message: "Service name",
840
+ placeholder: "api",
841
+ validate: (v) => nameProblem(v, services)
842
+ },
843
+ { message: "Port", placeholder: "3000", validate: portProblem },
844
+ {
845
+ message: "Path to request",
846
+ placeholder: DEFAULT_PATH,
847
+ defaultValue: DEFAULT_PATH,
848
+ validate: (v) => schemaProblem(serviceSchemaV1.shape.path, v)
849
+ },
850
+ {
851
+ message: "Expected status",
852
+ placeholder: "any 2xx",
853
+ defaultValue: "",
854
+ validate: expectProblem
855
+ }
856
+ ];
857
+ for (const { message, ...question } of questions) {
858
+ const answer = await ask(message, question);
859
+ if (answer === null) {
860
+ console.log("Nothing added.");
861
+ return;
862
+ }
863
+ answers.push(answer);
864
+ }
865
+ const [name, port, path, expect] = answers;
866
+ const service = { port: Number(port) };
867
+ if (path !== DEFAULT_PATH) service.path = path;
868
+ if (expect) service.expect = parseExpect(expect);
869
+ await saveServices(loaded, { ...services, [name]: service });
870
+ console.log(`Added ${name}. Try: probe check ${name}`);
871
+ }
466
872
  var add_default = defineCommand({
467
873
  meta: { name: "add", description: "Add a service" },
468
874
  args: {
@@ -502,8 +908,8 @@ var add_default = defineCommand({
502
908
  import { defineCommand as defineCommand2 } from "citty";
503
909
 
504
910
  // src/config/local.ts
505
- import { chmod, readFile as readFile4, realpath, rename, rm, stat, writeFile as writeFile4 } from "fs/promises";
506
- import { dirname as dirname2, join as join4 } from "path";
911
+ import { chmod, readFile as readFile5, realpath, rename, rm, stat, writeFile as writeFile4 } from "fs/promises";
912
+ import { dirname as dirname2, join as join5 } from "path";
507
913
  import { isMap as isMap2, isScalar as isScalar2, parseDocument as parseDocument2 } from "yaml";
508
914
  var SECTION = "dashboard";
509
915
  var KEYS = ["interval", "timeout"];
@@ -514,7 +920,7 @@ function isDelay(value) {
514
920
  }
515
921
  async function readLocal(paths) {
516
922
  try {
517
- return await readFile4(paths.local, "utf8");
923
+ return await readFile5(paths.local, "utf8");
518
924
  } catch {
519
925
  return null;
520
926
  }
@@ -555,7 +961,7 @@ async function editLocal(paths, edit) {
555
961
  const target = await realpath(paths.local).catch(() => paths.local);
556
962
  const existing = await stat(target).catch(() => null);
557
963
  const mode = existing ? existing.mode & 511 : LOCAL_FILE_MODE;
558
- const draft = join4(dirname2(target), LOCAL_DRAFT_FILE);
964
+ const draft = join5(dirname2(target), LOCAL_DRAFT_FILE);
559
965
  try {
560
966
  await writeFile4(draft, text, { mode });
561
967
  await chmod(draft, mode);
@@ -814,8 +1220,8 @@ var curl_default = defineCommand4({
814
1220
 
815
1221
  // src/commands/init.ts
816
1222
  import { existsSync } from "fs";
817
- import { mkdir, readFile as readFile5, writeFile as writeFile5 } from "fs/promises";
818
- import { join as join5 } from "path";
1223
+ import { mkdir, readFile as readFile6, writeFile as writeFile5 } from "fs/promises";
1224
+ import { join as join6 } from "path";
819
1225
  import { defineCommand as defineCommand5 } from "citty";
820
1226
  var IGNORED = [`${PROBE_DIR}/${LOCAL_FILE}`, `${PROBE_DIR}/${BACKUP_GLOB}`];
821
1227
  var INITIAL_CONFIG = `# probe - local services to health-check. Edit by hand or with \`probe add\`.
@@ -823,8 +1229,8 @@ schemaVersion: ${CURRENT_SCHEMA_VERSION}
823
1229
  services: {}
824
1230
  `;
825
1231
  async function ignoreLocalFiles(root) {
826
- const file = join5(root, ".gitignore");
827
- const current = existsSync(file) ? await readFile5(file, "utf8") : "";
1232
+ const file = join6(root, ".gitignore");
1233
+ const current = existsSync(file) ? await readFile6(file, "utf8") : "";
828
1234
  const lines = current.split("\n").map((line) => line.trim());
829
1235
  const missing = IGNORED.filter((entry) => !lines.includes(entry));
830
1236
  if (missing.length === 0) return;
@@ -832,20 +1238,30 @@ async function ignoreLocalFiles(root) {
832
1238
  await writeFile5(file, `${current}${separator}${missing.join("\n")}
833
1239
  `);
834
1240
  }
1241
+ async function initProject(root) {
1242
+ const paths = pathsFor(root);
1243
+ if (existsSync(paths.dir)) {
1244
+ throw new ProbeError("ALREADY_INITIALIZED", `${PROBE_DIR}/ already exists here.`);
1245
+ }
1246
+ await mkdir(paths.dir);
1247
+ await writeFile5(paths.config, INITIAL_CONFIG);
1248
+ await ignoreLocalFiles(paths.root);
1249
+ }
835
1250
  var init_default = defineCommand5({
836
1251
  meta: {
837
1252
  name: "init",
838
1253
  description: "Create .probe/ and the .gitignore entries"
839
1254
  },
840
1255
  async run() {
841
- const paths = pathsFor(process.cwd());
842
- if (existsSync(paths.dir)) {
843
- throw new ProbeError("ALREADY_INITIALIZED", `${PROBE_DIR}/ already exists here.`);
1256
+ await initProject(process.cwd());
1257
+ const next = "Next: probe add api -p 3000";
1258
+ if (!isInteractive(process.stdin, process.stdout, process.env)) {
1259
+ console.log(`Created ${PROBE_DIR}/. ${next}`);
1260
+ return;
844
1261
  }
845
- await mkdir(paths.dir);
846
- await writeFile5(paths.config, INITIAL_CONFIG);
847
- await ignoreLocalFiles(paths.root);
848
- console.log(`Created ${PROBE_DIR}/. Next: probe add api -p 3000`);
1262
+ console.log(`Created ${PROBE_DIR}/.`);
1263
+ const added = await offerDetected(await openProject());
1264
+ if (!added || added.length === 0) console.log(next);
849
1265
  }
850
1266
  });
851
1267
 
@@ -920,7 +1336,8 @@ import { defineCommand as defineCommand9 } from "citty";
920
1336
  var rm_default = defineCommand9({
921
1337
  meta: { name: "rm", description: "Remove a service" },
922
1338
  args: {
923
- name: { type: "positional", description: "Service name", required: true }
1339
+ name: { type: "positional", description: "Service name", required: true },
1340
+ yes: { type: "boolean", alias: "y", description: "Remove without asking" }
924
1341
  },
925
1342
  async run({ args: args2 }) {
926
1343
  const loaded = await openProject();
@@ -928,6 +1345,11 @@ var rm_default = defineCommand9({
928
1345
  if (!Object.hasOwn(services, args2.name)) {
929
1346
  throw new ProbeError("SERVICE_UNKNOWN", `unknown service "${args2.name}". See: probe ls`);
930
1347
  }
1348
+ const asks = !args2.yes && isInteractive(process.stdin, process.stdout, process.env);
1349
+ if (asks && !await confirm(`Remove ${args2.name}?`)) {
1350
+ console.log("Nothing removed.");
1351
+ return;
1352
+ }
931
1353
  const { [args2.name]: _removed, ...rest } = services;
932
1354
  await saveServices(loaded, rest);
933
1355
  console.log(`Removed ${args2.name}.`);
@@ -941,16 +1363,16 @@ import { defineCommand as defineCommand10 } from "citty";
941
1363
  import { spawn } from "child_process";
942
1364
  import { mkdirSync, readFileSync, writeFileSync } from "fs";
943
1365
  import { homedir } from "os";
944
- import { join as join6 } from "path";
1366
+ import { join as join7 } from "path";
945
1367
  function cacheDir(env = process.env) {
946
- return join6(env.XDG_CACHE_HOME || join6(homedir(), ".cache"), "probe");
1368
+ return join7(env.XDG_CACHE_HOME || join7(homedir(), ".cache"), "probe");
947
1369
  }
948
1370
  function updateCheckDisabled(env = process.env) {
949
1371
  return env.PROBE_NO_UPDATE_CHECK === "1" || Boolean(env.CI);
950
1372
  }
951
1373
  function readCache(dir) {
952
1374
  try {
953
- const cache = JSON.parse(readFileSync(join6(dir, UPDATE_CACHE_FILE), "utf8"));
1375
+ const cache = JSON.parse(readFileSync(join7(dir, UPDATE_CACHE_FILE), "utf8"));
954
1376
  return typeof cache.checkedAt === "number" ? cache : null;
955
1377
  } catch {
956
1378
  return null;
@@ -958,11 +1380,11 @@ function readCache(dir) {
958
1380
  }
959
1381
  function writeCache(dir, cache) {
960
1382
  mkdirSync(dir, { recursive: true });
961
- writeFileSync(join6(dir, UPDATE_CACHE_FILE), JSON.stringify(cache));
1383
+ writeFileSync(join7(dir, UPDATE_CACHE_FILE), JSON.stringify(cache));
962
1384
  }
963
1385
  function isNewer(latest, current) {
964
- const parse3 = (version) => (version.split("-")[0] ?? "").split(".").map(Number);
965
- const [a, b] = [parse3(latest), parse3(current)];
1386
+ const parse4 = (version) => (version.split("-")[0] ?? "").split(".").map(Number);
1387
+ const [a, b] = [parse4(latest), parse4(current)];
966
1388
  for (let index = 0; index < 3; index++) {
967
1389
  const difference = (a[index] || 0) - (b[index] || 0);
968
1390
  if (difference !== 0) return difference > 0;
@@ -1182,13 +1604,13 @@ var wait_default = defineCommand12({
1182
1604
  });
1183
1605
 
1184
1606
  // src/completion/services.ts
1185
- import { readFile as readFile6 } from "fs/promises";
1186
- import { parse as parse2 } from "yaml";
1607
+ import { readFile as readFile7 } from "fs/promises";
1608
+ import { parse as parse3 } from "yaml";
1187
1609
  async function completableServices(cwd) {
1188
1610
  try {
1189
1611
  const paths = findProbeDir(cwd);
1190
1612
  if (!paths) return [];
1191
- const raw = parse2(await readFile6(paths.config, "utf8"));
1613
+ const raw = parse3(await readFile7(paths.config, "utf8"));
1192
1614
  const services = raw?.services;
1193
1615
  if (typeof services !== "object" || services === null) return [];
1194
1616
  return Object.keys(services).filter((name) => SERVICE_NAME_PATTERN.test(name));
@@ -1279,7 +1701,10 @@ var EXAMPLES = {
1279
1701
  { command: "probe add admin -p 8080 --expect 200,204", note: "Accept these statuses only" },
1280
1702
  { command: "probe add db -p 5432 --timeout 10", note: "Give it 10 s to answer" }
1281
1703
  ],
1282
- rm: [{ command: "probe rm api", note: "Remove the service named api" }],
1704
+ rm: [
1705
+ { command: "probe rm api", note: "Remove the service named api" },
1706
+ { command: "probe rm api --yes", note: "Remove it without the question" }
1707
+ ],
1283
1708
  ls: [{ command: "probe ls", note: "List the services and their URL" }],
1284
1709
  check: [
1285
1710
  { command: "probe check", note: "Check every service" },
@@ -1385,7 +1810,7 @@ async function openedDashboard(rawArgs) {
1385
1810
  if (!asked) return false;
1386
1811
  throw new ProbeError("USAGE", "No services yet. Add one: probe add api -p 3000");
1387
1812
  }
1388
- const { openDashboard } = await import("./open-T3OQH2O4.js");
1813
+ const { openDashboard } = await import("./open-2H7PHDYL.js");
1389
1814
  let settings = initialDashboardSettings(askedMs, await readDashboardPreferences(paths));
1390
1815
  const saves = createSaveQueue();
1391
1816
  const local = `${PROBE_DIR}/${LOCAL_FILE}`;
@@ -1409,6 +1834,23 @@ async function openedDashboard(rawArgs) {
1409
1834
  for (const failure of saves.failures()) console.error(`probe: not saved: ${failure}`);
1410
1835
  return true;
1411
1836
  }
1837
+ async function offeredInit(error, rawArgs) {
1838
+ if (!(error instanceof ProbeError) || error.code !== "NO_PROBE_DIR") return false;
1839
+ if (rawArgs.includes("--json")) return false;
1840
+ if (!isInteractive(process.stdin, process.stdout, process.env)) return false;
1841
+ const root = process.cwd();
1842
+ if (!await confirm(`No ${PROBE_DIR}/ here. Create it in ${root}?`)) return false;
1843
+ await initProject(root);
1844
+ console.log(`Created ${PROBE_DIR}/.`);
1845
+ return true;
1846
+ }
1847
+ function wantsGuidedAdd(rawArgs) {
1848
+ return rawArgs.length === 1 && rawArgs[0] === "add" && isInteractive(process.stdin, process.stdout, process.env);
1849
+ }
1850
+ async function dispatch(rawArgs) {
1851
+ if (wantsGuidedAdd(rawArgs)) return guidedAdd();
1852
+ if (!await openedDashboard(rawArgs)) await runCommand(main, { rawArgs });
1853
+ }
1412
1854
  var UNKNOWN_COMMAND = "E_UNKNOWN_COMMAND";
1413
1855
  function didYouMean(error, rawArgs) {
1414
1856
  if (error.code !== UNKNOWN_COMMAND) return "";
@@ -1432,7 +1874,12 @@ async function run(rawArgs) {
1432
1874
  const wantsVersion = rawArgs.length === 1 && ["--version", "-v"].includes(rawArgs[0]);
1433
1875
  if (wantsHelp || wantsVersion) return runMain(main, { rawArgs, showUsage: printUsage });
1434
1876
  try {
1435
- if (!await openedDashboard(rawArgs)) await runCommand(main, { rawArgs });
1877
+ try {
1878
+ await dispatch(rawArgs);
1879
+ } catch (error) {
1880
+ if (!await offeredInit(error, rawArgs)) throw error;
1881
+ await dispatch(rawArgs);
1882
+ }
1436
1883
  } catch (error) {
1437
1884
  process.exitCode = EXIT_ERROR;
1438
1885
  if (error instanceof ProbeError) {