diff --git a/CHANGELOG.md b/CHANGELOG.md index 2fee70f..6e19a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 `. 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 diff --git a/README.md b/README.md index 39a3358..738e22e 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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-.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 @@ -58,15 +83,6 @@ 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-.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`. @@ -74,11 +90,7 @@ are also attached to those releases. Extract a tarball and run `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 @@ -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 ` 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 diff --git a/app/Sources/BrokerOnboardingCommands.swift b/app/Sources/BrokerOnboardingCommands.swift index 0f281aa..a7645cf 100644 --- a/app/Sources/BrokerOnboardingCommands.swift +++ b/app/Sources/BrokerOnboardingCommands.swift @@ -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 diff --git a/app/Sources/BrokerSnapshotLoader.swift b/app/Sources/BrokerSnapshotLoader.swift index b5a33e4..8fb4f3c 100644 --- a/app/Sources/BrokerSnapshotLoader.swift +++ b/app/Sources/BrokerSnapshotLoader.swift @@ -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() + 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() + 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") @@ -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 { diff --git a/app/Sources/SharedViews.swift b/app/Sources/SharedViews.swift index 918c789..8978dec 100644 --- a/app/Sources/SharedViews.swift +++ b/app/Sources/SharedViews.swift @@ -346,7 +346,7 @@ struct BrokerSetupView: View { .disabled(store.isApplyingAction || primaryActionEnabled == false) } - Button("Refresh") { + Button(BrokerMissingCLISetupCopy.refreshActionTitle) { store.refreshNow() } .buttonStyle(.bordered) @@ -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: @@ -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)\""), @@ -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: diff --git a/app/Tests/BrokerOnboardingCommandsTests.swift b/app/Tests/BrokerOnboardingCommandsTests.swift index 4e5dd82..93d26bd 100644 --- a/app/Tests/BrokerOnboardingCommandsTests.swift +++ b/app/Tests/BrokerOnboardingCommandsTests.swift @@ -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", diff --git a/app/Tests/BrokerRuntimePathsTests.swift b/app/Tests/BrokerRuntimePathsTests.swift index 177fd66..73ae6bf 100644 --- a/app/Tests/BrokerRuntimePathsTests.swift +++ b/app/Tests/BrokerRuntimePathsTests.swift @@ -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"] + ) + } } diff --git a/broker-core/index.mjs b/broker-core/index.mjs index 7d274b9..147e865 100644 --- a/broker-core/index.mjs +++ b/broker-core/index.mjs @@ -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]; diff --git a/broker-core/test/broker-core.test.mjs b/broker-core/test/broker-core.test.mjs index be855ab..90ade61 100644 --- a/broker-core/test/broker-core.test.mjs +++ b/broker-core/test/broker-core.test.mjs @@ -814,6 +814,51 @@ test("host init can bootstrap a starter host config when none exists yet", () => assert.ok(simctlState.devices.some((device) => device.name === "Simulator Broker bootstrap-host manual-1")); }); +test("host init --bootstrap-config names --ios-version when no runtime matches the starter iOS version", () => { + const root = makeTempDir(); + const simctl = createSimctlFixture(root, { + runtimes: [ + { + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-0", + isAvailable: true, + supportedDeviceTypes: [ + { + identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16", + name: "iPhone 16", + productFamily: "iPhone", + }, + { + identifier: "com.apple.CoreSimulator.SimDeviceType.iPad-A16", + name: "iPad (A16)", + productFamily: "iPad", + }, + ], + version: "26.0", + }, + ], + }); + const resolvedPaths = resolveBrokerPaths({ + hostConfigPath: path.join(root, "host-config.json"), + stateRoot: path.join(root, "state"), + }); + + try { + initBroker(resolvedPaths, { + bootstrapConfig: true, + hostId: "missing-runtime-host", + processExists: () => true, + simctlAdapter: simctl.adapter, + }); + assert.fail("expected runtime-not-found"); + } catch (error) { + assert.equal(error instanceof BrokerError, true); + assert.equal(error.payload.reasonCode, "runtime-not-found"); + assert.match(error.message, /--ios-version/); + assert.match(error.message, /xcrun simctl list runtimes/); + assert.match(error.message, /\b18\b/); + } +}); + test("host init warns before creating bootstrap simulators", () => { const paths = makePaths(); const resolvedPaths = brokerPaths(paths); diff --git a/broker-core/test/support/simctl-fixture.mjs b/broker-core/test/support/simctl-fixture.mjs index 372fa00..b7d4846 100644 --- a/broker-core/test/support/simctl-fixture.mjs +++ b/broker-core/test/support/simctl-fixture.mjs @@ -65,9 +65,13 @@ export function createDeviceRecord({ }; } -export function createSimctlFixture(root, { devices = [] } = {}) { +export function createSimctlFixture(root, { devices = [], runtimes } = {}) { const statePath = path.join(root, "simctl-state.json"); - writeJson(statePath, defaultSimctlFixtureState({ devices })); + const state = defaultSimctlFixtureState({ devices }); + if (runtimes !== undefined) { + state.runtimes = runtimes; + } + writeJson(statePath, state); return { adapter: createFixtureSimctlAdapter({ statePath }), env: { diff --git a/client/command-dispatch.mjs b/client/command-dispatch.mjs index 3afb921..7dff563 100644 --- a/client/command-dispatch.mjs +++ b/client/command-dispatch.mjs @@ -617,6 +617,7 @@ function helpPayload(group) { ], notes: [ "host init --bootstrap-config creates real iOS Simulator devices on this Mac.", + "If no iOS runtime matches the requested version, pass --ios-version from `xcrun simctl list runtimes`. Default starter iOS is 18.", ], group: "host", usage: "simbroker host ", diff --git a/client/test/simbroker.test.mjs b/client/test/simbroker.test.mjs index 97ebb37..bc02012 100644 --- a/client/test/simbroker.test.mjs +++ b/client/test/simbroker.test.mjs @@ -324,6 +324,42 @@ test("host init can bootstrap a starter host config on a fresh machine path", () assert.equal(result.json.bootstrapWarning, HOST_BOOTSTRAP_DEVICE_WARNING); }); +test("host init --bootstrap-config reports runtime-not-found with --ios-version guidance", () => { + const root = makeTempDir(); + const fixture = { + hostConfigPath: path.join(root, "host-config.json"), + simctl: createSimctlFixture(root, { + runtimes: [ + { + identifier: "com.apple.CoreSimulator.SimRuntime.iOS-26-0", + isAvailable: true, + supportedDeviceTypes: [ + { + identifier: "com.apple.CoreSimulator.SimDeviceType.iPhone-16", + name: "iPhone 16", + productFamily: "iPhone", + }, + { + identifier: "com.apple.CoreSimulator.SimDeviceType.iPad-A16", + name: "iPad (A16)", + productFamily: "iPad", + }, + ], + version: "26.0", + }, + ], + }), + stateRoot: path.join(root, "state"), + }; + + const result = runCli(fixture, "host", "init", "--bootstrap-config", "--host-id", "cli-missing-runtime"); + assert.notEqual(result.status, 0); + assert.equal(result.json.ok, false); + assert.equal(result.json.reasonCode, "runtime-not-found"); + assert.match(result.json.error, /--ios-version/); + assert.match(result.json.error, /xcrun simctl list runtimes/); +}); + test("host init --bootstrap-config warns on stdout-adjacent stderr before devices exist", () => { const root = makeTempDir(); const fixture = { diff --git a/docs/getting-started.md b/docs/getting-started.md index 09d5c51..bd3c9cb 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -85,12 +85,18 @@ the same way as the CLI-only installer. ## First-run host setup -The app is the preferred first-run surface. +Do this after the Homebrew CLI and cask (or another documented CLI +install) resolve, and before hello world. Homebrew does not create +Simulator devices. The app is the preferred first-run surface. 1. Launch `Simulator Broker.app`. 2. If it shows **Set Up This Mac**, click **Complete first-time setup**. 3. Wait until the dashboard shows the broker is ready. +If the app says **Finish Local Broker Installation**, install the +Homebrew CLI (`brew install fiveonecode/simulator-broker/simbroker`) and +click **Refresh**. + CLI fallback, which prints a warning and then creates real simulator devices: ```bash @@ -98,6 +104,10 @@ simbroker host init --bootstrap-config simbroker service start ``` +If bootstrap fails because no installed iOS runtime matches the requested +version, pass `--ios-version` from `xcrun simctl list runtimes`. The +default starter iOS version stays `18`. + Then register each repo you want the broker to know about: ```bash @@ -110,11 +120,28 @@ or the `broker-harness-adoption` skill. ## Hello world +Run this only after a host config exists from first-run setup. + ```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 ` 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 diff --git a/docs/status.md b/docs/status.md index cac635d..2692398 100644 --- a/docs/status.md +++ b/docs/status.md @@ -21,7 +21,15 @@ This Alpha already includes: - optional Automatic shutdown configured only through broker commands - a macOS operator app with Overview, Simulators, Projects, and Events - a broker-owned `app-snapshot.json` read model -- app-driven first-run setup and per-repo onboarding commands +- app-driven first-run setup and per-repo onboarding commands. 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`). Homebrew does not create simulators. Hello world runs + only after a host config exists and after `capacity check` for + `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 ` - CLI-only install through `bash scripts/install_local.sh --cli-only`, plus the contributor app+CLI path `npm run install:local` - tagged Alpha CLI tarball through `npm run package:cli` and GitHub Releases diff --git a/docs/test/front-door.test.mjs b/docs/test/front-door.test.mjs index 9a6b55a..e51a48b 100644 --- a/docs/test/front-door.test.mjs +++ b/docs/test/front-door.test.mjs @@ -57,6 +57,55 @@ test("README separates use-versus-develop install guidance and includes a hello- assert.ok(readme.includes("simbroker lease acquire")); }); +test("README Use it orders Homebrew CLI, cask, host setup, then hello world", () => { + const readme = readRepoFile("README.md"); + const useIt = headingSection(readme, "Use it"); + const hello = headingSection(readme, "Five-minute hello world"); + const gettingStarted = readRepoFile("docs/getting-started.md"); + + assert.ok(useIt.includes("brew install fiveonecode/simulator-broker/simbroker")); + assert.ok(useIt.includes("brew install --cask fiveonecode/simulator-broker/simulator-broker")); + assert.ok( + useIt.includes("Set Up This Mac") || useIt.includes("Complete first-time setup"), + "Use it must name the app first-run surface", + ); + assert.ok(useIt.includes("host init --bootstrap-config")); + assert.ok(useIt.includes("simbroker service start")); + assert.ok( + /Homebrew\s+does not create/i.test(useIt), + "Use it must not claim Homebrew creates simulators", + ); + assert.ok( + /hello world is only after a host config exists/i.test(useIt) + || /only after a host config exists/i.test(useIt), + "Use it must place hello world after host config", + ); + + assert.ok(hello.includes("simbroker capacity check --purpose agent-ui-session --json")); + assert.ok(hello.includes("purposes[].status")); + assert.ok(hello.includes("needs_attention")); + assert.ok(/unavailable/.test(hello)); + assert.ok(/repair_needed/.test(hello)); + assert.ok(hello.includes("simbroker capacity reconcile --json")); + assert.ok(hello.includes("simbroker doctor")); + assert.ok(hello.includes("simbroker simulators repair --alias ")); + assert.equal(hello.includes("simbroker simulators repair\n"), false); + assert.ok(hello.includes("simbroker lease acquire")); + assert.ok( + /host config/i.test(hello) && (/first-run/i.test(hello) || /after/i.test(hello)), + "hello world must require a host config", + ); + + const gettingHello = headingSection(gettingStarted, "Hello world"); + assert.ok(gettingHello.includes("simbroker capacity check --purpose agent-ui-session --json")); + assert.ok(gettingHello.includes("purposes[].status")); + assert.ok(gettingHello.includes("simbroker capacity reconcile --json")); + assert.ok(gettingHello.includes("simbroker doctor")); + assert.ok(gettingHello.includes("simbroker simulators repair --alias ")); + assert.ok(/unavailable/.test(gettingHello)); + assert.ok(/repair_needed/.test(gettingHello)); +}); + function headingSection(markdown, heading) { const marker = `\n## ${heading}\n`; const start = markdown.indexOf(marker); diff --git a/spec/build-and-test.md b/spec/build-and-test.md index d88383b..505c4d9 100644 --- a/spec/build-and-test.md +++ b/spec/build-and-test.md @@ -24,7 +24,7 @@ A first extracted implementation slice now exists: stale-recovery, lock-race, scheduler, confirmed-cleanup, and failure coverage - tracked-text public-surface scanning with an ignored local denylist extension - app-side operator controls for pin create and clear, lease release, and lifecycle actions over the shared broker authority -- app launch-time fixture overrides through `--state-root`, `--host-config`, optional `--cli-path`, plus direct pane/detail targeting for deterministic screenshot and smoke scenarios +- app launch-time fixture overrides through `--state-root`, `--host-config`, optional `--cli-path`, plus direct pane/detail targeting for deterministic screenshot and smoke scenarios. When `--cli-path` / `SIMBROKER_CLI_PATH` and `install.json` are unset, the app also looks for an executable `simbroker` in Homebrew prefix `bin` (`/opt/homebrew`, `/usr/local`, and `HOMEBREW_PREFIX`) before `~/.local/bin` - broker-owned state artifacts are restricted to the current user, and lease, containment, pin, and lifecycle mutations share the broker mutation authority whether invoked directly, through the service, or from the app - inactive local project registrations can be removed only through the explicit, locked `project forget --project-id ` command; it is idempotent, refreshes the shared app snapshot, preserves the repository and audit history, and rejects projects with active leases or pins - the macOS app test wrapper now supports build-only reruns, focused `-only-testing` filters, and stable `xcresult` output for runtime triage @@ -34,7 +34,7 @@ A first extracted implementation slice now exists: - installer coverage for stopping a running service before replacing the installed runtime, restarting it after metadata is written, shell-safe env helper serialization, and default-location install metadata for custom prefixes without leaving smoke-run paths in a developer install - CLI-only install through `bash scripts/install_local.sh --cli-only`, which copies the Node runtime and writes `simbroker` without XcodeGen or an app build - PATH persistence after install: Homebrew prefix bin when that is the install location, otherwise one guarded login-profile snippet for the default `~/.local/bin` location; `--profile` overrides the profile path so tests never edit the operator login rc -- `host init --bootstrap-config` prints an honest warning that it creates real iOS Simulator devices before those devices are created +- `host init --bootstrap-config` prints an honest warning that it creates real iOS Simulator devices before those devices are created. Public first-run order is Homebrew CLI, Homebrew cask, then the app **Set Up This Mac** / **Complete first-time setup** (or that CLI pair). Homebrew does not create simulators. Hello world requires a host config and a passing `capacity check --purpose agent-ui-session` (`purposes[].status`, not the top-level `status`). `unavailable` stops at `capacity reconcile`; `repair_needed` stops at `doctor` and `simulators repair --alias `. Default starter iOS stays `18`. A `runtime-not-found` error names `--ios-version` and `xcrun simctl list runtimes` - `scripts/package_cli.sh` packages the Node CLI runtime into a versioned tarball without XcodeGen or an app build - `Formula/simbroker.rb` installs that GitHub Release tarball through Homebrew.