feat(macos): install CLI via app script
This commit is contained in:
@@ -3,7 +3,7 @@
|
|||||||
## 2026.1.11 (Unreleased)
|
## 2026.1.11 (Unreleased)
|
||||||
|
|
||||||
### Changes
|
### Changes
|
||||||
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode, and use external launchd/CLI instead of the embedded gateway runtime.
|
- macOS: prompt to install the global `clawdbot` CLI when missing in local mode; install via `clawd.bot/install-cli.sh` (no onboarding) and use external launchd/CLI instead of the embedded gateway runtime.
|
||||||
|
|
||||||
## 2026.1.10
|
## 2026.1.10
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,69 @@ enum CLIInstaller {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
static func install(statusHandler: @escaping @Sendable (String) async -> Void) async {
|
||||||
let expected = GatewayEnvironment.expectedGatewayVersion()
|
let expected = GatewayEnvironment.expectedGatewayVersion()?.description ?? "latest"
|
||||||
await GatewayEnvironment.installGlobal(version: expected) { message in
|
let prefix = Self.installPrefix()
|
||||||
Task { @MainActor in await statusHandler(message) }
|
await statusHandler("Installing clawdbot CLI…")
|
||||||
|
let cmd = self.installScriptCommand(version: expected, prefix: prefix)
|
||||||
|
let response = await ShellExecutor.runDetailed(command: cmd, cwd: nil, env: nil, timeout: 900)
|
||||||
|
|
||||||
|
if response.success {
|
||||||
|
let parsed = self.parseInstallEvents(response.stdout)
|
||||||
|
let installedVersion = parsed.last { $0.event == "done" }?.version
|
||||||
|
let summary = installedVersion.map { "Installed clawdbot \($0)." } ?? "Installed clawdbot."
|
||||||
|
await statusHandler(summary)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let parsed = self.parseInstallEvents(response.stdout)
|
||||||
|
if let error = parsed.last(where: { $0.event == "error" })?.message {
|
||||||
|
await statusHandler("Install failed: \(error)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let detail = response.stderr.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
let fallback = response.errorMessage ?? "install failed"
|
||||||
|
await statusHandler("Install failed: \(detail.isEmpty ? fallback : detail)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func installPrefix() -> String {
|
||||||
|
FileManager.default.homeDirectoryForCurrentUser
|
||||||
|
.appendingPathComponent(".clawdbot")
|
||||||
|
.path
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func installScriptCommand(version: String, prefix: String) -> [String] {
|
||||||
|
let escapedVersion = self.shellEscape(version)
|
||||||
|
let escapedPrefix = self.shellEscape(prefix)
|
||||||
|
let script = """
|
||||||
|
curl -fsSL https://clawd.bot/install-cli.sh | \
|
||||||
|
bash -s -- --json --no-onboard --prefix \(escapedPrefix) --version \(escapedVersion)
|
||||||
|
"""
|
||||||
|
return ["/bin/bash", "-lc", script]
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func parseInstallEvents(_ output: String) -> [InstallEvent] {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
let lines = output
|
||||||
|
.split(whereSeparator: \.isNewline)
|
||||||
|
.map { String($0) }
|
||||||
|
var events: [InstallEvent] = []
|
||||||
|
for line in lines {
|
||||||
|
guard let data = line.data(using: .utf8) else { continue }
|
||||||
|
if let event = try? decoder.decode(InstallEvent.self, from: data) {
|
||||||
|
events.append(event)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return events
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func shellEscape(_ raw: String) -> String {
|
||||||
|
"'" + raw.replacingOccurrences(of: "'", with: "'\"'\"'") + "'"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct InstallEvent: Decodable {
|
||||||
|
let event: String
|
||||||
|
let version: String?
|
||||||
|
let message: String?
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,12 +84,30 @@ enum CommandResolver {
|
|||||||
"/bin",
|
"/bin",
|
||||||
]
|
]
|
||||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1)
|
let clawdbotPaths = self.clawdbotManagedPaths(home: home)
|
||||||
|
if !clawdbotPaths.isEmpty {
|
||||||
|
extras.insert(contentsOf: clawdbotPaths, at: 1)
|
||||||
|
}
|
||||||
|
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1 + clawdbotPaths.count)
|
||||||
var seen = Set<String>()
|
var seen = Set<String>()
|
||||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||||
return (extras + current).filter { seen.insert($0).inserted }
|
return (extras + current).filter { seen.insert($0).inserted }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func clawdbotManagedPaths(home: URL) -> [String] {
|
||||||
|
let base = home.appendingPathComponent(".clawdbot")
|
||||||
|
let bin = base.appendingPathComponent("bin")
|
||||||
|
let nodeBin = base.appendingPathComponent("tools/node/bin")
|
||||||
|
var paths: [String] = []
|
||||||
|
if FileManager.default.fileExists(atPath: bin.path) {
|
||||||
|
paths.append(bin.path)
|
||||||
|
}
|
||||||
|
if FileManager.default.fileExists(atPath: nodeBin.path) {
|
||||||
|
paths.append(nodeBin.path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
private static func nodeManagerBinPaths(home: URL) -> [String] {
|
private static func nodeManagerBinPaths(home: URL) -> [String] {
|
||||||
var bins: [String] = []
|
var bins: [String] = []
|
||||||
|
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ enum GatewayEnvironment {
|
|||||||
nodeVersion: runtime.version.description,
|
nodeVersion: runtime.version.description,
|
||||||
gatewayVersion: nil,
|
gatewayVersion: nil,
|
||||||
requiredGateway: expected?.description,
|
requiredGateway: expected?.description,
|
||||||
message: "clawdbot CLI not found in PATH; install the global package.")
|
message: "clawdbot CLI not found in PATH; install the CLI.")
|
||||||
}
|
}
|
||||||
|
|
||||||
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
|
let installed = gatewayBin.flatMap { self.readGatewayVersion(binary: $0) }
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ enum GatewayLaunchAgentManager {
|
|||||||
return .success(cmd)
|
return .success(cmd)
|
||||||
}
|
}
|
||||||
|
|
||||||
return .failure("clawdbot CLI not found in PATH; install the global package.")
|
return .failure("clawdbot CLI not found in PATH; install the CLI.")
|
||||||
}
|
}
|
||||||
|
|
||||||
static func isLoaded() async -> Bool {
|
static func isLoaded() async -> Bool {
|
||||||
|
|||||||
@@ -393,7 +393,7 @@ struct GeneralSettings: View {
|
|||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
} else {
|
} else {
|
||||||
Text("Installs via npm/pnpm/bun; requires Node 22+.")
|
Text("Installs a user-space Node 22+ runtime and the CLI (no Homebrew).")
|
||||||
.font(.caption)
|
.font(.caption)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.lineLimit(2)
|
.lineLimit(2)
|
||||||
|
|||||||
@@ -541,7 +541,7 @@ extension OnboardingView {
|
|||||||
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
} else if !self.cliInstalled, self.cliInstallLocation == nil {
|
||||||
Text(
|
Text(
|
||||||
"""
|
"""
|
||||||
Uses npm/pnpm/bun. Requires Node 22+ on this Mac.
|
Installs a user-space Node 22+ runtime and the CLI (no Homebrew).
|
||||||
Rerun anytime to reinstall or update.
|
Rerun anytime to reinstall or update.
|
||||||
""")
|
""")
|
||||||
.font(.footnote)
|
.font(.footnote)
|
||||||
|
|||||||
Reference in New Issue
Block a user