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
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Changed

- Public first-run order is Homebrew CLI, Homebrew cask, then **Set Up This
Mac** / **Complete first-time setup** (or `host init --bootstrap-config`
and `service start`). Hello world runs only after a host config exists
and after `capacity check --purpose agent-ui-session`. Read
`purposes[].status`, not the top-level `status`. Stop on `unavailable`
and preview with `capacity reconcile`. Stop on `repair_needed` and use
`doctor` plus `simulators repair --alias <alias>`. Homebrew does not
create simulators.
- The app **Finish Local Broker Installation** copy leads with
`brew install fiveonecode/simulator-broker/simbroker` and **Refresh**.
Refresh now discovers `simbroker` in Homebrew prefix `bin` (`/opt/homebrew`
and `/usr/local`) as well as `install.json` and `~/.local/bin`.
- `host init --bootstrap-config` `runtime-not-found` errors name
`--ios-version` and `xcrun simctl list runtimes`. Default starter iOS
stays `18`.

### Added

- Homebrew formula `Formula/simbroker.rb` installs the Alpha CLI tarball
Expand Down
63 changes: 44 additions & 19 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ If you only ever use one simulator by hand, you may not need this yet.

## Use it

One-line CLI install on macOS, with Node.js 20+ still required at runtime:
Install the Homebrew CLI, then the Homebrew cask, then set up this Mac.
The five-minute hello world is only after a host config exists. Homebrew
does not create Simulator devices. Node.js 20+ is still required at
runtime.

```bash
brew install fiveonecode/simulator-broker/simbroker
Expand All @@ -39,6 +42,28 @@ Homebrew clones
for that tap name. `Formula/` and `Casks/` in this repository stay the
source of truth.

```bash
brew install --cask fiveonecode/simulator-broker/simulator-broker
```

That cask downloads `Simulator-Broker-<version>.zip` from
[GitHub Releases](https://github.com/fiveonecode/simulator-broker/releases).

The app is the preferred first-run host setup. Open `Simulator Broker.app`.
If it shows **Set Up This Mac**, click **Complete first-time setup**. That
runs the same broker commands as:

```bash
simbroker host init --bootstrap-config
simbroker service start
```

`host init --bootstrap-config` prints a warning and then creates real
Simulator devices. Do not run it casually on a machine whose simulator
inventory you cannot afford to change.

Other CLI install options:

```bash
npm install -g https://github.com/fiveonecode/simulator-broker/releases/download/v0.1.0-alpha.1/simbroker-0.1.0-alpha.1.tgz
simbroker --help
Expand All @@ -58,27 +83,14 @@ build the macOS app. If Homebrew is installed, `simbroker` lands in
and one guarded login-shell PATH line. Open a new terminal if this shell still
cannot resolve `simbroker`. `source .../env.sh` remains a fallback.

The Homebrew cask installs the signed, notarized operator app:

```bash
brew install --cask fiveonecode/simulator-broker/simulator-broker
```

