Skip to content

Commit 6b50b76

Browse files
roodboiclaude
andcommitted
feat(init): add post-discovery validation pass for hack init
Field repos were shipping duplicated services (web/web-2), everything crammed onto port 3000, silently-wrong bun-node images on non-JS services, and missing backing-service infra (postgres/temporal) — all with zero warnings. Add a validation pass (src/init/validation.ts) that runs after discovery and before compose generation in both the --auto and interactive init paths: - Candidate dedupe: collapse same-package duplicate scripts (dev + start) and drop root-level aggregator scripts that just delegate to a workspace package's own dev script (turbo --filter, dotnet run --project, pnpm --cwd), which is what actually produced the reported web/web-2 and phantom "backend" duplicates. - Port collision handling: reassign colliding HTTP ports to the next free port (ascending) and rewrite the container command. - Runtime detection: flag non-JS services (csproj/fsproj, go.mod, Cargo.toml, pyproject.toml, mix.exs, Gemfile, or command sniffing — including root aggregator commands that target another dir) and use an obviously-wrong alpine:3 placeholder image with a TODO comment instead of silently defaulting to bun-node. - Backing-service detection: surface postgres/mysql/redis/temporal/ kafka/rabbitmq/mongodb signals from dependencies, prisma schema, and .env* key names (values never read) as warnings + a compose header comment, without auto-scaffolding them. - Existing-compose pointer: if a repo-root docker-compose*.yml already exists, point at it as ground truth instead of re-deriving. renderCompose gains header/service comment injection (idempotent post-process over the stringified YAML). buildDiscoveredComposeAuto and ComposeWizardInput are now exported for direct pipeline testing. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent e3c6da0 commit 6b50b76

6 files changed

Lines changed: 1729 additions & 14 deletions

File tree

docs/guides/init-project.md

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,44 @@ Note:
2727
- HTTP services via `https://*.hack` hostnames (matching your Caddy labels)
2828
- non-HTTP services via Compose service hostnames (e.g. `db`, `redis`)
2929

30+
## What discovery checks (and what it can't)
31+
32+
`hack init` (interactive and `--auto`) discovers dev scripts across a repo/monorepo
33+
and runs a validation pass over the results before writing `.hack/docker-compose.yml`.
34+
It catches common scaffolding mistakes automatically, but it is not a substitute for
35+
reviewing the generated compose file:
36+
37+
- **Duplicate/aggregator script dedupe**: when a package defines both `dev` and
38+
`start` (or similar), only the best-scoring script becomes a service — no more
39+
`web` + `web-2`. Root-level "aggregator" scripts that just delegate to a
40+
workspace package's own dev script (e.g. `turbo run dev --filter=web`, or
41+
`dotnet run --project apps/backend`) are also dropped in favor of the
42+
package-local script. In the interactive wizard, the deduped set is
43+
pre-selected by default — you can still add a dropped script back manually.
44+
- **Port reassignment**: HTTP services that would collide on the same internal
45+
port are deterministically reassigned to the next free port (ascending from
46+
the collision), and the container command is rewritten to match. Each
47+
reassignment is logged as a warning.
48+
- **Runtime TODOs**: services whose dev script looks non-JS (`.csproj`/`.fsproj`,
49+
`go.mod`, `Cargo.toml`, `pyproject.toml`/`requirements.txt`, `mix.exs`,
50+
`Gemfile`, or a command like `dotnet run`/`go run`/`cargo`/`python`/`mix`) get an
51+
obviously-wrong placeholder image (`alpine:3`) plus a `TODO(hack-init): ...`
52+
comment above the service in the compose file — instead of silently getting the
53+
default Bun/Node image. You must replace the image and command by hand.
54+
- **Backing-service warnings**: dependencies (`pg`, `ioredis`, `@temporalio/*`,
55+
`kafkajs`, `amqplib`, `mongodb`/`mongoose`, `prisma`, ...) and `.env`/`.env.example`
56+
key names (never values) are scanned for signals of postgres, mysql, redis,
57+
temporal, kafka, rabbitmq, or mongodb. These are **not** auto-scaffolded — they
58+
show up as warnings and as a comment block at the top of the generated compose
59+
file with a one-line suggestion each. If a `docker-compose*.yml`/`compose*.yml`
60+
already exists at the repo root, it's flagged too ("treat it as ground truth for
61+
backing services and images") instead of being re-derived from scratch.
62+
63+
None of this replaces reviewing the scaffold. Discovery is a best-effort heuristic
64+
pass, not ground truth — always inventory the generated `.hack/docker-compose.yml`
65+
against the real repo before running `hack up` (agents included; see the
66+
onboarding prompt for the inventory-first review step).
67+
3068
When the local path is working and you intentionally want remote execution or gateway exposure, move
3169
to [Beta workflows](../beta.md). For full command lookup and extension docs, use
3270
[Extensions & reference](../reference.md).

