From af6a2ffd2c22fe7eb6c451b07632521a09f6b1ec Mon Sep 17 00:00:00 2001 From: Yevhenii Hyzyla Date: Sun, 16 Aug 2026 19:20:10 +0200 Subject: [PATCH 1/3] Ignore the workspace-root .vscode/settings.json --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index c063f0bc..a11104dd 100644 --- a/.gitignore +++ b/.gitignore @@ -21,6 +21,7 @@ sweetpad-vscode/native/index.d.ts !sweetpad-vscode/.vscode/ sweetpad-vscode/.vscode/extensions.json sweetpad-vscode/.vscode/settings.json +/.vscode/settings.json .env From db7af826d1ca092755e05cfcd1bb35b7f5ad320b Mon Sep 17 00:00:00 2001 From: Yevhenii Hyzyla Date: Sun, 16 Aug 2026 19:47:21 +0200 Subject: [PATCH 2/3] Support xcodebuild passthrough args on app install, debug, and diagnose --- sweetpad-cli/CLI_DESIGN.md | 14 ++ sweetpad-cli/src/cli/commands/app.rs | 199 ++++++++++++++------------- sweetpad-cli/src/cli/mod.rs | 30 ++++ sweetpad-cli/src/cli/xcodebuild.rs | 11 +- sweetpad-docs/docs/cli/cli.md | 20 +++ sweetpad-docs/docs/cli/reference.md | 40 ++++++ 6 files changed, 214 insertions(+), 100 deletions(-) diff --git a/sweetpad-cli/CLI_DESIGN.md b/sweetpad-cli/CLI_DESIGN.md index 022d1841..7ede0a86 100644 --- a/sweetpad-cli/CLI_DESIGN.md +++ b/sweetpad-cli/CLI_DESIGN.md @@ -118,6 +118,16 @@ Destination selection is `--on ` (fuzzy name / `booted` / `mac` / `--destination` as the raw escape hatch. `-o json|ndjson` is the machine surface (§4). +The `-- XCODEBUILD_ARGS` tail follows the build. `build`, `test`, `archive`, +and the `app` verbs that spawn `xcodebuild` — `run`, `install`, `debug`, +`diagnose` — all take it; the verbs that only act on an already-installed app +(`launch`, `uninstall`, `logs`, `stop`) refuse it, because args that reach no +`xcodebuild` would be accepted and silently dropped. A passthrough +`-derivedDataPath` is read back out and handed to the in-process resolver, so +the app the CLI installs is the one the build just wrote; the settings that +relocate the product where the resolver cannot follow (`SYMROOT=`, `OBJROOT=`, +`CONFIGURATION_BUILD_DIR=`) are refused before a build is spent on them. + ## 3a. `project new` — scaffolding `project new` creates a fresh, buildable **minimal SwiftUI iOS app** with no @@ -1602,9 +1612,13 @@ passthrough as the escape hatch. ``` sweetpad app diagnose [--mac|--device] [--arg A] [--env K=V] [--timeout SECS] + [-- XCODEBUILD_ARGS] sweetpad app debug --batch [--cmd LLDB_CMD]… [--on-crash LLDB_CMD]… [--timeout SECS] + [-- XCODEBUILD_ARGS] ``` +Both build before they hand off to lldb, so both take the `--` tail (§3). + **`app diagnose`** is the agent-facing verb: build, launch under `lldb -b` with a breakpoint on `objc_exception_throw`, run bounded by `--timeout`, and on the first stop print a structured report — `stopReason`, `signal`, `exitStatus`, diff --git a/sweetpad-cli/src/cli/commands/app.rs b/sweetpad-cli/src/cli/commands/app.rs index 4dfc9868..b1493e8c 100644 --- a/sweetpad-cli/src/cli/commands/app.rs +++ b/sweetpad-cli/src/cli/commands/app.rs @@ -24,7 +24,6 @@ use crate::cli::{ CliError, CliResult, CommandResult, Context, ErrorContext, ErrorKind, Render, Rendered, buildlog, devicectl, oslog, process, pymobiledevice3, rawmode, simctl, }; -use sweetpad_core::build_settings::BuildSettingsOptions; /// The `app run` flags — also the top-level `sweetpad run`'s, so the flagship /// spelling and the resource-first one stay a single definition. @@ -108,7 +107,18 @@ pub struct RunArgs { #[command(flatten)] pub launch: LaunchArgs, - /// Extra arguments passed to xcodebuild verbatim (after '--'). + #[command(flatten)] + pub xcodebuild: XcodebuildArgs, +} + +/// The `--` tail the `app` verbs that build accept, so the escape hatch is one +/// definition and one spelling across `run`, `install`, `debug`, and +/// `diagnose`. The verbs that only act on an installed app don't carry it: +/// they spawn no xcodebuild, so the args would go nowhere. +#[derive(Debug, Clone, Default, clap::Args)] +pub struct XcodebuildArgs { + /// Extra arguments passed to xcodebuild verbatim (after '--'), e.g. + /// 'sweetpad app install -- -allowProvisioningUpdates KEY=VALUE'. #[arg(last = true, value_name = "XCODEBUILD_ARGS")] pub passthrough: Vec, } @@ -327,6 +337,8 @@ pub enum Action { target: crate::cli::BuildTargetArgs, #[command(flatten)] stage: StageTargetArgs, + #[command(flatten)] + xcodebuild: XcodebuildArgs, }, /// Launch an already-installed app. Launch { @@ -349,6 +361,8 @@ pub enum Action { launch: LaunchArgs, #[command(flatten)] batch: DebugBatchArgs, + #[command(flatten)] + xcodebuild: XcodebuildArgs, }, /// Run the app under lldb, catch the first Objective-C exception or crash, /// print a structured report, and quit. Built for unattended/agent use: @@ -365,6 +379,8 @@ pub enum Action { /// exits, killing it and reporting a timeout (0 disables). Default 30. #[arg(long, value_name = "SECS", default_value_t = 30)] timeout: u64, + #[command(flatten)] + xcodebuild: XcodebuildArgs, }, /// Remove the app from a simulator or device. Uninstall { @@ -618,14 +634,24 @@ pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { keep_sandbox: args.keep_sandbox, hot_entitlements: args.hot_entitlements.as_deref(), launch: &args.launch, - passthrough: &args.passthrough, + passthrough: &args.xcodebuild.passthrough, }, ) } - Action::Install { target, stage } => { + Action::Install { + target, + stage, + xcodebuild, + } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; - simple(ctx, Stage::Install, &LaunchArgs::default(), stage) + simple( + ctx, + Stage::Install, + &LaunchArgs::default(), + stage, + &xcodebuild.passthrough, + ) } Action::Launch { target, @@ -634,32 +660,34 @@ pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; - simple(ctx, Stage::Launch, launch, stage) + simple(ctx, Stage::Launch, launch, stage, &[]) } Action::Debug { target, stage, launch, batch, + xcodebuild, } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; - debug(ctx, stage, launch, batch) + debug(ctx, stage, launch, batch, &xcodebuild.passthrough) } Action::Diagnose { target, stage, launch, timeout, + xcodebuild, } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; - diagnose(ctx, stage, launch, *timeout) + diagnose(ctx, stage, launch, *timeout, &xcodebuild.passthrough) } Action::Uninstall { target, stage } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; - simple(ctx, Stage::Uninstall, &LaunchArgs::default(), stage) + simple(ctx, Stage::Uninstall, &LaunchArgs::default(), stage, &[]) } Action::Logs { target, @@ -673,7 +701,7 @@ pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { Action::Stop { target, stage } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; - simple(ctx, Stage::Stop, &LaunchArgs::default(), stage) + simple(ctx, Stage::Stop, &LaunchArgs::default(), stage, &[]) } Action::OpenUrl { url, simulator } => open_url(ctx, url, simulator.as_deref()), Action::Screenshot(args) => { @@ -817,74 +845,13 @@ impl RunPlan { } } - /// The `-derivedDataPath` the passthrough hands xcodebuild, if any — the - /// app locator must look where the build actually put the product. - /// Product-relocating build settings the locator can't model (`SYMROOT=`, - /// `OBJROOT=`, `CONFIGURATION_BUILD_DIR=`) are refused loudly: silently - /// looking in the default DerivedData would install whatever stale `.app` - /// a previous plain build left there. - fn passthrough_derived_data(&self) -> Result, CliError> { - let mut derived_data = None; - let mut iter = self.passthrough.iter().peekable(); - while let Some(arg) = iter.next() { - if arg == "-derivedDataPath" { - derived_data = iter.peek().map(std::path::PathBuf::from); - } else if let Some((key, _)) = arg.split_once('=') - && matches!(key, "SYMROOT" | "OBJROOT" | "CONFIGURATION_BUILD_DIR") - { - return Err(CliError::new(format!( - "`-- {key}=…` relocates the built product where the app locator can't \ - follow; use `-- -derivedDataPath ` instead" - ))); - } - } - Ok(derived_data) - } - - /// Resolve every target's build settings via the in-process resolver (the - /// engine behind `settings show`), with no xcodebuild spawn — including a - /// passthrough `-derivedDataPath`. Swift packages never reach here — they - /// run via `swift run`, not a build/install/launch. + /// Resolve every target's build settings for this plan — the same + /// [`xcodebuild::resolved_settings`] the build side reports its product + /// from, so the two can't disagree about where the `.app` landed. Swift + /// packages never reach here: they run via `swift run`, not a + /// build/install/launch. fn resolved_settings(&self) -> Result, CliError> { - let (project, workspace) = match &self.resolved.container { - resolve::Container::Project(p) => (Some(p.clone()), None), - resolve::Container::Workspace(p) => (None, Some(p.clone())), - resolve::Container::SwiftPackage(_) => { - return Err(CliError::new("Swift packages have no .app bundle")); - } - }; - let opts = BuildSettingsOptions { - project, - workspace, - scheme: Some(self.scheme.clone()), - target: None, - configuration: self.configuration.clone(), - // Must match the build's own -sdk (if any), or TARGET_BUILD_DIR - // points at a different products dir than the one just built. - sdk: self.resolved.sdk.clone().unwrap_or_default(), - arch: String::new(), - destination: sweetpad_lib::destination::parse_destination_arg(&self.destination), - xcconfig: None, - xcode: None, - xcspec_root: None, - sdksettings_root: None, - catalog_cache: None, - derived_data_path: self.passthrough_derived_data()?, - // We go on to install and launch what this resolves, so it has to - // name the bundle `xcodebuild` actually wrote — including when the - // user has moved Derived Data in Xcode (issue #306). - read_xcode_locations: true, - keys: None, - }; - let resolved = - sweetpad_core::build_settings::resolve_build_settings(&opts).map_err(CliError::new)?; - Ok(resolved - .into_iter() - .map(|t| xcodebuild::TargetBuildSettings { - target: t.target, - settings: t.settings, - }) - .collect()) + xcodebuild::resolved_settings(&self.build_plan()) } /// Locate the built `.app`: [`resolved_settings`](Self::resolved_settings) @@ -1033,19 +1000,23 @@ fn run_app(ctx: &mut Context, opts: &RunOpts) -> CommandResult { result } -/// The in-process resolver that locates the built .app can't see passthrough -/// flags that move xcodebuild's output — installing a stale bundle from -/// default DerivedData would silently run old code. Warn instead. +/// The first passthrough flag that moves xcodebuild's output past where the +/// in-process app locator looks, if any. `-derivedDataPath` is not one of +/// them — [`xcodebuild::passthrough_derived_data`] follows it, and refuses the +/// relocating build settings outright — so this is the pair the locator can +/// neither follow nor recognize: a `TARGET_BUILD_DIR=` override, and an +/// `-xcconfig` free to set any of them from a file. +fn passthrough_moves_output(passthrough: &[String]) -> Option<&String> { + passthrough + .iter() + .find(|t| *t == "-xcconfig" || t.starts_with("TARGET_BUILD_DIR=")) +} + +/// Say so when the build's products land somewhere the install step won't +/// look: installing a stale bundle from default DerivedData would silently +/// run old code. fn warn_if_passthrough_moves_output(ctx: &Context, passthrough: &[String]) { - let moves_output = |t: &String| { - t == "-derivedDataPath" - || t == "-xcconfig" - || t.starts_with("SYMROOT=") - || t.starts_with("OBJROOT=") - || t.starts_with("CONFIGURATION_BUILD_DIR=") - || t.starts_with("TARGET_BUILD_DIR=") - }; - if let Some(flag) = passthrough.iter().find(|t| moves_output(t)) { + if let Some(flag) = passthrough_moves_output(passthrough) { ctx.out.warn(&format!( "{flag} can move the build output, but the app is installed from the \ default build location — the launched bundle may be stale or missing" @@ -1173,7 +1144,7 @@ fn plan(ctx: &mut Context, opts: &RunOpts) -> Result { }; // A product-relocating passthrough the app locator can't follow fails // here, before a build is spent on it. - plan.passthrough_derived_data()?; + xcodebuild::passthrough_derived_data(&plan.passthrough)?; // A hot macOS build may need to sign with an ephemeral sandbox-stripped // entitlements file (§9d zero-config sandbox stripping) — settled here so // every session build (including `r` rebuilds) carries the override. @@ -3539,6 +3510,7 @@ fn simple( stage: Stage, launch: &LaunchArgs, stage_target: &StageTargetArgs, + passthrough: &[String], ) -> CommandResult { let on_device = stage_target.device || stage_target.device_id.is_some(); // `stop` acts on the *running* app: when a launch is recorded, use it @@ -3569,7 +3541,7 @@ fn simple( keep_sandbox: false, hot_entitlements: None, launch, - passthrough: &[], + passthrough, }; let plan = plan(ctx, &opts)?; let app = plan.app_bundle()?; @@ -3784,7 +3756,11 @@ fn stop_mac(ctx: &Context, executable: &Path, bundle_id: &str) -> Result(stage_target: &'a StageTargetArgs, launch: &'a LaunchArgs) -> RunOpts<'a> { +fn lldb_run_opts<'a>( + stage_target: &'a StageTargetArgs, + launch: &'a LaunchArgs, + passthrough: &'a [String], +) -> RunOpts<'a> { RunOpts { device: stage_target.device || stage_target.device_id.is_some(), device_id: stage_target.device_id.as_deref(), @@ -3798,7 +3774,7 @@ fn lldb_run_opts<'a>(stage_target: &'a StageTargetArgs, launch: &'a LaunchArgs) keep_sandbox: false, hot_entitlements: None, launch, - passthrough: &[], + passthrough, } } @@ -3841,6 +3817,7 @@ fn debug( stage_target: &StageTargetArgs, launch: &LaunchArgs, batch: &DebugBatchArgs, + passthrough: &[String], ) -> CommandResult { // `--batch` streams lldb's output live; like `app run` there's no coherent // one-shot JSON for it. Point at `app diagnose` for a structured report. @@ -3850,7 +3827,7 @@ fn debug( `app diagnose -o json` for a structured exception/crash report", )); } - let opts = lldb_run_opts(stage_target, launch); + let opts = lldb_run_opts(stage_target, launch, passthrough); let plan = plan(ctx, &opts)?; match &plan.target { // A macOS app runs on this machine, so lldb can own the launch @@ -4567,8 +4544,9 @@ fn diagnose( stage_target: &StageTargetArgs, launch: &LaunchArgs, timeout_secs: u64, + passthrough: &[String], ) -> CommandResult { - let opts = lldb_run_opts(stage_target, launch); + let opts = lldb_run_opts(stage_target, launch, passthrough); let plan = plan(ctx, &opts)?; match &plan.target { Target::Mac => diagnose_mac(ctx, &plan, timeout_secs), @@ -5717,6 +5695,37 @@ mod tests { assert_eq!(launched_pid("com.example.App: not-a-pid"), None); } + /// The warning covers what the locator can neither follow nor refuse. + /// Warning about the rest reads as "this may not work" over cases that + /// either work or fail loudly a few lines later. + #[test] + fn only_the_relocations_the_locator_misses_are_warned_about() { + let argv = |args: &[&str]| args.iter().map(|s| (*s).to_string()).collect::>(); + + // Followed: the resolver takes the same -derivedDataPath the build does. + assert!(passthrough_moves_output(&argv(&["-derivedDataPath", "/tmp/dd"])).is_none()); + // Refused outright by `xcodebuild::passthrough_derived_data`. + for relocating in [ + "SYMROOT=/tmp/s", + "OBJROOT=/tmp/o", + "CONFIGURATION_BUILD_DIR=/tmp/c", + ] { + assert!( + passthrough_moves_output(&argv(&[relocating])).is_none(), + "{relocating}" + ); + assert!( + xcodebuild::passthrough_derived_data(&argv(&[relocating])).is_err(), + "{relocating}" + ); + } + // Neither followed nor refused — the warning's whole remit. + assert!(passthrough_moves_output(&argv(&["-xcconfig", "Over.xcconfig"])).is_some()); + assert!(passthrough_moves_output(&argv(&["TARGET_BUILD_DIR=/tmp/t"])).is_some()); + // An ordinary flag says nothing about the products dir. + assert!(passthrough_moves_output(&argv(&["-allowProvisioningUpdates"])).is_none()); + } + #[test] fn a_detached_launch_narrates_itself_in_the_order_it_happened() { // The hint names the app the line above launched, so it has to come diff --git a/sweetpad-cli/src/cli/mod.rs b/sweetpad-cli/src/cli/mod.rs index f174476d..9d1b2dfa 100644 --- a/sweetpad-cli/src/cli/mod.rs +++ b/sweetpad-cli/src/cli/mod.rs @@ -1655,6 +1655,36 @@ mod cli_definition_tests { ); } } + + /// The `-- XCODEBUILD_ARGS` tail follows the build: every `app` verb that + /// spawns xcodebuild takes it, and the verbs that only act on an installed + /// app refuse it rather than accept args that reach nothing. + #[test] + fn the_app_verbs_that_build_take_the_xcodebuild_passthrough() { + use crate::cli::commands::app; + use clap::Parser; + + let parse = |verb: &str| { + super::Cli::try_parse_from(["sweetpad", "app", verb, "--", "-allowProvisioningUpdates"]) + }; + let tail = |verb: &str| match parse(verb).expect("`--` tail rejected").resource { + Some(super::Resource::App { action }) => match action.expect("no action parsed") { + app::Action::Install { xcodebuild, .. } + | app::Action::Debug { xcodebuild, .. } + | app::Action::Diagnose { xcodebuild, .. } => xcodebuild.passthrough, + app::Action::Run(args) => args.xcodebuild.passthrough, + other => panic!("`app {verb}` parsed as {other:?}"), + }, + other => panic!("`app {verb}` parsed as {other:?}"), + }; + + for verb in ["run", "install", "debug", "diagnose"] { + assert_eq!(tail(verb), ["-allowProvisioningUpdates"], "app {verb}"); + } + for verb in ["launch", "uninstall", "stop", "logs"] { + assert!(parse(verb).is_err(), "app {verb} accepted a `--` tail"); + } + } } #[cfg(test)] diff --git a/sweetpad-cli/src/cli/xcodebuild.rs b/sweetpad-cli/src/cli/xcodebuild.rs index c0ab2238..df871f48 100644 --- a/sweetpad-cli/src/cli/xcodebuild.rs +++ b/sweetpad-cli/src/cli/xcodebuild.rs @@ -1100,8 +1100,9 @@ fn bundle_of(t: &TargetBuildSettings) -> Option { /// Product-relocating build settings the locator can't model (`SYMROOT=`, /// `OBJROOT=`, `CONFIGURATION_BUILD_DIR=`) are refused loudly: looking in the /// default DerivedData would name whatever stale `.app` an earlier plain build -/// left there. -fn passthrough_derived_data(passthrough: &[String]) -> Result, CliError> { +/// left there. Public so `app`'s run plan can spend the refusal before a build +/// rather than after one. +pub fn passthrough_derived_data(passthrough: &[String]) -> Result, CliError> { let mut derived_data = None; let mut iter = passthrough.iter().peekable(); while let Some(arg) = iter.next() { @@ -1125,9 +1126,9 @@ fn passthrough_derived_data(passthrough: &[String]) -> Result, C /// [`app_bundle`] to name the product a build of this plan writes. Swift /// packages build no `.app`, so they have nothing to resolve here. /// -/// `app`'s `RunPlan` resolves the same way for its install/launch path; both -/// locators must agree on the products dir or the CLI reports one bundle and -/// installs another. +/// `app`'s `RunPlan` locates its install/launch bundle through this same +/// function: one locator, so the CLI cannot report one bundle and install +/// another. pub fn resolved_settings(plan: &BuildPlan<'_>) -> Result, CliError> { let (project, workspace) = match plan.container { Container::Project(p) => (Some(p.clone()), None), diff --git a/sweetpad-docs/docs/cli/cli.md b/sweetpad-docs/docs/cli/cli.md index 915be830..fe432063 100644 --- a/sweetpad-docs/docs/cli/cli.md +++ b/sweetpad-docs/docs/cli/cli.md @@ -131,6 +131,26 @@ sweetpad status To change or clear the remembered choices, use `sweetpad context`. For all the ways to describe a destination, run `sweetpad help destinations`. +## Passing options straight to xcodebuild + +SweetPad has its own flags for the things you reach for daily, but it doesn't wrap all of +`xcodebuild`. Anything you write after `--` is handed to `xcodebuild` untouched, so one unusual +option doesn't send you back to the raw tool: + +```bash +sweetpad app install -- -allowProvisioningUpdates # let Xcode fix up signing +sweetpad build -- SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG STAGING" +sweetpad app install --device -- DEVELOPMENT_TEAM=ABCDE12345 +sweetpad run -- -derivedDataPath ./build # build somewhere else +``` + +Both shapes work: `xcodebuild`'s own flags, and `KEY=VALUE` build-setting overrides. + +The commands that run a build take it — `build`, `test`, `archive`, `run`, `app install`, +`app debug`, and `app diagnose`. The ones that only act on an app that's already installed +(`app launch`, `app stop`, `app logs`, `app uninstall`) build nothing, so they turn a `--` down +rather than accept options that would go nowhere. + ## Live reload while you edit `sweetpad run --hot` keeps your app running and applies each Swift file you save without a full diff --git a/sweetpad-docs/docs/cli/reference.md b/sweetpad-docs/docs/cli/reference.md index 9a880522..2186f503 100644 --- a/sweetpad-docs/docs/cli/reference.md +++ b/sweetpad-docs/docs/cli/reference.md @@ -63,6 +63,46 @@ The CLI describes itself, and that is the authority: `sweetpad --help` lists the | `sweetpad app screenshot` | Save a PNG of the running app — a macOS app's window, or the simulator it launched on. | | `sweetpad app ui` | Read or drive a macOS app's UI through accessibility: `ui tree`, `ui click`, `ui type`. | +### Extra xcodebuild arguments + +Anything after `--` goes to `xcodebuild` verbatim — its own flags, or `KEY=VALUE` build-setting +overrides: + +```bash +# xcodebuild flags +sweetpad app install -- -allowProvisioningUpdates +sweetpad build -- -parallelizeTargets +sweetpad archive -- -allowProvisioningUpdates + +# build-setting overrides +sweetpad build -- SWIFT_ACTIVE_COMPILATION_CONDITIONS="DEBUG STAGING" +sweetpad app install --device -- DEVELOPMENT_TEAM=ABCDE12345 + +# both at once, and combined with SweetPad's own flags +sweetpad app install --on "iPhone 16 Pro" -- -derivedDataPath ./build ENABLE_TESTABILITY=YES +``` + +The commands that run a build accept it: `build`, `test`, `archive`, and `app run`, `app install`, +`app debug`, `app diagnose`. The `app` commands that only act on an already-installed app — +`launch`, `uninstall`, `logs`, `stop` — reject it rather than accept arguments that would reach no +`xcodebuild`. + +A `-derivedDataPath` in the tail is honored when locating the built `.app`, so the bundle SweetPad +installs is the one the build just wrote: + +```bash +sweetpad app install -- -derivedDataPath /tmp/dd # builds and installs from /tmp/dd +``` + +Overrides that move the product somewhere the locator can't follow are rejected up front, before a +build is spent on them — use `-derivedDataPath` instead: + +```bash +sweetpad app install -- SYMROOT=/tmp/out +# error: `-- SYMROOT=…` relocates the built product where the app locator +# can't follow; use `-- -derivedDataPath ` instead +``` + ### Simulators Alias: `sim`. Most take an optional target (name or UDID) and default to the booted simulator. From 07c2a78e28e6cea3af2bda50a4c6ef18de71ad32 Mon Sep 17 00:00:00 2001 From: Yevhenii Hyzyla Date: Sun, 16 Aug 2026 19:50:14 +0200 Subject: [PATCH 3/3] Add a sweetpad.toml [xcodebuild] args key for project-wide build flags --- sweetpad-cli/CLI_DESIGN.md | 27 +++- sweetpad-cli/src/cli/commands/app.rs | 19 ++- sweetpad-cli/src/cli/commands/archive.rs | 3 +- sweetpad-cli/src/cli/commands/build.rs | 3 + sweetpad-cli/src/cli/commands/help_topics.rs | 21 ++- sweetpad-cli/src/cli/commands/status.rs | 17 +++ sweetpad-cli/src/cli/commands/test.rs | 3 +- sweetpad-cli/src/cli/config.rs | 139 ++++++++++++++++++- sweetpad-cli/src/cli/mod.rs | 25 ++++ sweetpad-docs/docs/cli/cli.md | 14 ++ sweetpad-docs/docs/cli/reference.md | 41 +++++- 11 files changed, 298 insertions(+), 14 deletions(-) diff --git a/sweetpad-cli/CLI_DESIGN.md b/sweetpad-cli/CLI_DESIGN.md index 7ede0a86..aa0f7a32 100644 --- a/sweetpad-cli/CLI_DESIGN.md +++ b/sweetpad-cli/CLI_DESIGN.md @@ -126,7 +126,9 @@ and the `app` verbs that spawn `xcodebuild` — `run`, `install`, `debug`, `-derivedDataPath` is read back out and handed to the in-process resolver, so the app the CLI installs is the one the build just wrote; the settings that relocate the product where the resolver cannot follow (`SYMROOT=`, `OBJROOT=`, -`CONFIGURATION_BUILD_DIR=`) are refused before a build is spent on them. +`CONFIGURATION_BUILD_DIR=`) are refused before a build is spent on them. A +project that always needs the same argument writes it in `sweetpad.toml`'s +`[xcodebuild] args` instead of typing it each time (§6). ## 3a. `project new` — scaffolding @@ -395,8 +397,31 @@ hot_recompiler = "resolver" [format] tool = "swiftlint" + +[xcodebuild] +args = ["-skipMacroValidation"] # added to every command that builds ``` +- **`[xcodebuild] args`** is the committed form of the `-- XCODEBUILD_ARGS` + tail (§3): a list joined onto every `xcodebuild` this project spawns — + `build`, `test`, `archive`, and the builds inside `app + run`/`install`/`debug`/`diagnose`. A repo-wide `-skipMacroValidation` is a + property of the project, not a decision to re-make per command; this is where + it lives. The typed tail is appended *after* the file's arguments, so typing + one wins under xcodebuild's last-one-wins, and `status` prints the effective + list — a build shaped by a file the caller never opened must still say where + that came from. +- The arguments the CLI settles itself are **refused** in the file, naming the + key to use instead: `-scheme`, `-configuration`, `-destination`, `-sdk`, + `-workspace`, `-project` (a second copy makes the build depend on which one + xcodebuild honors), `-resultBundlePath` (the CLI writes and reads back its + own), and `-derivedDataPath` — whose relative value would resolve against the + working directory while every other path in this file resolves against the + file, so one committed line would name a different directory per caller. A + refusal is an error rather than a warning: the alternative is handing + xcodebuild two answers to one question. Swift packages ignore the table + entirely — `swift build` knows none of these flags. + - Unknown keys are warned about; a malformed file is warned about and ignored (a broken committed file must not brick every teammate's CLI). An absolute `workspace`/`project` warns too — it resolves to nothing on every other diff --git a/sweetpad-cli/src/cli/commands/app.rs b/sweetpad-cli/src/cli/commands/app.rs index b1493e8c..e7a95c12 100644 --- a/sweetpad-cli/src/cli/commands/app.rs +++ b/sweetpad-cli/src/cli/commands/app.rs @@ -568,8 +568,10 @@ impl Action { /// project default is simply ignored for `--device` runs rather than erroring /// on a committed file. fn hot_settings(ctx: &Context, args: &RunArgs) -> (bool, Mode) { - let run_defaults = resolve::container(ctx) - .ok() + // Silent, like every other pre-flight peek at the project file: `plan` + // resolves for real a moment later, and `container` narrates its discovery + // — twice would be this lookup's only visible effect. + let run_defaults = resolve::container_silently(ctx) .map(|c| ctx.project_file(&c).run.clone()) .unwrap_or_default(); let default_hot = run_defaults.hot.unwrap_or(false) && !args.device && args.device_id.is_none(); @@ -609,6 +611,7 @@ impl HotRecompiler { } } +#[allow(clippy::too_many_lines)] // one arm per verb, each a flat hand-off pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { match action { Action::Run(args) => { @@ -618,6 +621,7 @@ pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { args.mac || args.device || args.device_id.is_some(), )?; let (hot, hot_mode) = hot_settings(ctx, args); + let passthrough = ctx.xcodebuild_args(&args.xcodebuild.passthrough)?; // The live build-and-run session streams its own output until you quit. run_app( ctx, @@ -634,7 +638,7 @@ pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { keep_sandbox: args.keep_sandbox, hot_entitlements: args.hot_entitlements.as_deref(), launch: &args.launch, - passthrough: &args.xcodebuild.passthrough, + passthrough: &passthrough, }, ) } @@ -645,12 +649,13 @@ pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; + let passthrough = ctx.xcodebuild_args(&xcodebuild.passthrough)?; simple( ctx, Stage::Install, &LaunchArgs::default(), stage, - &xcodebuild.passthrough, + &passthrough, ) } Action::Launch { @@ -671,7 +676,8 @@ pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; - debug(ctx, stage, launch, batch, &xcodebuild.passthrough) + let passthrough = ctx.xcodebuild_args(&xcodebuild.passthrough)?; + debug(ctx, stage, launch, batch, &passthrough) } Action::Diagnose { target, @@ -682,7 +688,8 @@ pub fn run(ctx: &mut Context, action: &Action) -> CommandResult { } => { ctx.targeting = target.clone().into(); settle_stage_mode(ctx, stage)?; - diagnose(ctx, stage, launch, *timeout, &xcodebuild.passthrough) + let passthrough = ctx.xcodebuild_args(&xcodebuild.passthrough)?; + diagnose(ctx, stage, launch, *timeout, &passthrough) } Action::Uninstall { target, stage } => { ctx.targeting = target.clone().into(); diff --git a/sweetpad-cli/src/cli/commands/archive.rs b/sweetpad-cli/src/cli/commands/archive.rs index 17c632e4..cd188c41 100644 --- a/sweetpad-cli/src/cli/commands/archive.rs +++ b/sweetpad-cli/src/cli/commands/archive.rs @@ -107,6 +107,7 @@ impl Render for ArchiveReport { pub fn run(ctx: &mut Context, args: &ArchiveArgs) -> CommandResult { ctx.targeting = args.target.clone().into(); + let passthrough = ctx.xcodebuild_args(&args.passthrough)?; let mut resolved = resolve::resolve(ctx)?; if matches!(resolved.container, Container::SwiftPackage(_)) { return Err(CliError::new( @@ -145,7 +146,7 @@ pub fn run(ctx: &mut Context, args: &ArchiveArgs) -> CommandResult { archive_args.push(sdk.clone()); } archive_args.extend(xcodebuild::container_args(&resolved.container)); - archive_args.extend(args.passthrough.iter().cloned()); + archive_args.extend(passthrough.iter().cloned()); let cwd = xcodebuild::working_dir(&resolved.container); let export_dir = out_dir.join("export"); diff --git a/sweetpad-cli/src/cli/commands/build.rs b/sweetpad-cli/src/cli/commands/build.rs index 7ac26da8..1de55c62 100644 --- a/sweetpad-cli/src/cli/commands/build.rs +++ b/sweetpad-cli/src/cli/commands/build.rs @@ -193,6 +193,9 @@ fn start( show_command: bool, passthrough: &[String], ) -> CommandResult { + // Both entry points (`build` and each `--watch` iteration) land here, so + // the project file's `[xcodebuild] args` join the tail once. + let passthrough = &ctx.xcodebuild_args(passthrough)?; let mut resolved = resolve::resolve(ctx)?; // Swift packages have no simulator destination; build them with the `swift` diff --git a/sweetpad-cli/src/cli/commands/help_topics.rs b/sweetpad-cli/src/cli/commands/help_topics.rs index 88ee13fd..a854e442 100644 --- a/sweetpad-cli/src/cli/commands/help_topics.rs +++ b/sweetpad-cli/src/cli/commands/help_topics.rs @@ -48,9 +48,24 @@ Resolution precedence, highest first: A committed 'sweetpad.toml' is the team-shared defaults layer (scheme/configuration/destination/sdk, 'developer_dir', '[run]', '[format]', -'[testing]') — personal config beats it, remembered picks yield to it. It is -found by walking up from the working directory to the git root, so one file -serves the whole checkout. +'[testing]', '[xcodebuild]') — personal config beats it, remembered picks +yield to it. It is found by walking up from the working directory to the git +root, so one file serves the whole checkout. + +'[xcodebuild] args' is the repo-wide version of the '--' tail — a list added +to every command that builds, so a flag the project always needs is written +down once instead of typed each time: + + [xcodebuild] + args = [\"-skipMacroValidation\"] + +A typed '--' tail is appended after it, so it wins for anything both set +(xcodebuild takes the last value). 'sweetpad status' prints the effective +list. The arguments sweetpad settles itself are refused there, naming the key +to use instead: -scheme, -configuration, -destination, -sdk, -workspace, +-project, -resultBundlePath, and -derivedDataPath (a relative value would +mean a different directory depending on where the command ran — pass that one +per command). Swift packages ignore the table: they build with 'swift build'. When the project is not a sibling of that file, name it with 'workspace' or 'project', relative to the file itself: diff --git a/sweetpad-cli/src/cli/commands/status.rs b/sweetpad-cli/src/cli/commands/status.rs index da2fffa5..3d799250 100644 --- a/sweetpad-cli/src/cli/commands/status.rs +++ b/sweetpad-cli/src/cli/commands/status.rs @@ -188,6 +188,23 @@ pub fn run(ctx: &mut Context) -> CommandResult { }); } + // A committed `[xcodebuild] args` shapes every build in this project from + // a file the person running the command may never have opened. Show it, or + // the difference it makes has no visible cause. + let pf_xcodebuild = ctx + .project_file(&resolved.container) + .xcodebuild + .args + .clone(); + if !pf_xcodebuild.is_empty() { + rows.push(Row { + name: "xcodebuild", + value: Some(pf_xcodebuild.join(" ")), + source: "sweetpad.toml", + note: "added to every build", + }); + } + let detached_log = detached_log_for(st.last_launched_app.as_ref()); Ok(Rendered::data(StatusReport { container: resolved.container.path().display().to_string(), diff --git a/sweetpad-cli/src/cli/commands/test.rs b/sweetpad-cli/src/cli/commands/test.rs index f23c5d90..1f6191ef 100644 --- a/sweetpad-cli/src/cli/commands/test.rs +++ b/sweetpad-cli/src/cli/commands/test.rs @@ -124,6 +124,7 @@ pub fn run(ctx: &mut Context, args: &TestArgs, action: Option<&Action>) -> Comma Some(Action::Output(opts)) => return output(ctx, args, opts), Some(Action::Run) | None => {} } + let passthrough = ctx.xcodebuild_args(&args.passthrough)?; let run_args = RunArgs { only_testing: &args.only_testing, skip_testing: &args.skip_testing, @@ -133,7 +134,7 @@ pub fn run(ctx: &mut Context, args: &TestArgs, action: Option<&Action>) -> Comma retry_flaky: args.retry_flaky, coverage: args.coverage, show_command: args.show_command, - passthrough: &args.passthrough, + passthrough: &passthrough, }; if args.watch { let resolved = resolve::resolve_testing(ctx)?; diff --git a/sweetpad-cli/src/cli/config.rs b/sweetpad-cli/src/cli/config.rs index 9caf5d99..42145e9c 100644 --- a/sweetpad-cli/src/cli/config.rs +++ b/sweetpad-cli/src/cli/config.rs @@ -278,6 +278,7 @@ pub struct ProjectFile { pub testing: TestingDefaults, pub run: RunDefaults, pub format: FormatDefaults, + pub xcodebuild: XcodebuildDefaults, } /// `[run]` — `app run` defaults for this project. @@ -303,6 +304,69 @@ pub struct FormatDefaults { pub tool: Option, } +/// `[xcodebuild]` — arguments every command in this project that spawns +/// `xcodebuild` adds to the invocation, so a repo-wide flag is written down +/// once instead of typed after `--` on each command. +#[derive(Debug, Default, Clone, Deserialize)] +#[serde(default)] +pub struct XcodebuildDefaults { + /// `xcodebuild` flags and/or `KEY=VALUE` build-setting overrides, in the + /// order they should appear. + pub args: Vec, +} + +/// The effective `xcodebuild` passthrough for one invocation: the committed +/// `[xcodebuild] args` first, then the `--` tail typed on the command line, so +/// a typed argument wins under `xcodebuild`'s last-one-wins. +/// +/// The file's arguments are refused when they name something the CLI already +/// owns — [`configured_arg_refusal`] explains each case. Refusing is an error +/// rather than a warning because the alternative is handing `xcodebuild` two +/// answers to one question and building whichever it picks. +pub fn effective_xcodebuild_args( + configured: &[String], + tail: &[String], +) -> Result, String> { + if let Some((arg, fix)) = configured + .iter() + .find_map(|a| configured_arg_refusal(a).map(|fix| (a, fix))) + { + return Err(format!( + "sweetpad.toml: `{arg}` in [xcodebuild] args — {fix}" + )); + } + let mut merged = configured.to_vec(); + merged.extend(tail.iter().cloned()); + Ok(merged) +} + +/// Why a given argument can't live in a committed `[xcodebuild] args`, if it +/// can't. Three groups: the inputs the resolver settles and passes itself (a +/// second copy makes the build depend on which `xcodebuild` honors), the +/// result bundle the CLI writes and then reads back, and `-derivedDataPath` — +/// whose relative value would resolve against the working directory while +/// every other path in this file resolves against the file, so the same +/// committed line would mean a different directory per caller. +fn configured_arg_refusal(arg: &str) -> Option<&'static str> { + Some(match arg { + "-workspace" | "-project" => "name the container with the `workspace`/`project` key", + "-scheme" => "use the `scheme` key", + "-configuration" => "use the `configuration` key", + "-destination" => "use the `destination` key", + "-sdk" => "use the `sdk` key", + "-derivedDataPath" => { + "a relative value would resolve against the working directory rather than \ + the file, so it would name a different place per caller; pass it per \ + command instead" + } + "-resultBundlePath" => { + "sweetpad writes and reads back its own result bundle; pass it per command \ + if you need a second one" + } + _ => return None, + }) +} + impl ProjectFile { /// Load the `sweetpad.toml` next to `container_dir` (the container's /// parent). Missing file ⇒ defaults. A malformed or typo'd file is @@ -425,7 +489,7 @@ impl RootFile { } /// The keys a `sweetpad.toml` accepts at the top level. -const PROJECT_FILE_KEYS: [&str; 11] = [ +const PROJECT_FILE_KEYS: [&str; 12] = [ "workspace", "project", "scheme", @@ -437,6 +501,7 @@ const PROJECT_FILE_KEYS: [&str; 11] = [ "testing", "run", "format", + "xcodebuild", ]; /// Report every `sweetpad.toml` key serde would silently drop. @@ -492,6 +557,17 @@ fn lint_project_file(raw: &toml::Value, warnings: &mut Vec) { } } } + "xcodebuild" => { + if let Some(t) = value.as_table() { + for xkey in t.keys() { + if xkey != "args" { + warnings.push(format!( + "sweetpad.toml: unknown key `{xkey}` in [xcodebuild]" + )); + } + } + } + } other if !PROJECT_FILE_KEYS.contains(&other) => { let hint = suggest(other, &PROJECT_FILE_KEYS) .map(|s| format!(" (did you mean `{s}`?)")) @@ -646,6 +722,67 @@ mod tests { assert!(warnings[0].contains("bogus"), "{warnings:?}"); } + #[test] + fn xcodebuild_args_parse_and_lint_clean() { + let pf: ProjectFile = + toml::from_str("[xcodebuild]\nargs = [\"-skipMacroValidation\", \"FOO=1\"]\n").unwrap(); + assert_eq!(pf.xcodebuild.args, ["-skipMacroValidation", "FOO=1"]); + + // An absent table is an empty list, not a parse error. + let pf: ProjectFile = toml::from_str("scheme = \"App\"\n").unwrap(); + assert!(pf.xcodebuild.args.is_empty()); + + let raw: toml::Value = + toml::from_str("[xcodebuild]\nargs = [\"-x\"]\nbogus = 1\n").unwrap(); + let mut warnings = Vec::new(); + lint_project_file(&raw, &mut warnings); + assert_eq!(warnings.len(), 1, "{warnings:?}"); + assert!(warnings[0].contains("bogus"), "{warnings:?}"); + } + + #[test] + fn the_file_supplies_arguments_before_the_typed_tail() { + let s = |args: &[&str]| args.iter().map(|a| (*a).to_string()).collect::>(); + + // Committed first, typed second — xcodebuild takes the last one, so a + // typed argument beats the file's. + assert_eq!( + effective_xcodebuild_args(&s(&["-skipMacroValidation"]), &s(&["FOO=1"])).unwrap(), + ["-skipMacroValidation", "FOO=1"] + ); + // Either side alone. + assert_eq!(effective_xcodebuild_args(&s(&["-a"]), &[]).unwrap(), ["-a"]); + assert_eq!(effective_xcodebuild_args(&[], &s(&["-b"])).unwrap(), ["-b"]); + assert!(effective_xcodebuild_args(&[], &[]).unwrap().is_empty()); + } + + #[test] + fn the_file_cannot_carry_the_arguments_the_cli_settles_itself() { + let s = |args: &[&str]| args.iter().map(|a| (*a).to_string()).collect::>(); + + for (arg, hint) in [ + ("-scheme", "`scheme` key"), + ("-configuration", "`configuration` key"), + ("-destination", "`destination` key"), + ("-sdk", "`sdk` key"), + ("-workspace", "`workspace`/`project` key"), + ("-project", "`workspace`/`project` key"), + ("-derivedDataPath", "per command"), + ("-resultBundlePath", "own result bundle"), + ] { + let err = effective_xcodebuild_args(&s(&[arg, "value"]), &[]) + .expect_err("a refused argument must not merge"); + assert!(err.contains(arg) && err.contains(hint), "{arg}: {err}"); + } + + // Typing one is still the caller's own business — only the committed + // file is policed, since everyone else inherits it unseen. + assert_eq!( + effective_xcodebuild_args(&[], &s(&["-derivedDataPath", "/tmp/dd"])).unwrap(), + ["-derivedDataPath", "/tmp/dd"] + ); + } + #[test] fn testing_section_layers_separately_from_build() { // The #219 setup: build on UAT-Debug, test on TEST-Debug. diff --git a/sweetpad-cli/src/cli/mod.rs b/sweetpad-cli/src/cli/mod.rs index 9d1b2dfa..9c662b8b 100644 --- a/sweetpad-cli/src/cli/mod.rs +++ b/sweetpad-cli/src/cli/mod.rs @@ -727,6 +727,31 @@ impl Context { beside.file }) } + + /// The `xcodebuild` arguments for a command that builds: the project + /// file's `[xcodebuild] args`, then the `--` tail typed on this + /// invocation. Every verb that spawns `xcodebuild` resolves its + /// passthrough through here, so a committed argument reaches the builds + /// inside `app run`/`install`/`debug`/`diagnose` as well as + /// `build`/`test`/`archive`. + pub fn xcodebuild_args(&self, tail: &[String]) -> Result, CliError> { + // Silent resolution: this runs *before* the command resolves for real, + // and `container` narrates its discovery ("using X (found below …)") — + // saying it twice per build would be the whole visible effect of a peek + // at a config table. No container found is not an error here either; + // resolution is about to fail on its own terms, and the typed tail is + // still the caller's. + let Some(container) = resolve::container_silently(self) else { + return Ok(tail.to_vec()); + }; + // `swift build`/`swift run` take the tail directly and know none of + // xcodebuild's flags, so a package's file contributes nothing here. + if matches!(container, resolve::Container::SwiftPackage(_)) { + return Ok(tail.to_vec()); + } + let configured = self.project_file(&container).xcodebuild.args.clone(); + config::effective_xcodebuild_args(&configured, tail).map_err(CliError::new) + } } /// Apply a project file's `developer_dir`, unless a flag or the ambient diff --git a/sweetpad-docs/docs/cli/cli.md b/sweetpad-docs/docs/cli/cli.md index fe432063..a75a5fa3 100644 --- a/sweetpad-docs/docs/cli/cli.md +++ b/sweetpad-docs/docs/cli/cli.md @@ -151,6 +151,20 @@ The commands that run a build take it — `build`, `test`, `archive`, `run`, `ap (`app launch`, `app stop`, `app logs`, `app uninstall`) build nothing, so they turn a `--` down rather than accept options that would go nowhere. +If your project always needs the same option, write it down instead of typing it every time. An +`[xcodebuild] args` list in `sweetpad.toml` is added to every command that builds, and it's +committed, so your whole team gets it: + +```toml +# sweetpad.toml +[xcodebuild] +args = ["-skipMacroValidation"] +``` + +A `--` you type is appended after the file's arguments, so it wins for anything they both set. +`sweetpad status` shows the effective list — handy when a build behaves differently than you expect +and the reason is in a file you didn't write. + ## Live reload while you edit `sweetpad run --hot` keeps your app running and applies each Swift file you save without a full diff --git a/sweetpad-docs/docs/cli/reference.md b/sweetpad-docs/docs/cli/reference.md index 2186f503..53ed912b 100644 --- a/sweetpad-docs/docs/cli/reference.md +++ b/sweetpad-docs/docs/cli/reference.md @@ -103,6 +103,45 @@ sweetpad app install -- SYMROOT=/tmp/out # can't follow; use `-- -derivedDataPath ` instead ``` +#### Writing them down for the whole repo + +An argument every build in a project needs belongs in `sweetpad.toml`, not in your shell history. +The `[xcodebuild] args` list is added to every command that builds, so it reaches the builds inside +`app run`/`install`/`debug`/`diagnose` as well as `build`, `test`, and `archive`: + +```toml +# sweetpad.toml (committed) +scheme = "MyApp" + +[xcodebuild] +args = ["-skipMacroValidation", "-disablePackageRepositoryCache"] +``` + +A `--` tail is appended after the file's arguments, so typing one wins — `xcodebuild` takes the last +value for a repeated flag or setting: + +```bash +sweetpad build -- SWIFT_ACTIVE_COMPILATION_CONDITIONS=DEBUG # beats the file's value +``` + +`sweetpad status` prints the effective list, so a build shaped by a file you didn't write still says +where it came from. + +Arguments SweetPad settles itself are refused in the file, naming the key to use instead: `-scheme`, +`-configuration`, `-destination`, `-sdk`, `-workspace`, `-project`, and `-resultBundlePath` (SweetPad +writes and reads back its own). `-derivedDataPath` is refused too — a relative value in a committed +file would resolve against the working directory rather than the file, meaning a different place +depending on where the command ran. Pass it per command instead. Swift packages ignore the table +entirely: they build with `swift build`, which knows none of `xcodebuild`'s flags. + +:::tip + +For `KEY=VALUE` build settings, an `.xcconfig` is usually the better committed home — Xcode honors it +too, so ⌘B and `sweetpad build` stay in agreement. Put flags in `[xcodebuild] args`; put build +settings in an xcconfig unless you specifically want them only when building through SweetPad. + +::: + ### Simulators Alias: `sim`. Most take an optional target (name or UDID) and default to the booted simulator. @@ -192,7 +231,7 @@ Three layers, from personal to shared: overrides. SweetPad never writes this file; it's yours. - **`sweetpad.toml`** next to the project — team defaults, meant to be committed. Same keys (`scheme`, `configuration`, `destination`, `sdk`), plus `developer_dir` and `[run]`, `[format]`, - and `[testing]` tables. + `[testing]`, and `[xcodebuild]` tables. - **Remembered state** — the answers you gave to interactive prompts, stored per project. Inspect and change it with `sweetpad context`, not by editing files.