That cask downloads `Simulator-Broker-<version>.zip` from
[GitHub Releases](https://github.com/fiveonecode/simulator-broker/releases).

Xcode is still required to create and run iOS Simulators. Alpha CLI tarballs
are also attached to those releases. Extract a tarball and run
`./bin/simbroker --help`.

`simbroker` help and `simbroker doctor` print human-readable text by default.
Pass `--json` for machine-readable payloads.

First-run host setup is `simbroker host init --bootstrap-config`. It prints a
warning and then creates real Simulator devices. Do not run it casually on a
machine whose simulator inventory you cannot afford to change.

To install the operator app as well, use the contributor command in
To build the operator app from this checkout, use the contributor command in
[Develop it](#develop-it).

## Develop it
Expand All @@ -100,21 +112,34 @@ keep the `agent:context` / `agent:verify` / `agent:complete` track.

## Five-minute hello world

After the CLI resolves and this Mac has a host config:
After the CLI resolves and this Mac has a host config from the first-run step
above:

```bash
mkdir -p /tmp/sample-broker-repo && cd /tmp/sample-broker-repo
simbroker project init
simbroker project validate
simbroker capacity check --purpose agent-ui-session --json
```

Read `purposes[].status` (and `summary` counts), not the top-level
`status`. Top-level `status` is only `ready` or `needs_attention`.

If `purposes[].status` is `unavailable`, stop. Preview missing capacity
with `simbroker capacity reconcile --json`. Do not acquire a lease.

If `purposes[].status` is `repair_needed`, stop. Run `simbroker doctor`,
then `simbroker simulators repair --alias <alias>` for the alias doctor
names. Do not acquire a lease.

If `purposes[].status` is `available`:

```bash
simbroker lease acquire --purpose agent-ui-session --lease-file /tmp/simbroker-hello-lease.json
simbroker host status
simbroker lease release --lease-file /tmp/simbroker-hello-lease.json
```

`host init --bootstrap-config` provisions real Simulator devices. Do not run it
casually on a machine whose simulator inventory you cannot afford to change.

## Next reading

- [Getting started](docs/getting-started.md) — install, first-run, reinstall, and uninstall
Expand Down
10 changes: 10 additions & 0 deletions app/Sources/BrokerOnboardingCommands.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,16 @@ struct BrokerCLIInvocationFormatter {
}
}

enum BrokerMissingCLISetupCopy {
static let brewInstallCommand = "brew install fiveonecode/simulator-broker/simbroker"
static let refreshActionTitle = "Refresh"
static let heroMessage =
"Install the Homebrew CLI with `brew install fiveonecode/simulator-broker/simbroker`, then click Refresh. The app cannot finish first-run setup until that CLI is on this Mac."
static let manualFallbackText =
"The public path is the Homebrew formula, then Refresh. Development builds may still set SIMBROKER_CLI_PATH."
static let manualFallbackCommands = [brewInstallCommand]
}

struct RepoOnboardingCommandGuide {
let commands: [String]
let shellHelpText: String
Expand Down
66 changes: 47 additions & 19 deletions app/Sources/BrokerSnapshotLoader.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,47 @@ struct BrokerRuntimePaths: Sendable {
.appending(path: "simbroker")
}

static func defaultHomebrewPrefixRoots(
environment: [String: String] = ProcessInfo.processInfo.environment
) -> [URL] {
var roots: [URL] = []
if let prefix = environment["HOMEBREW_PREFIX"], prefix.isEmpty == false {
roots.append(URL(fileURLWithPath: (prefix as NSString).expandingTildeInPath))
}
roots.append(URL(fileURLWithPath: "/opt/homebrew"))
roots.append(URL(fileURLWithPath: "/usr/local"))
var seen = Set<String>()
return roots.filter { seen.insert($0.standardizedFileURL.path).inserted }
}

static func cliCandidateURLs(
configuredCLIURL: URL?,
installMetadataCLIPath: String?,
homebrewPrefixRoots: [URL] = defaultHomebrewPrefixRoots(),
defaultCLIURL: URL = defaultCLIURL()
) -> [URL] {
var candidates: [URL] = []
if let configuredCLIURL {
candidates.append(configuredCLIURL)
}
if let installMetadataCLIPath, installMetadataCLIPath.isEmpty == false {
candidates.append(URL(fileURLWithPath: (installMetadataCLIPath as NSString).expandingTildeInPath))
}
for root in homebrewPrefixRoots {
candidates.append(root.appending(path: "bin").appending(path: "simbroker"))
}
candidates.append(defaultCLIURL)
var seen = Set<String>()
return candidates.filter { seen.insert($0.standardizedFileURL.path).inserted }
}

static func firstExecutableCLIURL(
among candidates: [URL],
isExecutable: (String) -> Bool = { FileManager.default.isExecutableFile(atPath: $0) }
) -> URL? {
candidates.first { isExecutable($0.path) }
}

static func defaultInstallRoot() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appending(path: "Library")
Expand Down Expand Up @@ -285,25 +326,12 @@ actor FileBrokerSnapshotLoader: BrokerSnapshotLoading {
}

private func resolveCLIPath(installMetadata: BrokerInstallMetadata?) -> URL? {
let fileManager = FileManager.default
let candidates = [
paths.configuredCLIURL,
installMetadata?.cliPath.flatMap { cliPath in
cliPath.isEmpty ? nil : URL(fileURLWithPath: (cliPath as NSString).expandingTildeInPath)
},
BrokerRuntimePaths.defaultCLIURL(),
]

for candidate in candidates {
guard let candidate else {
continue
}
if fileManager.isExecutableFile(atPath: candidate.path) {
return candidate
}
}

return nil
BrokerRuntimePaths.firstExecutableCLIURL(
among: BrokerRuntimePaths.cliCandidateURLs(
configuredCLIURL: paths.configuredCLIURL,
installMetadataCLIPath: installMetadata?.cliPath
)
)
}

private func normalizedPath(_ rawPath: String) -> String {
Expand Down
10 changes: 4 additions & 6 deletions app/Sources/SharedViews.swift
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,7 @@ struct BrokerSetupView: View {
.disabled(store.isApplyingAction || primaryActionEnabled == false)
}

Button("Refresh") {
Button(BrokerMissingCLISetupCopy.refreshActionTitle) {
store.refreshNow()
}
.buttonStyle(.bordered)
Expand Down Expand Up @@ -422,7 +422,7 @@ struct BrokerSetupView: View {
private var heroMessage: String {
switch store.startupState {
case .missingCLI:
return "The app cannot find a usable `simbroker` CLI yet, so it cannot self-serve machine setup. Once the CLI is available, this screen can initialize the Mac and start brokerd for you."
return BrokerMissingCLISetupCopy.heroMessage
case .needsHostBootstrap:
return "This Mac has not been initialized for Simulator Broker yet. First-run setup will create the starter simulator pool, write host config, and start brokerd."
case .needsServiceStart:
Expand Down Expand Up @@ -475,9 +475,7 @@ struct BrokerSetupView: View {

switch store.startupState {
case .missingCLI:
return [
"export SIMBROKER_CLI_PATH=/absolute/path/to/simbroker",
]
return BrokerMissingCLISetupCopy.manualFallbackCommands
case .needsHostBootstrap:
return [
formatter.command("host init --bootstrap-config --host-config \"\(store.hostConfigPath)\" --state-root \"\(store.stateRootPath)\""),
Expand All @@ -501,7 +499,7 @@ struct BrokerSetupView: View {
private var manualFallbackText: String {
switch store.startupState {
case .missingCLI:
return "If you are running a development build, point the app at a repo-local CLI with `SIMBROKER_CLI_PATH` or reinstall the packaged broker so \(store.cliHintPath) exists."
return BrokerMissingCLISetupCopy.manualFallbackText
case .needsHostBootstrap:
return "CLI fallback for the same first-run setup flow."
case .needsServiceStart, .readOnlySnapshot:
Expand Down
32 changes: 32 additions & 0 deletions app/Tests/BrokerOnboardingCommandsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,38 @@ import XCTest
@testable import SimulatorBrokerApp

final class BrokerOnboardingCommandsTests: XCTestCase {
func testMissingCLICopyLeadsWithHomebrewFormulaAndRefresh() {
XCTAssertEqual(
BrokerMissingCLISetupCopy.brewInstallCommand,
"brew install fiveonecode/simulator-broker/simbroker"
)
XCTAssertEqual(BrokerMissingCLISetupCopy.refreshActionTitle, "Refresh")
XCTAssertTrue(
BrokerMissingCLISetupCopy.heroMessage.contains(BrokerMissingCLISetupCopy.brewInstallCommand)
)
XCTAssertTrue(
BrokerMissingCLISetupCopy.heroMessage.contains(BrokerMissingCLISetupCopy.refreshActionTitle)
)
XCTAssertEqual(
BrokerMissingCLISetupCopy.manualFallbackCommands,
[BrokerMissingCLISetupCopy.brewInstallCommand]
)
XCTAssertTrue(
BrokerMissingCLISetupCopy.manualFallbackText.hasPrefix("The public path is the Homebrew formula, then Refresh.")
)
XCTAssertFalse(
BrokerMissingCLISetupCopy.heroMessage.contains("SIMBROKER_CLI_PATH"),
"missing-CLI hero must not lead with SIMBROKER_CLI_PATH"
)
let fallback = BrokerMissingCLISetupCopy.manualFallbackText
let brewIndex = fallback.range(of: "Homebrew formula")?.lowerBound
let envIndex = fallback.range(of: "SIMBROKER_CLI_PATH")?.lowerBound
XCTAssertNotNil(brewIndex)
if let brewIndex, let envIndex {
XCTAssertLessThan(brewIndex, envIndex)
}
}

func testRepoGuideUsesQuotedAbsoluteCLIPathWhenAvailable() {
let guide = RepoOnboardingCommandGuide(
cliPath: "/tmp/custom broker/simbroker",
Expand Down
66 changes: 66 additions & 0 deletions app/Tests/BrokerRuntimePathsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -74,4 +74,70 @@ final class BrokerRuntimePathsTests: XCTestCase {
XCTAssertEqual(launchContext.initialSelection.projectId, "sample-project")
XCTAssertNil(launchContext.initialSelection.eventId)
}

func testCLICandidatesPreferConfiguredThenInstallThenHomebrewThenLocalDefault() {
let configured = URL(fileURLWithPath: "/tmp/configured-simbroker")
let installPath = "/tmp/installed-simbroker"
let candidates = BrokerRuntimePaths.cliCandidateURLs(
configuredCLIURL: configured,
installMetadataCLIPath: installPath,
homebrewPrefixRoots: [
URL(fileURLWithPath: "/opt/homebrew"),
URL(fileURLWithPath: "/usr/local"),
],
defaultCLIURL: URL(fileURLWithPath: "/tmp/home/.local/bin/simbroker")
)

XCTAssertEqual(
candidates.map(\.path),
[
"/tmp/configured-simbroker",
"/tmp/installed-simbroker",
"/opt/homebrew/bin/simbroker",
"/usr/local/bin/simbroker",
"/tmp/home/.local/bin/simbroker",
]
)
}

func testFirstExecutableCLIPrefersHomebrewOverLocalDefault() {
let homebrew = URL(fileURLWithPath: "/opt/homebrew/bin/simbroker")
let localDefault = URL(fileURLWithPath: "/tmp/home/.local/bin/simbroker")
let candidates = BrokerRuntimePaths.cliCandidateURLs(
configuredCLIURL: nil,
installMetadataCLIPath: nil,
homebrewPrefixRoots: [URL(fileURLWithPath: "/opt/homebrew")],
defaultCLIURL: localDefault
)
let resolved = BrokerRuntimePaths.firstExecutableCLIURL(among: candidates) { path in
path == homebrew.path || path == localDefault.path
}

XCTAssertEqual(resolved, homebrew)
}

func testFirstExecutableCLIFallsBackToLocalDefaultWhenHomebrewIsMissing() {
let localDefault = URL(fileURLWithPath: "/tmp/home/.local/bin/simbroker")
let candidates = BrokerRuntimePaths.cliCandidateURLs(
configuredCLIURL: nil,
installMetadataCLIPath: nil,
homebrewPrefixRoots: [URL(fileURLWithPath: "/opt/homebrew")],
defaultCLIURL: localDefault
)
let resolved = BrokerRuntimePaths.firstExecutableCLIURL(among: candidates) { path in
path == localDefault.path
}

XCTAssertEqual(resolved, localDefault)
}

func testDefaultHomebrewPrefixesIncludeStandardRootsAndOptionalHOMEBREW_PREFIX() {
let prefixes = BrokerRuntimePaths.defaultHomebrewPrefixRoots(
environment: ["HOMEBREW_PREFIX": "/opt/homebrew"]
)
XCTAssertEqual(
prefixes.map(\.path),
["/opt/homebrew", "/usr/local"]
)
}
}
11 changes: 7 additions & 4 deletions broker-core/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1064,10 +1064,13 @@ function selectRuntimeForAlias(simctl, iosVersion) {
.sort((left, right) => compareVersions(right.version, left.version));

if (runtimes.length === 0) {
throw new BrokerError(`No available iOS runtime matched ${iosVersion}.`, {
iosVersion,
reasonCode: "runtime-not-found",
});
throw new BrokerError(
`No available iOS runtime matched ${iosVersion}. Pass --ios-version to match an installed runtime from \`xcrun simctl list runtimes\`.`,
{
iosVersion,
reasonCode: "runtime-not-found",
},
);
}

return runtimes[0];
Expand Down
Loading
Loading