src/commands/project.ts

Lines changed: 194 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ import {
6464
} from "../constants.ts";
6565
import { readControlPlaneConfig } from "../control-plane/sdk/config.ts";
6666
import { requestDaemonJson } from "../daemon/client.ts";
67-
import { renderCompose } from "../init/compose.ts";
67+
import { renderCompose, TODO_IMAGE_SENTINEL } from "../init/compose.ts";
6868
import type { ServiceCandidate } from "../init/discovery.ts";
6969
import { discoverRepo } from "../init/discovery.ts";
7070
import {
@@ -74,6 +74,21 @@ import {
7474
guessServiceName,
7575
inferPortFromScript,
7676
} from "../init/heuristics.ts";
77+
import type {
78+
InitDiscoveryFinding,
79+
PortCollisionDraft,
80+
} from "../init/validation.ts";
81+
import {
82+
buildDiscoveryHeaderComments,
83+
buildExistingComposeFindings,
84+
dedupeCandidates,
85+
describeUnknownRuntimeComment,
86+
detectBackingServices,
87+
detectPackageRuntime,
88+
findExistingComposeFiles,
89+
reassignCollidingPorts,
90+
reportInitDiscoveryFindings,
91+
} from "../init/validation.ts";
7792
import { resolveEffectiveBranch, touchBranchUsage } from "../lib/branches.ts";
7893
import {
7994
type CliResult,
@@ -3837,7 +3852,9 @@ function logInstallResult(opts: {
38373852
logger.success({ message: `Updated ${opts.label} at ${opts.path}` });
38383853
}
38393854

3840-
interface ComposeWizardInput {
3855+
// Exported for direct unit-testing of the auto (non-interactive) discovery
3856+
// + validation pipeline without going through the CLI prompt flow.
3857+
export interface ComposeWizardInput {
38413858
readonly repoRoot: string;
38423859
readonly devHost: string;
38433860
readonly projectSlug: string;
@@ -4114,13 +4131,27 @@ function validateSubdomain(value: string | undefined): string | undefined {
41144131

41154132
async function selectCandidatesForDiscoveredCompose(opts: {
41164133
readonly candidates: readonly ServiceCandidate[];
4117-
}): Promise<ServiceCandidate[]> {
4134+
}): Promise<{
4135+
readonly selectedCandidates: ServiceCandidate[];
4136+
readonly dedupeFindings: readonly InitDiscoveryFinding[];
4137+
}> {
41184138
const byId = new Map(opts.candidates.map((c) => [c.id, c] as const));
41194139

4140+
// Default-select the deduped set (best script per package, aggregator
4141+
// scripts dropped in favor of the package's own script) so the common
4142+
// case is a single confirm — the user can still add dropped candidates
4143+
// back via the multiselect.
4144+
const dedupe = dedupeCandidates({ candidates: opts.candidates });
4145+
const defaultIds = dedupe.selected.map((c) => c.id);
4146+
const droppedIds = new Set(
4147+
opts.candidates.filter((c) => !defaultIds.includes(c.id)).map((c) => c.id)
4148+
);
4149+
41204150
const selectedIds = unwrapPromptValue(
41214151
await autocompleteMultiselect<string>({
41224152
message: "Select dev scripts to include as services:",
41234153
required: true,
4154+
initialValues: defaultIds,
41244155
options: opts.candidates.map((c) => ({
41254156
value: c.id,
41264157
label: formatCandidateLabel(c),
@@ -4141,7 +4172,13 @@ async function selectCandidatesForDiscoveredCompose(opts: {
41414172
throw new Error("No services selected");
41424173
}
41434174

4144-
return selectedCandidates;
4175+
// Only report dedupe findings when the user kept the pruned selection —
4176+
// if they manually re-added every dropped candidate, there's nothing left
4177+
// to warn about.
4178+
const stillDropped = [...droppedIds].some((id) => !selectedIds.includes(id));
4179+
const dedupeFindings = stillDropped ? dedupe.findings : [];
4180+
4181+
return { selectedCandidates, dedupeFindings };
41454182
}
41464183

41474184
async function promptDraftForDiscoveredCandidate(opts: {
@@ -4227,6 +4264,7 @@ async function promptDraftForDiscoveredCandidate(opts: {
42274264
port: portNum,
42284265
workingDir,
42294266
command,
4267+
candidate: opts.candidate,
42304268
};
42314269
}
42324270

@@ -4280,9 +4318,10 @@ async function promptHttpSubdomainsForDrafts(opts: {
42804318
async function buildDiscoveredCompose(
42814319
input: ComposeWizardInput
42824320
): Promise<string> {
4283-
const selectedCandidates = await selectCandidatesForDiscoveredCompose({
4284-
candidates: input.candidates,
4285-
});
4321+
const { selectedCandidates, dedupeFindings } =
4322+
await selectCandidatesForDiscoveredCompose({
4323+
candidates: input.candidates,
4324+
});
42864325
const usedServiceNames = new Set<string>();
42874326
const drafts: AutoComposeDraft[] = [];
42884327

@@ -4300,13 +4339,40 @@ async function buildDiscoveredCompose(
43004339
devHost: input.devHost,
43014340
});
43024341

4342+
const findings = [...dedupeFindings];
4343+
await applyRuntimeDetectionToDrafts({
4344+
drafts,
4345+
repoRoot: input.repoRoot,
4346+
findings,
4347+
});
4348+
applyPortCollisionReassignment({ drafts, findings });
4349+
4350+
const discoveryForServices = await discoverRepo(input.repoRoot);
4351+
const backingServices = await detectBackingServices({
4352+
repoRoot: input.repoRoot,
4353+
packages: discoveryForServices.packages,
4354+
});
4355+
const existingComposeFiles = await findExistingComposeFiles({
4356+
repoRoot: input.repoRoot,
4357+
});
4358+
findings.push(...buildExistingComposeFindings({ existingComposeFiles }));
4359+
4360+
reportInitDiscoveryFindings({ findings });
4361+
43034362
const services = buildServicesFromDrafts({
43044363
drafts,
43054364
devHost: input.devHost,
43064365
oauth: input.oauth,
43074366
});
43084367

4309-
return renderCompose({ name: input.projectSlug, services });
4368+
return renderCompose({
4369+
name: input.projectSlug,
4370+
services,
4371+
headerComments: buildDiscoveryHeaderComments({
4372+
detections: backingServices,
4373+
existingComposeFiles,
4374+
}),
4375+
});
43104376
}
43114377

43124378
type AutoComposeDraft = {
@@ -4317,16 +4383,105 @@ type AutoComposeDraft = {
43174383
workingDir: string;
43184384
command: string;
43194385
image?: string;
4386+
candidate?: ServiceCandidate;
4387+
comments?: string[];
43204388
};
43214389

4322-
function buildDiscoveredComposeAuto(input: ComposeWizardInput): string {
4390+
/**
4391+
* Runs `detectPackageRuntime` for each draft that carries its source
4392+
* candidate and, for non-JS runtimes, sets the compose TODO-image sentinel
4393+
* plus an explanatory comment instead of silently defaulting to the
4394+
* bun-node image. Mutates `drafts` in place and appends one
4395+
* `unknown-runtime` finding per detected draft.
4396+
*/
4397+
async function applyRuntimeDetectionToDrafts(opts: {
4398+
readonly drafts: AutoComposeDraft[];
4399+
readonly repoRoot: string;
4400+
readonly findings: InitDiscoveryFinding[];
4401+
}): Promise<void> {
4402+
for (const draft of opts.drafts) {
4403+
const candidate = draft.candidate;
4404+
if (!candidate) {
4405+
continue;
4406+
}
4407+
4408+
const runtime = await detectPackageRuntime({
4409+
dir:
4410+
candidate.packageRelativeDir === "."
4411+
? opts.repoRoot
4412+
: resolve(opts.repoRoot, candidate.packageRelativeDir),
4413+
scriptCommand: candidate.scriptCommand,
4414+
repoRoot: opts.repoRoot,
4415+
});
4416+
if (!runtime) {
4417+
continue;
4418+
}
4419+
4420+
draft.image = TODO_IMAGE_SENTINEL;
4421+
draft.comments = [
4422+
...(draft.comments ?? []),
4423+
describeUnknownRuntimeComment({ serviceName: draft.name, runtime }),
4424+
];
4425+
opts.findings.push({
4426+
kind: "unknown-runtime",
4427+
serviceName: draft.name,
4428+
runtime,
4429+
});
4430+
}
4431+
}
4432+
4433+
/**
4434+
* Detects HTTP drafts sharing a port, reassigns duplicates to the next
4435+
* free port (ascending), and rewrites their command via
4436+
* `buildSuggestedCommand`. Mutates `drafts` in place and appends one
4437+
* `port-reassigned` finding per reassignment.
4438+
*/
4439+
function applyPortCollisionReassignment(opts: {
4440+
readonly drafts: AutoComposeDraft[];
4441+
readonly findings: InitDiscoveryFinding[];
4442+
}): void {
4443+
const collisionDrafts: PortCollisionDraft[] = [];
4444+
for (const draft of opts.drafts) {
4445+
if (!draft.candidate) {
4446+
continue;
4447+
}
4448+
collisionDrafts.push({
4449+
name: draft.name,
4450+
role: draft.role,
4451+
port: draft.port,
4452+
candidate: draft.candidate,
4453+
});
4454+
}
4455+
4456+
const { reassignments, findings } = reassignCollidingPorts({
4457+
drafts: collisionDrafts,
4458+
});
4459+
opts.findings.push(...findings);
4460+
4461+
for (const draft of opts.drafts) {
4462+
const reassignment = reassignments.get(draft.name);
4463+
if (!reassignment) {
4464+
continue;
4465+
}
4466+
draft.port = reassignment.port;
4467+
draft.command = reassignment.command;
4468+
}
4469+
}
4470+
4471+
// Exported for direct unit-testing (see tests/init-discovery-validation.test.ts).
4472+
export async function buildDiscoveredComposeAuto(
4473+
input: ComposeWizardInput
4474+
): Promise<string> {
4475+
const dedupe = dedupeCandidates({ candidates: input.candidates });
43234476
const selectedCandidates = selectAutoCandidates({
4324-
candidates: input.candidates,
4477+
candidates: dedupe.selected,
43254478
});
43264479
if (selectedCandidates.length === 0) {
43274480
throw new Error("No dev scripts discovered for auto init.");
43284481
}
43294482

4483+
const findings: InitDiscoveryFinding[] = [...dedupe.findings];
4484+
43304485
const usedServiceNames = new Set<string>();
43314486
const drafts: AutoComposeDraft[] = [];
43324487

@@ -4352,18 +4507,45 @@ function buildDiscoveredComposeAuto(input: ComposeWizardInput): string {
43524507
port,
43534508
workingDir,
43544509
command,
4510+
candidate,
43554511
});
43564512
}
43574513

43584514
assignAutoSubdomains({ drafts });
43594515

4516+
await applyRuntimeDetectionToDrafts({
4517+
drafts,
4518+
repoRoot: input.repoRoot,
4519+
findings,
4520+
});
4521+
applyPortCollisionReassignment({ drafts, findings });
4522+
4523+
const discovery = await discoverRepo(input.repoRoot);
4524+
const backingServices = await detectBackingServices({
4525+
repoRoot: input.repoRoot,
4526+
packages: discovery.packages,
4527+
});
4528+
const existingComposeFiles = await findExistingComposeFiles({
4529+
repoRoot: input.repoRoot,
4530+
});
4531+
findings.push(...buildExistingComposeFindings({ existingComposeFiles }));
4532+
4533+
reportInitDiscoveryFindings({ findings });
4534+
43604535
const services = buildServicesFromDrafts({
43614536
drafts,
43624537
devHost: input.devHost,
43634538
oauth: input.oauth,
43644539
});
43654540

4366-
return renderCompose({ name: input.projectSlug, services });
4541+
return renderCompose({
4542+
name: input.projectSlug,
4543+
services,
4544+
headerComments: buildDiscoveryHeaderComments({
4545+
detections: backingServices,
4546+
existingComposeFiles,
4547+
}),
4548+
});
43674549
}
43684550

43694551
interface ManualComposeWizardInput {
@@ -4691,6 +4873,7 @@ function buildServicesFromDrafts(opts: {
46914873
env,
46924874
labels,
46934875
networks,
4876+
...(d.comments && d.comments.length > 0 ? { comments: d.comments } : {}),
46944877
};
46954878
});
46964879
}

0 commit comments

Comments
 (0)