Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 37 additions & 10 deletions Sources/mcs/Core/CLIOutput.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,8 +289,21 @@ struct CLIOutput {
}

/// Inline text prompt where the user types on the same line as the label.
func promptInline(_ prompt: String, default defaultValue: String? = nil) -> String {
let hint = defaultValue.map { " (\($0))" } ?? ""
///
/// - Parameter maskDefault: When `true`, the hint shows a generic
/// `(press Enter to keep existing value)` placeholder instead of the raw default.
/// Use for defaults that originate from previously-entered user input, which may
/// be sensitive (API keys, tokens). Pack-declared defaults remain visible.
func promptInline(
_ prompt: String,
default defaultValue: String? = nil,
maskDefault: Bool = false
) -> String {
let hint: String = if let defaultValue {
maskDefault ? " (press Enter to keep existing value)" : " (\(defaultValue))"
} else {
""
}
write(" \(bold)\(prompt)\(reset)\(hint): ")
let answer = readLine()?.trimmingCharacters(in: .whitespaces) ?? ""
if answer.isEmpty, let defaultValue {
Expand All @@ -314,21 +327,30 @@ struct CLIOutput {
/// Single-select: arrow keys to navigate, Enter to confirm.
/// Returns the index of the selected item.
/// Falls back to numbered list with readLine() when not a TTY.
func singleSelect(title: String, items: [(name: String, description: String)]) -> Int {
///
/// - Parameter initialIndex: Pre-selected cursor position (clamped to `0..<items.count`).
/// Used to seed the selection with a previously-stored value.
func singleSelect(
title: String,
items: [(name: String, description: String)],
initialIndex: Int = 0
) -> Int {
guard !items.isEmpty else { return 0 }
let seed = max(0, min(initialIndex, items.count - 1))

if isInteractiveTerminal {
return interactiveSingleSelect(title: title, items: items)
return interactiveSingleSelect(title: title, items: items, initialIndex: seed)
}
return fallbackSingleSelect(title: title, items: items)
return fallbackSingleSelect(title: title, items: items, initialIndex: seed)
}

private func interactiveSingleSelect(
title: String,
items: [(name: String, description: String)]
items: [(name: String, description: String)],
initialIndex: Int
) -> Int {
withRawTerminal {
var cursor = 0
var cursor = initialIndex

renderSingleSelectList(title: title, items: items, cursor: cursor)

Expand Down Expand Up @@ -414,7 +436,8 @@ struct CLIOutput {

private func fallbackSingleSelect(
title: String,
items: [(name: String, description: String)]
items: [(name: String, description: String)],
initialIndex: Int
) -> Int {
write("\n")
write(" \(bold)\(title)\(reset)\n")
Expand All @@ -423,7 +446,8 @@ struct CLIOutput {
for (index, item) in items.enumerated() {
if index > 0 { write("\n") }
let num = index + 1
write(" [\(num)] \(bold)\(item.name)\(reset)\n")
let marker = index == initialIndex ? " (default)" : ""
write(" [\(num)] \(bold)\(item.name)\(reset)\(marker)\n")
write(" \(dim)\(item.description)\(reset)\n")
}

Expand All @@ -432,7 +456,10 @@ struct CLIOutput {
while true {
write("\(bold)> \(reset)")
guard let input = readLine()?.trimmingCharacters(in: .whitespaces) else {
return 0
return initialIndex
}
if input.isEmpty {
return initialIndex
}
if let num = Int(input), num >= 1, num <= items.count {
return num - 1
Expand Down
4 changes: 3 additions & 1 deletion Sources/mcs/ExternalPack/ExternalPackAdapter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ struct ExternalPackAdapter: TechPack {

/// Execute prompts and return resolved values, skipping keys already in `context.resolvedValues`.
/// Returns only newly resolved values — callers must merge with previously resolved values.
/// `context.priorValues` are forwarded as per-prompt defaults for `input`/`select`.
func templateValues(context: ProjectConfigContext) throws -> [String: String] {
let prompts = declaredPrompts(context: context)
guard !prompts.isEmpty else { return [:] }
Expand All @@ -91,7 +92,8 @@ struct ExternalPackAdapter: TechPack {
return try executor.executeAll(
prompts: remaining,
packPath: packPath,
projectPath: context.projectPath
projectPath: context.projectPath,
priorValues: context.priorValues
)
}

Expand Down
36 changes: 26 additions & 10 deletions Sources/mcs/ExternalPack/PromptExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,23 @@ struct PromptExecutor {
/// - prompt: The declarative prompt definition
/// - packPath: Root directory of the external pack
/// - projectPath: Current project root directory
/// - priorValue: Value resolved in a previous sync, used as the default for
/// `input`/`select` prompts. Ignored for `script` and `fileDetect` (both
/// re-compute every sync).
/// - Returns: The resolved value string
func execute(
prompt: PromptDefinition,
packPath: URL,
projectPath: URL
projectPath: URL,
priorValue: String? = nil
) throws -> String {
switch prompt.type {
case .fileDetect:
try executeFileDetect(prompt: prompt, projectPath: projectPath)
case .input:
executeInput(prompt: prompt)
executeInput(prompt: prompt, priorValue: priorValue)
case .select:
executeSelect(prompt: prompt)
executeSelect(prompt: prompt, priorValue: priorValue)
case .script:
try executeScript(prompt: prompt, packPath: packPath, projectPath: projectPath)
}
Expand All @@ -51,18 +55,22 @@ struct PromptExecutor {
/// - prompts: Array of prompt definitions
/// - packPath: Root directory of the external pack
/// - projectPath: Current project root directory
/// - priorValues: Values from the previous sync, keyed by prompt key,
/// used as defaults for `input`/`select` prompts.
/// - Returns: Dictionary of prompt key to resolved value
func executeAll(
prompts: [PromptDefinition],
packPath: URL,
projectPath: URL
projectPath: URL,
priorValues: [String: String] = [:]
) throws -> [String: String] {
var resolved: [String: String] = [:]
for prompt in prompts {
let value = try execute(
prompt: prompt,
packPath: packPath,
projectPath: projectPath
projectPath: projectPath,
priorValue: priorValues[prompt.key]
)
resolved[prompt.key] = value
}
Expand Down Expand Up @@ -166,24 +174,32 @@ struct PromptExecutor {
// MARK: - Input

/// Free-text prompt with optional default value.
private func executeInput(prompt: PromptDefinition) -> String {
/// `priorValue` (stored from a previous sync) wins over `prompt.defaultValue` as the
/// Enter-to-accept default. Prior values are masked in the prompt hint — pack-declared
/// defaults are authored publicly and remain visible.
private func executeInput(prompt: PromptDefinition, priorValue: String?) -> String {
let label = prompt.label ?? "Enter value for \(prompt.key)"
return output.promptInline(label, default: prompt.defaultValue)
let effectiveDefault = priorValue ?? prompt.defaultValue
return output.promptInline(label, default: effectiveDefault, maskDefault: priorValue != nil)
}

// MARK: - Select

/// Single choice from a fixed list of options.
private func executeSelect(prompt: PromptDefinition) -> String {
/// `priorValue` (stored from a previous sync) becomes the pre-selected option when
/// it matches an option value; otherwise the stored value is ignored (caller should
/// have already purged invalid select priors upstream).
private func executeSelect(prompt: PromptDefinition, priorValue: String?) -> String {
guard let options = prompt.options, !options.isEmpty else {
return prompt.defaultValue ?? ""
return priorValue ?? prompt.defaultValue ?? ""
}

let items = options.map { option -> (name: String, description: String) in
(name: option.label, description: option.value)
}
let label = prompt.label ?? "Select value for \(prompt.key)"
let selected = output.singleSelect(title: label, items: items)
let initialIndex = PromptOption.index(of: priorValue, in: options)
let selected = output.singleSelect(title: label, items: items, initialIndex: initialIndex)
return options[selected].value
}

Expand Down
127 changes: 102 additions & 25 deletions Sources/mcs/Sync/Configurator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,7 +138,12 @@ struct Configurator {
try self.dryRun(packs: selectedPacks)
} else {
// Interactive flow already confirmed via the "Review changes" screen above.
try configure(packs: selectedPacks, confirmRemovals: false, excludedComponents: excludedComponents)
try configure(
packs: selectedPacks,
confirmRemovals: false,
excludedComponents: excludedComponents,
customize: customize
)

output.header("Done")
output.info("Run 'mcs doctor' to verify configuration")
Expand Down Expand Up @@ -176,7 +181,8 @@ struct Configurator {
func configure(
packs: [any TechPack],
confirmRemovals: Bool = true,
excludedComponents: [String: Set<String>] = [:]
excludedComponents: [String: Set<String>] = [:],
customize: Bool = false
) throws {
var state = try ProjectState(stateFile: scope.stateFile)
let fsContext = strategy.makeCollisionContext(trackedFiles: state.allTrackedFiles)
Expand Down Expand Up @@ -226,8 +232,13 @@ struct Configurator {
}
}

// Snapshot prior values before resolveAllValues overwrites them via
// state.setResolvedValues — used later by the configureProject hook so
// packs can diff prior vs current during migrations.
let priorResolvedValues = state.resolvedValues ?? [:]

// 3–4b. Resolve all template/placeholder values upfront (single pass)
let allValues = try resolveAllValues(packs: packs, state: &state)
let allValues = try resolveAllValues(packs: packs, state: &state, customize: customize)

// 4c. Pre-load templates (single disk read per pack), filtering excluded dependencies
let preloadedTemplates = preloadTemplates(
Expand Down Expand Up @@ -279,7 +290,11 @@ struct Configurator {

// 8. Run pack-specific configureProject hooks (project scope only)
if scope.runConfigureProjectHooks {
let hookContext = strategy.makeConfigContext(output: output, resolvedValues: allValues)
let hookContext = strategy.makeConfigContext(
output: output,
resolvedValues: allValues,
priorValues: priorResolvedValues
)
let projectPath = scope.targetPath.deletingLastPathComponent()
for pack in packs {
try pack.configureProject(at: projectPath, context: hookContext)
Expand Down Expand Up @@ -588,55 +603,117 @@ struct Configurator {

/// Resolve all template/placeholder values upfront (single pass).
///
/// Resolves built-in values (e.g. REPO_NAME), shared cross-pack prompts,
/// per-pack prompts, and auto-prompts for undeclared placeholders.
/// Persists resolved values to state for doctor freshness checks.
/// Reuses values from the previous sync when safe:
/// - `input` priors are reused verbatim.
/// - `select` priors are reused only if the stored value is still a valid option.
/// - `script` and `fileDetect` always re-execute (computed / filesystem-dependent).
private func resolveAllValues(
packs: [any TechPack],
state: inout ProjectState
state: inout ProjectState,
customize: Bool
) throws -> [String: String] {
// 3a. Built-in values (REPO_NAME, PROJECT_DIR_NAME in project scope)
let priorValues = state.resolvedValues ?? [:]
var allValues = strategy.resolveBuiltInValues(shell: shell, output: output)

// 3b–3c. Detect shared prompts across packs and resolve them once.
// `initialContext` uses partial resolvedValues (built-ins only). groupSharedPrompts
// filters out already-resolved keys; current TechPack implementations only use
// isGlobalScope from the context in declaredPrompts().
let initialContext = strategy.makeConfigContext(output: output, resolvedValues: allValues)
let sharedPrompts = CrossPackPromptResolver.groupSharedPrompts(
let initialContext = strategy.makeConfigContext(
output: output, resolvedValues: allValues, priorValues: priorValues
)
let allDeclaredPrompts = CrossPackPromptResolver.collectDeclaredPrompts(
packs: packs, context: initialContext
)
Comment thread
bguidolim marked this conversation as resolved.
let (reusableValues, newDeclaredKeys) = CrossPackPromptResolver.partitionDeclaredPrompts(
allDeclaredPrompts, priorValues: priorValues
)

let seedFromPriors = decideSeedStrategy(
reusableValues: reusableValues,
newDeclaredKeys: newDeclaredKeys,
customize: customize
)
if seedFromPriors {
allValues.merge(reusableValues) { existing, _ in existing }
}

let sharedContext = strategy.makeConfigContext(
output: output, resolvedValues: allValues, priorValues: priorValues
)
let sharedPrompts = CrossPackPromptResolver.groupSharedPrompts(
packs: packs, context: sharedContext
)
if !sharedPrompts.isEmpty {
let sharedValues = CrossPackPromptResolver.resolveSharedPrompts(sharedPrompts, output: output)
let sharedValues = CrossPackPromptResolver.resolveSharedPrompts(
sharedPrompts, output: output, priorValues: priorValues
)
allValues.merge(sharedValues) { existing, _ in existing }
}

// 3d. Execute remaining per-pack prompts. templateValues() skips prompts whose key
// already exists in context.resolvedValues (pre-resolved by shared prompt resolution).
// Merge uses "first wins" — shared values and built-ins take precedence.
let context = strategy.makeConfigContext(output: output, resolvedValues: allValues)
let context = strategy.makeConfigContext(
output: output, resolvedValues: allValues, priorValues: priorValues
)
for pack in packs {
let packValues = try pack.templateValues(context: context)
allValues.merge(packValues) { existing, _ in existing }
}

// 4. Auto-prompt for undeclared placeholders in pack files
let undeclared = ConfiguratorSupport.scanForUndeclaredPlaceholders(
packs: packs, resolvedValues: allValues,
includeTemplates: scope.includeTemplatesInScan,
onWarning: { output.warn($0) }
)
for key in undeclared {
let value = output.promptInline("Set value for \(key)", default: nil)
allValues[key] = value
if seedFromPriors, let prior = priorValues[key] {
allValues[key] = prior
continue
}
let prior = priorValues[key]
allValues[key] = output.promptInline(
"Set value for \(key)",
default: prior,
maskDefault: prior != nil
)
}

// 4b. Persist resolved values for doctor freshness checks
state.setResolvedValues(allValues)

return allValues
}

/// Returns `true` when reusable priors should short-circuit the prompt executors.
///
/// `--customize` always re-asks. Non-interactive reuses with a dimmed one-line
/// acknowledgement (visible in CI logs, not loud). Interactive with new prompts
/// added since last sync reuses old values and prompts only for the new ones.
/// Interactive with only reusable prompts shows the key list (values masked —
/// prompts often hold secrets) and gates on a single Y/n.
private func decideSeedStrategy(
reusableValues: [String: String],
newDeclaredKeys: Set<String>,
customize: Bool
) -> Bool {
guard !reusableValues.isEmpty else { return false }
if customize { return false }

if !output.hasInteractiveStdin {
output.dimmed("Reusing \(reusableValues.count) previously configured value(s) from last sync.")
return true
Comment thread
bguidolim marked this conversation as resolved.
}

if !newDeclaredKeys.isEmpty {
output.plain("")
output.info(
"Reusing \(reusableValues.count) previously configured value(s); "
+ "asking for \(newDeclaredKeys.count) new."
)
return true
}

output.plain("")
output.info("Previously configured keys (values hidden — prompts may hold secrets):")
for key in reusableValues.keys.sorted() {
output.dimmed(" \(key)")
}
return output.askYesNo("Reuse these values?", default: true)
}

/// Pre-load templates from disk (single read per pack), filtering excluded dependencies.
///
/// Templates whose `dependencies` include an excluded component are filtered out,
Expand Down
Loading
Loading