@@ -64,7 +64,7 @@ import {
6464} from "../constants.ts" ;
6565import { readControlPlaneConfig } from "../control-plane/sdk/config.ts" ;
6666import { requestDaemonJson } from "../daemon/client.ts" ;
67- import { renderCompose } from "../init/compose.ts" ;
67+ import { renderCompose , TODO_IMAGE_SENTINEL } from "../init/compose.ts" ;
6868import type { ServiceCandidate } from "../init/discovery.ts" ;
6969import { discoverRepo } from "../init/discovery.ts" ;
7070import {
@@ -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" ;
7792import { resolveEffectiveBranch , touchBranchUsage } from "../lib/branches.ts" ;
7893import {
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
41154132async 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
41474184async 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: {
42804318async 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
43124378type 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
43694551interface 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