@monster-orange/den 0.4.5 → 0.4.6

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.
@@ -0,0 +1,490 @@
1
+ /**
2
+ * `<cli> repo add <path|git-url> [--sidecar] [-w <ws>] [--name <n>]
3
+ * [--run "<cmd>"] [--down "<cmd>"] [--health <url>] [--branch <b>] [--json]`
4
+ * (ON-2): add a repo to a workspace with no prompts, and without changing
5
+ * the repo.
6
+ *
7
+ * - A path is a checkout of the person's own: added dev-managed, at its path.
8
+ * A git URL is cloned where Den keeps its managed clones
9
+ * (`<servicesDir>/<repo>/<branch>`, ensureManagedServiceClone) and added
10
+ * managed.
11
+ * - A repo with its own manifest is used as is. One without (or with
12
+ * `--sidecar`) gets a side-car manifest written into the distribution's
13
+ * `manifests/<repo><manifest ext>`, the held-manifest layer of "Overriding
14
+ * a manifest" (docs/distributions.md): Den reads it for that repo, the
15
+ * repo is never written. Its commands come from repo-detect.ts and it is
16
+ * validated by the manifest validator before it is written.
17
+ * - Detection that cannot decide writes nothing and answers with a question
18
+ * and its choices; a re-run passes `--run "<cmd>"`.
19
+ * - Safe to re-run: the same repo in the same workspace answers ok with
20
+ * existed: true, and an existing side-car is never overwritten.
21
+ * - The workspace: `-w <ws>` (created and activated when missing), else the
22
+ * focused one, else a new `default` workspace, activated and focused.
23
+ */
24
+ import { execFileSync } from "child_process";
25
+ import * as fs from "fs";
26
+ import * as path from "path";
27
+ import { product } from "../../shared/product.js";
28
+ import { getDistRoot, getInvokeCwd } from "../../shared/paths.js";
29
+ import {
30
+ heldManifestPath,
31
+ heldManifestsDir,
32
+ originRepoName,
33
+ readManifest,
34
+ repoManifestPath,
35
+ } from "../../shared/manifest/read-manifest.js";
36
+ import { formatManifestYaml } from "./init.js";
37
+ import {
38
+ DEN_SERVICES_DIR,
39
+ getFocusedWorkspaceName,
40
+ readWorkspacesFile,
41
+ writeWorkspacesFile,
42
+ } from "../../shared/environment-manager/workspaces.js";
43
+ import {
44
+ readCatalog,
45
+ upsertCatalogEntry,
46
+ } from "../../shared/environment-manager/catalog.js";
47
+ import {
48
+ isGitRemote,
49
+ isSafeRemote,
50
+ redactRemote,
51
+ } from "../../shared/environment-manager/git-remote.js";
52
+ import { ensureManagedServiceClone } from "../../shared/environment-manager/workspace-manager.js";
53
+ import { getGitHubCoords } from "../../shared/smoke/git-remote.js";
54
+ import {
55
+ detectRunCommands,
56
+ repoNameFrom,
57
+ sidecarManifest,
58
+ type DetectedCommands,
59
+ } from "./repo-detect.js";
60
+ import { printJson, quietStdout } from "./json-out.js";
61
+
62
+ export interface RepoAddArgs {
63
+ spec: string;
64
+ sidecar: boolean;
65
+ workspace?: string;
66
+ name?: string;
67
+ run?: string;
68
+ down?: string;
69
+ health?: string;
70
+ branch?: string;
71
+ json: boolean;
72
+ }
73
+
74
+ export interface RepoAddResult {
75
+ ok: boolean;
76
+ existed?: boolean;
77
+ repo?: string;
78
+ workspace?: string;
79
+ mode?: "dev-managed" | "managed";
80
+ path?: string;
81
+ /** Where the manifest Den uses comes from. */
82
+ manifest?: {
83
+ source: "repo" | "sidecar";
84
+ file: string;
85
+ /** True when this run wrote the side-car. */
86
+ written?: boolean;
87
+ /** How the commands were found, when this run detected them. */
88
+ detected?: DetectedCommands["source"];
89
+ };
90
+ services?: string[];
91
+ next?: string[];
92
+ error?: string;
93
+ fix?: string;
94
+ ask?: string;
95
+ choices?: string[];
96
+ }
97
+
98
+ const USAGE = () =>
99
+ `${product().cli} repo add <path|git-url> [--sidecar] [-w <workspace>] [--name <repo>] ` +
100
+ `[--run "<cmd>"] [--down "<cmd>"] [--health <url>] [--branch <branch>] [--json]`;
101
+
102
+ export const REPO_HELP = () =>
103
+ [
104
+ `\n ${USAGE()}\n`,
105
+ " Add a repo to a workspace, with no prompts and without changing the repo.",
106
+ " A path is your own checkout (dev-managed); a git URL is cloned where Den",
107
+ " keeps its managed clones. A repo with no manifest of its own, or with",
108
+ " --sidecar, gets a side-car manifest in this distribution's manifests/",
109
+ " (never in the repo), detected from its Makefile (an `up` target), its",
110
+ " compose file or its package.json scripts (dev, start, test). When that",
111
+ ' cannot decide, nothing is written and --json answers with "ask" and',
112
+ ' "choices": re-run with --run "<cmd>". --health <url> adds an HTTP health',
113
+ " check. -w picks the workspace (default: the focused one, else a new",
114
+ " `default`). Safe to re-run: the same repo answers existed: true.\n",
115
+ ].join("\n");
116
+
117
+ const VALUE_FLAGS = new Set([
118
+ "-w",
119
+ "--workspace",
120
+ "--name",
121
+ "--run",
122
+ "--down",
123
+ "--health",
124
+ "--branch",
125
+ ]);
126
+
127
+ export function parseRepoAddArgs(argv: string[]): RepoAddArgs {
128
+ const out: Partial<RepoAddArgs> & { sidecar: boolean; json: boolean } = {
129
+ sidecar: false,
130
+ json: false,
131
+ };
132
+ const positional: string[] = [];
133
+ for (let i = 0; i < argv.length; i++) {
134
+ const a = argv[i];
135
+ const eq = a.indexOf("=");
136
+ const flag = a.startsWith("--") && eq > 0 ? a.slice(0, eq) : a;
137
+ if (a === "--sidecar") out.sidecar = true;
138
+ else if (a === "--json") out.json = true;
139
+ else if (VALUE_FLAGS.has(flag)) {
140
+ const v = flag !== a ? a.slice(eq + 1) : argv[++i];
141
+ if (v === undefined || v === "") throw new Error(`${flag} needs a value`);
142
+ if (flag === "-w" || flag === "--workspace") out.workspace = v;
143
+ else if (flag === "--name") out.name = v;
144
+ else if (flag === "--run") out.run = v;
145
+ else if (flag === "--down") out.down = v;
146
+ else if (flag === "--health") out.health = v;
147
+ else out.branch = v;
148
+ } else if (a.startsWith("-")) throw new Error(`unknown flag ${a}`);
149
+ else positional.push(a);
150
+ }
151
+ if (positional.length !== 1)
152
+ throw new Error("repo add needs one <path|git-url>");
153
+ return { ...out, spec: positional[0] } as RepoAddArgs;
154
+ }
155
+
156
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
157
+
158
+ /** The command an agent runs from the distribution root. */
159
+ function runnable(): string {
160
+ const cli = product().cli;
161
+ return fs.existsSync(path.join(getDistRoot(), "bin", cli))
162
+ ? `./bin/${cli}`
163
+ : cli;
164
+ }
165
+
166
+ /** A spec is a git URL when it is not a directory here and looks like one. */
167
+ function isUrlSpec(spec: string, cwd: string): boolean {
168
+ if (fs.existsSync(path.resolve(cwd, spec))) return false;
169
+ return (
170
+ /^(https?|ssh|git|file):\/\//.test(spec) ||
171
+ /^[A-Za-z0-9._-]+@[A-Za-z0-9.-]+:/.test(spec)
172
+ );
173
+ }
174
+
175
+ /** The remote's default branch (`git ls-remote --symref`), or "main". */
176
+ function defaultBranchOf(url: string): string {
177
+ try {
178
+ const out = execFileSync("git", ["ls-remote", "--symref", url, "HEAD"], {
179
+ encoding: "utf-8",
180
+ stdio: ["ignore", "pipe", "ignore"],
181
+ timeout: 30_000,
182
+ });
183
+ return /^ref: refs\/heads\/(\S+)\s+HEAD/m.exec(out)?.[1] ?? "main";
184
+ } catch {
185
+ return "main";
186
+ }
187
+ }
188
+
189
+ /** The workspace to write: -w (made when missing), the focus, or `default`. */
190
+ function targetWorkspace(named: string | undefined): {
191
+ name: string;
192
+ created: boolean;
193
+ } {
194
+ const file = readWorkspacesFile();
195
+ const name = named ?? getFocusedWorkspaceName() ?? "default";
196
+ if (file.workspaces[name]) return { name, created: false };
197
+ file.workspaces[name] = { services: {} };
198
+ if (!file.active.includes(name)) file.active.push(name);
199
+ if (!file.focus || !file.workspaces[file.focus]) file.focus = name;
200
+ writeWorkspacesFile(file);
201
+ return { name, created: true };
202
+ }
203
+
204
+ function sidecarFile(key: string): string {
205
+ return path.join(heldManifestsDir(), `${key}${product().manifestYml}`);
206
+ }
207
+
208
+ /**
209
+ * The manifest validator (validate-manifest.ts's validateManifest), passed
210
+ * in by the CLI entry: this module stays out of the type-checked program's
211
+ * zod-drift baseline (tsconfig.typecheck.json) that file is in.
212
+ */
213
+ export type ManifestValidator = (manifest: unknown) => {
214
+ valid: boolean;
215
+ errors: { path: string; message: string }[];
216
+ };
217
+
218
+ /** Run `repo add` as data. Never prompts; throws only on a bug. */
219
+ export function repoAdd(
220
+ a: RepoAddArgs,
221
+ validate: ManifestValidator,
222
+ cwd: string = getInvokeCwd(),
223
+ ): RepoAddResult {
224
+ const bin = runnable();
225
+ const url = isUrlSpec(a.spec, cwd);
226
+ if (a.name !== undefined && !NAME_RE.test(a.name))
227
+ return {
228
+ ok: false,
229
+ error: `--name "${a.name}" must use only letters, digits, ".", "_" and "-"`,
230
+ ask: "Which name should Den use for this repo?",
231
+ };
232
+ if (a.health !== undefined && !/^https?:\/\/\S+$/.test(a.health))
233
+ return {
234
+ ok: false,
235
+ error: `--health "${a.health}" must be an http(s) URL`,
236
+ fix: `${bin} repo add ${a.spec} --health http://localhost:<port>/ --json`,
237
+ };
238
+
239
+ // ── Where the repo is ────────────────────────────────────────────────────
240
+ let repoPath: string;
241
+ let mode: "dev-managed" | "managed";
242
+ let branch: string | undefined;
243
+ let name: string;
244
+ if (url) {
245
+ if (!isSafeRemote(a.spec) || !isGitRemote(a.spec))
246
+ return {
247
+ ok: false,
248
+ error: `${redactRemote(a.spec)} is not a git URL Den can clone (https://, ssh://, git://, file://, user@host:path)`,
249
+ ask: "What is the repo's clone URL, or the path of a local checkout?",
250
+ };
251
+ name = a.name ?? repoNameFrom(a.spec);
252
+ if (!NAME_RE.test(name))
253
+ return {
254
+ ok: false,
255
+ error: `cannot name a repo from ${redactRemote(a.spec)}`,
256
+ fix: `${bin} repo add ${a.spec} --name <repo> --json`,
257
+ };
258
+ branch = a.branch ?? defaultBranchOf(a.spec);
259
+ mode = "managed";
260
+ const existing = path.join(DEN_SERVICES_DIR, name, branch);
261
+ try {
262
+ // A clone that is there is used as is: adding a repo never pulls
263
+ // (`up --pull` and `sync` do), so a re-run touches no network.
264
+ repoPath = fs.existsSync(path.join(existing, ".git"))
265
+ ? existing
266
+ : ensureManagedServiceClone(name, a.spec, branch, false);
267
+ } catch (e) {
268
+ if (!fs.existsSync(path.join(existing, ".git")))
269
+ return {
270
+ ok: false,
271
+ error: `could not clone ${redactRemote(a.spec)}@${branch}: ${(e as Error).message.split("\n")[0]}`,
272
+ ask: "Can you clone this URL with git on this machine (is access set up)? If the default branch is not main, which branch should Den use?",
273
+ fix: `${bin} repo add ${a.spec} --branch <branch> --json`,
274
+ };
275
+ repoPath = existing; // a pull failed; the clone is there
276
+ }
277
+ } else {
278
+ repoPath = path.resolve(cwd, a.spec);
279
+ if (!fs.existsSync(repoPath) || !fs.statSync(repoPath).isDirectory())
280
+ return {
281
+ ok: false,
282
+ error: `no folder at ${repoPath}`,
283
+ ask: "Which folder holds the repo? (a local path, or a git URL to clone)",
284
+ };
285
+ mode = "dev-managed";
286
+ name = "";
287
+ }
288
+
289
+ // ── Its manifest: the repo's own, or a side-car ──────────────────────────
290
+ const own = repoManifestPath(repoPath);
291
+ if (own && a.sidecar)
292
+ return {
293
+ ok: false,
294
+ error: `${repoPath} has its own manifest (${path.basename(own)}), and Den always uses a repo's own manifest over a side-car`,
295
+ fix: `${bin} repo add ${a.spec} --json`,
296
+ ask: `Use the repo's own ${path.basename(own)} as it is? To change what this distribution runs, Den reads manifests/<repo>.override${path.extname(product().manifestYml)} on top of it.`,
297
+ };
298
+ const key = url
299
+ ? (originRepoName(repoPath) ?? name)
300
+ : (originRepoName(repoPath) ?? path.basename(repoPath));
301
+ let manifestInfo: RepoAddResult["manifest"];
302
+ if (own) {
303
+ manifestInfo = { source: "repo", file: own };
304
+ } else {
305
+ const held = heldManifestPath(repoPath);
306
+ if (held) {
307
+ manifestInfo = { source: "sidecar", file: held, written: false };
308
+ } else {
309
+ const d = detectRunCommands(repoPath, { run: a.run, down: a.down });
310
+ if (!d.decided)
311
+ return {
312
+ ok: false,
313
+ error: `Den could not tell how ${repoPath} starts`,
314
+ ask: d.ask,
315
+ choices: d.choices,
316
+ fix: `${bin} repo add ${a.spec}${a.sidecar ? " --sidecar" : ""} --run "${d.choices[0] ?? "<command>"}" --json`,
317
+ };
318
+ const mName = a.name ?? (url ? name : key);
319
+ if (!NAME_RE.test(mName))
320
+ return {
321
+ ok: false,
322
+ error: `cannot name a repo from the folder "${key}"`,
323
+ fix: `${bin} repo add ${a.spec} --name <repo> --json`,
324
+ };
325
+ const manifest = sidecarManifest(mName, d.commands, a.health);
326
+ const v = validate(manifest);
327
+ if (!v.valid)
328
+ return {
329
+ ok: false,
330
+ error: `the detected manifest is not valid: ${v.errors.map((e) => `${e.path} ${e.message}`).join("; ")}`,
331
+ fix: `${bin} repo add ${a.spec} --run "<command>" --json`,
332
+ };
333
+ const file = sidecarFile(key);
334
+ fs.mkdirSync(path.dirname(file), { recursive: true });
335
+ // "wx": never overwrite a side-car someone may have edited.
336
+ fs.writeFileSync(file, formatManifestYaml(manifest), {
337
+ encoding: "utf-8",
338
+ flag: "wx",
339
+ });
340
+ manifestInfo = {
341
+ source: "sidecar",
342
+ file,
343
+ written: true,
344
+ detected: d.commands.source,
345
+ };
346
+ }
347
+ }
348
+
349
+ const manifest = readManifest(repoPath);
350
+ if (!manifest)
351
+ return {
352
+ ok: false,
353
+ error: `Den cannot read the manifest at ${manifestInfo.file}`,
354
+ fix: `${bin} validate ${repoPath}`,
355
+ };
356
+ if (!url)
357
+ name =
358
+ a.name ??
359
+ (typeof manifest.name === "string" && manifest.name
360
+ ? manifest.name
361
+ : path.basename(repoPath));
362
+ if (!NAME_RE.test(name))
363
+ return {
364
+ ok: false,
365
+ error: `cannot use "${name}" as a repo name`,
366
+ fix: `${bin} repo add ${a.spec} --name <repo> --json`,
367
+ };
368
+
369
+ // ── The workspace entry ──────────────────────────────────────────────────
370
+ const ws = targetWorkspace(a.workspace);
371
+ const file = readWorkspacesFile();
372
+ const entries = file.workspaces[ws.name].services;
373
+ const mine = Object.keys(entries).filter(
374
+ (k) => k === name || k.startsWith(`${name}/`),
375
+ );
376
+ const same = (k: string) =>
377
+ entries[k].mode === mode &&
378
+ (mode === "dev-managed"
379
+ ? path.resolve(entries[k].path ?? "") === repoPath
380
+ : (entries[k].branch ?? "main") === branch);
381
+ const services = Object.keys(manifest.services ?? {});
382
+ const next = [
383
+ `${bin} up ${name} --json`,
384
+ `${bin} web up --no-open --json`,
385
+ `${bin} doctor --json`,
386
+ ];
387
+ const base = {
388
+ repo: name,
389
+ workspace: ws.name,
390
+ mode,
391
+ path: repoPath,
392
+ manifest: manifestInfo,
393
+ services,
394
+ next,
395
+ };
396
+ if (mine.length > 0 && mine.every(same))
397
+ return { ok: true, existed: true, ...base };
398
+ if (mine.length > 0)
399
+ return {
400
+ ok: false,
401
+ error: `workspace "${ws.name}" already has a different "${name}" (${mine.map((k) => entries[k].path ?? `managed ${entries[k].branch ?? "main"}`).join(", ")})`,
402
+ ask: `Replace the "${name}" in workspace "${ws.name}" with ${repoPath}, or add this one under another name?`,
403
+ fix: `${bin} relocate ${name} ${repoPath} -w ${ws.name}`,
404
+ choices: [
405
+ `${bin} relocate ${name} ${repoPath} -w ${ws.name}`,
406
+ `${bin} repo add ${a.spec} --name <other-name> --json`,
407
+ ],
408
+ };
409
+ entries[name] =
410
+ mode === "managed"
411
+ ? { mode, branch: branch ?? "main" }
412
+ : { mode, path: repoPath };
413
+ writeWorkspacesFile(file);
414
+ if (mode === "managed") {
415
+ upsertCatalogEntry(name, { gh: "", remote: a.spec });
416
+ } else if (!readCatalog()[name]) {
417
+ const coords = getGitHubCoords(repoPath);
418
+ upsertCatalogEntry(name, {
419
+ gh: coords ? `${coords.owner}/${coords.repo}` : "",
420
+ });
421
+ }
422
+ return { ok: true, existed: false, ...base };
423
+ }
424
+
425
+ function printHuman(r: RepoAddResult): void {
426
+ if (!r.ok) {
427
+ console.error(`✗ repo add: ${r.error}`);
428
+ if (r.ask) console.error(` ${r.ask}`);
429
+ for (const c of r.choices ?? []) console.error(` - ${c}`);
430
+ if (r.fix) console.error(` Run: ${r.fix}`);
431
+ return;
432
+ }
433
+ console.log(
434
+ r.existed
435
+ ? `✓ ${r.repo} is already in workspace ${r.workspace} (${r.mode}, ${r.path})`
436
+ : `✓ Added ${r.repo} to workspace ${r.workspace} (${r.mode}, ${r.path})`,
437
+ );
438
+ if (r.manifest)
439
+ console.log(
440
+ ` manifest: ${r.manifest.source} ${r.manifest.file}${r.manifest.written ? ` (written, from ${r.manifest.detected})` : ""}`,
441
+ );
442
+ console.log(` Next: ${r.next?.[0] ?? ""}`);
443
+ }
444
+
445
+ /** `repo <sub> …`; returns the exit code. */
446
+ export async function cmdRepo(
447
+ argv: string[],
448
+ validate: ManifestValidator,
449
+ ): Promise<number> {
450
+ const [sub, ...rest] = argv;
451
+ if (!sub || sub === "-h" || sub === "--help" || rest.includes("--help")) {
452
+ console.log(REPO_HELP());
453
+ return sub ? 0 : 1;
454
+ }
455
+ const json = rest.includes("--json");
456
+ if (sub !== "add") {
457
+ const r = {
458
+ ok: false,
459
+ error: `unknown repo subcommand "${sub}"`,
460
+ fix: `${runnable()} repo add <path|git-url> --json`,
461
+ };
462
+ if (json) printJson(r);
463
+ else console.error(`✗ ${r.error}\n${REPO_HELP()}`);
464
+ return 2;
465
+ }
466
+ let args: RepoAddArgs;
467
+ try {
468
+ args = parseRepoAddArgs(rest);
469
+ } catch (e) {
470
+ const r = {
471
+ ok: false,
472
+ error: (e as Error).message,
473
+ fix: `${runnable()} repo add <path|git-url> --json`,
474
+ };
475
+ if (json) printJson(r);
476
+ else console.error(`✗ repo add: ${r.error}\n usage: ${USAGE()}`);
477
+ return 2;
478
+ }
479
+ let r: RepoAddResult;
480
+ try {
481
+ r = args.json
482
+ ? await quietStdout(async () => repoAdd(args, validate))
483
+ : repoAdd(args, validate);
484
+ } catch (e) {
485
+ r = { ok: false, error: (e as Error).message };
486
+ }
487
+ if (args.json) printJson(r);
488
+ else printHuman(r);
489
+ return r.ok ? 0 : 1;
490
+ }
@@ -0,0 +1,192 @@
1
+ /**
2
+ * `repo add`'s detection: how a repo runs, read from its own files, so Den
3
+ * can write a side-car manifest for it without a commit to the repo.
4
+ *
5
+ * Read-only and pure apart from reading files: nothing here writes, spawns
6
+ * or runs the repo's tools. The rules, in order:
7
+ *
8
+ * 1. A Makefile with an `up` target (init's detection, listMakeTargets):
9
+ * `make up`, plus `down`, `setup` and `restart` when present, and a
10
+ * `test` target as the repo's test suite.
11
+ * 2. Otherwise each other way to start it is a candidate: a compose file
12
+ * (`docker compose -f <file> up -d`, down with `down`), and package.json's
13
+ * `dev` or else `start` script (`npm run dev`, `npm start`). Exactly one
14
+ * candidate decides; more than one, or none, is a question for the
15
+ * person, with the candidates as choices. Nothing is written until a
16
+ * re-run names one (`--run "<cmd>"`).
17
+ * 3. package.json's `test` script (or a Makefile `test`) is the test suite.
18
+ *
19
+ * `--run "<cmd>"` overrides 1 and 2: that command is `up`.
20
+ */
21
+ import * as fs from "fs";
22
+ import * as path from "path";
23
+ import { listMakeTargets } from "../../shared/environment-manager/make-targets.js";
24
+
25
+ /** Compose files Docker looks for, in its own order. */
26
+ export const COMPOSE_FILES = [
27
+ "compose.yaml",
28
+ "compose.yml",
29
+ "docker-compose.yaml",
30
+ "docker-compose.yml",
31
+ ] as const;
32
+
33
+ export interface DetectedCommands {
34
+ /** Where the commands came from. */
35
+ source: "make" | "compose" | "package.json" | "--run";
36
+ up: string;
37
+ down?: string;
38
+ setup?: string;
39
+ restart?: string;
40
+ /** The repo's own test command, when it has one. */
41
+ test?: string;
42
+ /** The compose file, when the repo has one. */
43
+ composeFile?: string;
44
+ }
45
+
46
+ export type Detection =
47
+ | { decided: true; commands: DetectedCommands }
48
+ | {
49
+ decided: false;
50
+ /** A question for the person. */
51
+ ask: string;
52
+ /** Start commands to choose from (may be empty). */
53
+ choices: string[];
54
+ };
55
+
56
+ function readScripts(repoPath: string): Record<string, string> {
57
+ try {
58
+ const pkg = JSON.parse(
59
+ fs.readFileSync(path.join(repoPath, "package.json"), "utf-8"),
60
+ ) as { scripts?: Record<string, unknown> };
61
+ const out: Record<string, string> = {};
62
+ for (const [k, v] of Object.entries(pkg.scripts ?? {}))
63
+ if (typeof v === "string") out[k] = v;
64
+ return out;
65
+ } catch {
66
+ return {};
67
+ }
68
+ }
69
+
70
+ /** The compose file at the repo root, or undefined. */
71
+ export function composeFileOf(repoPath: string): string | undefined {
72
+ return COMPOSE_FILES.find((f) => fs.existsSync(path.join(repoPath, f)));
73
+ }
74
+
75
+ /** How `repoPath` runs, or the question to ask. See the module comment. */
76
+ export function detectRunCommands(
77
+ repoPath: string,
78
+ opts: { run?: string; down?: string } = {},
79
+ ): Detection {
80
+ const make = new Set(listMakeTargets(repoPath).targets);
81
+ const scripts = readScripts(repoPath);
82
+ const composeFile = composeFileOf(repoPath);
83
+ const test = make.has("test")
84
+ ? "make test"
85
+ : scripts.test && !/no test specified/.test(scripts.test)
86
+ ? "npm test"
87
+ : undefined;
88
+ const withExtras = (c: DetectedCommands): DetectedCommands => ({
89
+ ...c,
90
+ ...(test ? { test } : {}),
91
+ ...(composeFile ? { composeFile } : {}),
92
+ });
93
+
94
+ if (opts.run?.trim()) {
95
+ return {
96
+ decided: true,
97
+ commands: withExtras({
98
+ source: "--run",
99
+ up: opts.run.trim(),
100
+ ...(opts.down?.trim()
101
+ ? { down: opts.down.trim() }
102
+ : make.has("down")
103
+ ? { down: "make down" }
104
+ : {}),
105
+ }),
106
+ };
107
+ }
108
+
109
+ if (make.has("up")) {
110
+ const c: DetectedCommands = { source: "make", up: "make up" };
111
+ for (const t of ["down", "setup", "restart"] as const)
112
+ if (make.has(t)) c[t] = `make ${t}`;
113
+ return { decided: true, commands: withExtras(c) };
114
+ }
115
+
116
+ const candidates: DetectedCommands[] = [];
117
+ if (composeFile)
118
+ candidates.push({
119
+ source: "compose",
120
+ up: `docker compose -f ${composeFile} up -d`,
121
+ down: `docker compose -f ${composeFile} down`,
122
+ });
123
+ if (scripts.dev)
124
+ candidates.push({ source: "package.json", up: "npm run dev" });
125
+ else if (scripts.start)
126
+ candidates.push({ source: "package.json", up: "npm start" });
127
+
128
+ if (candidates.length === 1)
129
+ return { decided: true, commands: withExtras(candidates[0]) };
130
+ const choices = candidates.map((c) => c.up);
131
+ return {
132
+ decided: false,
133
+ ask:
134
+ choices.length > 0
135
+ ? `This repo can start in ${choices.length} ways. Which one do you use to run it locally?`
136
+ : "How do you usually start this repo locally? (the command you type in its folder)",
137
+ choices,
138
+ };
139
+ }
140
+
141
+ /** Whether a repo's commands or files say it needs Docker. */
142
+ export function needsDocker(manifest: unknown, repoPath?: string): boolean {
143
+ const m = (manifest ?? {}) as {
144
+ compose?: unknown;
145
+ services?: Record<string, { commands?: Record<string, unknown> }>;
146
+ };
147
+ if (m.compose) return true;
148
+ for (const svc of Object.values(m.services ?? {}))
149
+ for (const env of Object.values(svc?.commands ?? {}))
150
+ for (const cmd of Object.values((env ?? {}) as Record<string, unknown>)) {
151
+ const text =
152
+ typeof cmd === "string"
153
+ ? cmd
154
+ : ((cmd as { cmd?: string } | null)?.cmd ?? "");
155
+ if (/\bdocker(-compose)?\b/.test(text)) return true;
156
+ }
157
+ return repoPath ? composeFileOf(repoPath) !== undefined : false;
158
+ }
159
+
160
+ /** The side-car manifest for a repo Den detected. Validate before writing. */
161
+ export function sidecarManifest(
162
+ name: string,
163
+ c: DetectedCommands,
164
+ health?: string,
165
+ ): Record<string, unknown> {
166
+ const local: Record<string, string> = { up: c.up };
167
+ if (c.down) local.down = c.down;
168
+ if (c.setup) local.setup = c.setup;
169
+ if (c.restart) local.restart = c.restart;
170
+ return {
171
+ version: 1,
172
+ name,
173
+ services: {
174
+ [name]: {
175
+ commands: { local },
176
+ ...(health ? { health: { local: { url: health } } } : {}),
177
+ },
178
+ },
179
+ ...(c.test ? { tests: { unit: { cmd: c.test } } } : {}),
180
+ };
181
+ }
182
+
183
+ /** A repo name Den can use as a key: the folder or URL's last segment. */
184
+ export function repoNameFrom(spec: string): string {
185
+ const leaf =
186
+ spec
187
+ .replace(/[/\\]+$/, "")
188
+ .replace(/\.git$/, "")
189
+ .split(/[/:\\]/)
190
+ .pop() ?? "";
191
+ return leaf;
192
+ }
@@ -1196,6 +1196,7 @@ export function displayWebHelp(): void {
1196
1196
  console.log(" --port, -p <port> Use a non-default web port");
1197
1197
  console.log(
1198
1198
  " --no-open Start or check the server without opening a browser",
1199
+ " --json (up, restart) log on stderr; stdout: { ok, url, port, version? }",
1199
1200
  );
1200
1201
  console.log(
1201
1202
  " --setup-browser-access Request macOS Automation access for browser tab reuse",