chore: rename project to clawdbot
This commit is contained in:
459
apps/macos/Sources/Clawdbot/CommandResolver.swift
Normal file
459
apps/macos/Sources/Clawdbot/CommandResolver.swift
Normal file
@@ -0,0 +1,459 @@
|
||||
import Foundation
|
||||
|
||||
enum CommandResolver {
|
||||
private static let projectRootDefaultsKey = "clawdbot.gatewayProjectRootPath"
|
||||
private static let helperName = "clawdbot"
|
||||
|
||||
static func gatewayEntrypoint(in root: URL) -> String? {
|
||||
let distEntry = root.appendingPathComponent("dist/index.js").path
|
||||
if FileManager.default.isReadableFile(atPath: distEntry) { return distEntry }
|
||||
let binEntry = root.appendingPathComponent("bin/clawdbot.js").path
|
||||
if FileManager.default.isReadableFile(atPath: binEntry) { return binEntry }
|
||||
return nil
|
||||
}
|
||||
|
||||
static func runtimeResolution() -> Result<RuntimeResolution, RuntimeResolutionError> {
|
||||
RuntimeLocator.resolve(searchPaths: self.preferredPaths())
|
||||
}
|
||||
|
||||
static func runtimeResolution(searchPaths: [String]?) -> Result<RuntimeResolution, RuntimeResolutionError> {
|
||||
RuntimeLocator.resolve(searchPaths: searchPaths ?? self.preferredPaths())
|
||||
}
|
||||
|
||||
static func makeRuntimeCommand(
|
||||
runtime: RuntimeResolution,
|
||||
entrypoint: String,
|
||||
subcommand: String,
|
||||
extraArgs: [String]) -> [String]
|
||||
{
|
||||
[runtime.path, entrypoint, subcommand] + extraArgs
|
||||
}
|
||||
|
||||
static func runtimeErrorCommand(_ error: RuntimeResolutionError) -> [String] {
|
||||
let message = RuntimeLocator.describeFailure(error)
|
||||
return self.errorCommand(with: message)
|
||||
}
|
||||
|
||||
static func errorCommand(with message: String) -> [String] {
|
||||
let script = """
|
||||
cat <<'__CLAWDBOT_ERR__' >&2
|
||||
\(message)
|
||||
__CLAWDBOT_ERR__
|
||||
exit 1
|
||||
"""
|
||||
return ["/bin/sh", "-c", script]
|
||||
}
|
||||
|
||||
static func projectRoot() -> URL {
|
||||
if let stored = UserDefaults.standard.string(forKey: self.projectRootDefaultsKey),
|
||||
let url = self.expandPath(stored),
|
||||
FileManager.default.fileExists(atPath: url.path)
|
||||
{
|
||||
return url
|
||||
}
|
||||
let fallback = FileManager.default.homeDirectoryForCurrentUser
|
||||
.appendingPathComponent("Projects/clawdbot")
|
||||
if FileManager.default.fileExists(atPath: fallback.path) {
|
||||
return fallback
|
||||
}
|
||||
return FileManager.default.homeDirectoryForCurrentUser
|
||||
}
|
||||
|
||||
static func setProjectRoot(_ path: String) {
|
||||
UserDefaults.standard.set(path, forKey: self.projectRootDefaultsKey)
|
||||
}
|
||||
|
||||
static func projectRootPath() -> String {
|
||||
self.projectRoot().path
|
||||
}
|
||||
|
||||
static func preferredPaths() -> [String] {
|
||||
let current = ProcessInfo.processInfo.environment["PATH"]?
|
||||
.split(separator: ":").map(String.init) ?? []
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser
|
||||
let projectRoot = self.projectRoot()
|
||||
return self.preferredPaths(home: home, current: current, projectRoot: projectRoot)
|
||||
}
|
||||
|
||||
static func preferredPaths(home: URL, current: [String], projectRoot: URL) -> [String] {
|
||||
var extras = [
|
||||
home.appendingPathComponent("Library/pnpm").path,
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
]
|
||||
extras.insert(projectRoot.appendingPathComponent("node_modules/.bin").path, at: 0)
|
||||
extras.insert(contentsOf: self.nodeManagerBinPaths(home: home), at: 1)
|
||||
var seen = Set<String>()
|
||||
// Preserve order while stripping duplicates so PATH lookups remain deterministic.
|
||||
return (extras + current).filter { seen.insert($0).inserted }
|
||||
}
|
||||
|
||||
private static func nodeManagerBinPaths(home: URL) -> [String] {
|
||||
var bins: [String] = []
|
||||
|
||||
// Volta
|
||||
let volta = home.appendingPathComponent(".volta/bin")
|
||||
if FileManager.default.fileExists(atPath: volta.path) {
|
||||
bins.append(volta.path)
|
||||
}
|
||||
|
||||
// asdf
|
||||
let asdf = home.appendingPathComponent(".asdf/shims")
|
||||
if FileManager.default.fileExists(atPath: asdf.path) {
|
||||
bins.append(asdf.path)
|
||||
}
|
||||
|
||||
// fnm
|
||||
bins.append(contentsOf: self.versionedNodeBinPaths(
|
||||
base: home.appendingPathComponent(".local/share/fnm/node-versions"),
|
||||
suffix: "installation/bin"))
|
||||
|
||||
// nvm
|
||||
bins.append(contentsOf: self.versionedNodeBinPaths(
|
||||
base: home.appendingPathComponent(".nvm/versions/node"),
|
||||
suffix: "bin"))
|
||||
|
||||
return bins
|
||||
}
|
||||
|
||||
private static func versionedNodeBinPaths(base: URL, suffix: String) -> [String] {
|
||||
guard FileManager.default.fileExists(atPath: base.path) else { return [] }
|
||||
let entries: [String]
|
||||
do {
|
||||
entries = try FileManager.default.contentsOfDirectory(atPath: base.path)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
func parseVersion(_ name: String) -> [Int] {
|
||||
let trimmed = name.hasPrefix("v") ? String(name.dropFirst()) : name
|
||||
return trimmed.split(separator: ".").compactMap { Int($0) }
|
||||
}
|
||||
|
||||
let sorted = entries.sorted { a, b in
|
||||
let va = parseVersion(a)
|
||||
let vb = parseVersion(b)
|
||||
let maxCount = max(va.count, vb.count)
|
||||
for i in 0..<maxCount {
|
||||
let ai = i < va.count ? va[i] : 0
|
||||
let bi = i < vb.count ? vb[i] : 0
|
||||
if ai != bi { return ai > bi }
|
||||
}
|
||||
// If identical numerically, keep stable ordering.
|
||||
return a > b
|
||||
}
|
||||
|
||||
var paths: [String] = []
|
||||
for entry in sorted {
|
||||
let binDir = base.appendingPathComponent(entry).appendingPathComponent(suffix)
|
||||
let node = binDir.appendingPathComponent("node")
|
||||
if FileManager.default.isExecutableFile(atPath: node.path) {
|
||||
paths.append(binDir.path)
|
||||
}
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
static func findExecutable(named name: String, searchPaths: [String]? = nil) -> String? {
|
||||
for dir in (searchPaths ?? self.preferredPaths()) {
|
||||
let candidate = (dir as NSString).appendingPathComponent(name)
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
static func clawdbotExecutable(searchPaths: [String]? = nil) -> String? {
|
||||
self.findExecutable(named: self.helperName, searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
static func projectClawdbotExecutable(projectRoot: URL? = nil) -> String? {
|
||||
let root = projectRoot ?? self.projectRoot()
|
||||
let candidate = root.appendingPathComponent("node_modules/.bin").appendingPathComponent(self.helperName).path
|
||||
return FileManager.default.isExecutableFile(atPath: candidate) ? candidate : nil
|
||||
}
|
||||
|
||||
static func nodeCliPath() -> String? {
|
||||
let candidate = self.projectRoot().appendingPathComponent("bin/clawdbot.js").path
|
||||
return FileManager.default.isReadableFile(atPath: candidate) ? candidate : nil
|
||||
}
|
||||
|
||||
static func hasAnyClawdbotInvoker(searchPaths: [String]? = nil) -> Bool {
|
||||
if self.clawdbotExecutable(searchPaths: searchPaths) != nil { return true }
|
||||
if self.findExecutable(named: "pnpm", searchPaths: searchPaths) != nil { return true }
|
||||
if self.findExecutable(named: "node", searchPaths: searchPaths) != nil,
|
||||
self.nodeCliPath() != nil
|
||||
{
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static func clawdbotNodeCommand(
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
let settings = self.connectionSettings(defaults: defaults)
|
||||
if settings.mode == .remote, let ssh = self.sshNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
settings: settings)
|
||||
{
|
||||
return ssh
|
||||
}
|
||||
|
||||
let runtimeResult = self.runtimeResolution(searchPaths: searchPaths)
|
||||
|
||||
switch runtimeResult {
|
||||
case let .success(runtime):
|
||||
let root = self.projectRoot()
|
||||
if let clawdbotPath = self.projectClawdbotExecutable(projectRoot: root) {
|
||||
return [clawdbotPath, subcommand] + extraArgs
|
||||
}
|
||||
|
||||
if let entry = self.gatewayEntrypoint(in: root) {
|
||||
return self.makeRuntimeCommand(
|
||||
runtime: runtime,
|
||||
entrypoint: entry,
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs)
|
||||
}
|
||||
if let pnpm = self.findExecutable(named: "pnpm", searchPaths: searchPaths) {
|
||||
// Use --silent to avoid pnpm lifecycle banners that would corrupt JSON outputs.
|
||||
return [pnpm, "--silent", "clawdbot", subcommand] + extraArgs
|
||||
}
|
||||
if let clawdbotPath = self.clawdbotExecutable(searchPaths: searchPaths) {
|
||||
return [clawdbotPath, subcommand] + extraArgs
|
||||
}
|
||||
|
||||
let missingEntry = """
|
||||
clawdbot entrypoint missing (looked for dist/index.js or bin/clawdbot.js); run pnpm build.
|
||||
"""
|
||||
return self.errorCommand(with: missingEntry)
|
||||
|
||||
case let .failure(error):
|
||||
return self.runtimeErrorCommand(error)
|
||||
}
|
||||
}
|
||||
|
||||
// Existing callers still refer to clawdbotCommand; keep it as node alias.
|
||||
static func clawdbotCommand(
|
||||
subcommand: String,
|
||||
extraArgs: [String] = [],
|
||||
defaults: UserDefaults = .standard,
|
||||
searchPaths: [String]? = nil) -> [String]
|
||||
{
|
||||
self.clawdbotNodeCommand(
|
||||
subcommand: subcommand,
|
||||
extraArgs: extraArgs,
|
||||
defaults: defaults,
|
||||
searchPaths: searchPaths)
|
||||
}
|
||||
|
||||
// MARK: - SSH helpers
|
||||
|
||||
private static func sshNodeCommand(subcommand: String, extraArgs: [String], settings: RemoteSettings) -> [String]? {
|
||||
guard !settings.target.isEmpty else { return nil }
|
||||
guard let parsed = self.parseSSHTarget(settings.target) else { return nil }
|
||||
|
||||
var args: [String] = [
|
||||
"-o", "BatchMode=yes",
|
||||
"-o", "IdentitiesOnly=yes",
|
||||
"-o", "StrictHostKeyChecking=accept-new",
|
||||
"-o", "UpdateHostKeys=yes",
|
||||
]
|
||||
if parsed.port > 0 { args.append(contentsOf: ["-p", String(parsed.port)]) }
|
||||
if !settings.identity.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
args.append(contentsOf: ["-i", settings.identity])
|
||||
}
|
||||
let userHost = parsed.user.map { "\($0)@\(parsed.host)" } ?? parsed.host
|
||||
args.append(userHost)
|
||||
|
||||
// Run the real clawdbot CLI on the remote host.
|
||||
let exportedPath = [
|
||||
"/opt/homebrew/bin",
|
||||
"/usr/local/bin",
|
||||
"/usr/bin",
|
||||
"/bin",
|
||||
"/usr/sbin",
|
||||
"/sbin",
|
||||
"$HOME/Library/pnpm",
|
||||
"$PATH",
|
||||
].joined(separator: ":")
|
||||
let quotedArgs = ([subcommand] + extraArgs).map(self.shellQuote).joined(separator: " ")
|
||||
let userPRJ = settings.projectRoot.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
let userCLI = settings.cliPath.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
|
||||
let projectSection = if userPRJ.isEmpty {
|
||||
"""
|
||||
DEFAULT_PRJ="$HOME/Projects/clawdbot"
|
||||
if [ -d "$DEFAULT_PRJ" ]; then
|
||||
PRJ="$DEFAULT_PRJ"
|
||||
cd "$PRJ" || { echo "Project root not found: $PRJ"; exit 127; }
|
||||
fi
|
||||
"""
|
||||
} else {
|
||||
"""
|
||||
PRJ=\(self.shellQuote(userPRJ))
|
||||
cd \(self.shellQuote(userPRJ)) || { echo "Project root not found: \(userPRJ)"; exit 127; }
|
||||
"""
|
||||
}
|
||||
|
||||
let cliSection = if userCLI.isEmpty {
|
||||
""
|
||||
} else {
|
||||
"""
|
||||
CLI_HINT=\(self.shellQuote(userCLI))
|
||||
if [ -n "$CLI_HINT" ]; then
|
||||
if [ -x "$CLI_HINT" ]; then
|
||||
CLI="$CLI_HINT"
|
||||
"$CLI_HINT" \(quotedArgs);
|
||||
exit $?;
|
||||
elif [ -f "$CLI_HINT" ]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
CLI="node $CLI_HINT"
|
||||
node "$CLI_HINT" \(quotedArgs);
|
||||
exit $?;
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
"""
|
||||
}
|
||||
|
||||
let scriptBody = """
|
||||
PATH=\(exportedPath);
|
||||
CLI="";
|
||||
\(cliSection)
|
||||
\(projectSection)
|
||||
if command -v clawdbot >/dev/null 2>&1; then
|
||||
CLI="$(command -v clawdbot)"
|
||||
clawdbot \(quotedArgs);
|
||||
elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/dist/index.js" ]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
CLI="node $PRJ/dist/index.js"
|
||||
node "$PRJ/dist/index.js" \(quotedArgs);
|
||||
else
|
||||
echo "Node >=22 required on remote host"; exit 127;
|
||||
fi
|
||||
elif [ -n "${PRJ:-}" ] && [ -f "$PRJ/bin/clawdbot.js" ]; then
|
||||
if command -v node >/dev/null 2>&1; then
|
||||
CLI="node $PRJ/bin/clawdbot.js"
|
||||
node "$PRJ/bin/clawdbot.js" \(quotedArgs);
|
||||
else
|
||||
echo "Node >=22 required on remote host"; exit 127;
|
||||
fi
|
||||
elif command -v pnpm >/dev/null 2>&1; then
|
||||
CLI="pnpm --silent clawdbot"
|
||||
pnpm --silent clawdbot \(quotedArgs);
|
||||
else
|
||||
echo "clawdbot CLI missing on remote host"; exit 127;
|
||||
fi
|
||||
"""
|
||||
args.append(contentsOf: ["/bin/sh", "-c", scriptBody])
|
||||
return ["/usr/bin/ssh"] + args
|
||||
}
|
||||
|
||||
struct RemoteSettings {
|
||||
let mode: AppState.ConnectionMode
|
||||
let target: String
|
||||
let identity: String
|
||||
let projectRoot: String
|
||||
let cliPath: String
|
||||
}
|
||||
|
||||
static func connectionSettings(defaults: UserDefaults = .standard) -> RemoteSettings {
|
||||
let modeRaw = defaults.string(forKey: connectionModeKey)
|
||||
let mode: AppState.ConnectionMode
|
||||
if let modeRaw {
|
||||
mode = AppState.ConnectionMode(rawValue: modeRaw) ?? .local
|
||||
} else {
|
||||
let seen = defaults.bool(forKey: "clawdbot.onboardingSeen")
|
||||
mode = seen ? .local : .unconfigured
|
||||
}
|
||||
let target = defaults.string(forKey: remoteTargetKey) ?? ""
|
||||
let identity = defaults.string(forKey: remoteIdentityKey) ?? ""
|
||||
let projectRoot = defaults.string(forKey: remoteProjectRootKey) ?? ""
|
||||
let cliPath = defaults.string(forKey: remoteCliPathKey) ?? ""
|
||||
return RemoteSettings(
|
||||
mode: mode,
|
||||
target: self.sanitizedTarget(target),
|
||||
identity: identity,
|
||||
projectRoot: projectRoot,
|
||||
cliPath: cliPath)
|
||||
}
|
||||
|
||||
static var attachExistingGatewayOnly: Bool {
|
||||
UserDefaults.standard.bool(forKey: attachExistingGatewayOnlyKey)
|
||||
}
|
||||
|
||||
static func connectionModeIsRemote(defaults: UserDefaults = .standard) -> Bool {
|
||||
self.connectionSettings(defaults: defaults).mode == .remote
|
||||
}
|
||||
|
||||
private static func sanitizedTarget(_ raw: String) -> String {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.hasPrefix("ssh ") {
|
||||
return trimmed.replacingOccurrences(of: "ssh ", with: "").trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
struct SSHParsedTarget {
|
||||
let user: String?
|
||||
let host: String
|
||||
let port: Int
|
||||
}
|
||||
|
||||
static func parseSSHTarget(_ target: String) -> SSHParsedTarget? {
|
||||
let trimmed = target.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return nil }
|
||||
let userHostPort: String
|
||||
let user: String?
|
||||
if let atRange = trimmed.range(of: "@") {
|
||||
user = String(trimmed[..<atRange.lowerBound])
|
||||
userHostPort = String(trimmed[atRange.upperBound...])
|
||||
} else {
|
||||
user = nil
|
||||
userHostPort = trimmed
|
||||
}
|
||||
|
||||
let host: String
|
||||
let port: Int
|
||||
if let colon = userHostPort.lastIndex(of: ":"), colon != userHostPort.startIndex {
|
||||
host = String(userHostPort[..<colon])
|
||||
let portStr = String(userHostPort[userHostPort.index(after: colon)...])
|
||||
port = Int(portStr) ?? 22
|
||||
} else {
|
||||
host = userHostPort
|
||||
port = 22
|
||||
}
|
||||
|
||||
return SSHParsedTarget(user: user, host: host, port: port)
|
||||
}
|
||||
|
||||
private static func shellQuote(_ text: String) -> String {
|
||||
if text.isEmpty { return "''" }
|
||||
let escaped = text.replacingOccurrences(of: "'", with: "'\\''")
|
||||
return "'\(escaped)'"
|
||||
}
|
||||
|
||||
private static func expandPath(_ path: String) -> URL? {
|
||||
var expanded = path
|
||||
if expanded.hasPrefix("~") {
|
||||
let home = FileManager.default.homeDirectoryForCurrentUser.path
|
||||
expanded.replaceSubrange(expanded.startIndex...expanded.startIndex, with: home)
|
||||
}
|
||||
return URL(fileURLWithPath: expanded)
|
||||
}
|
||||
|
||||
#if SWIFT_PACKAGE
|
||||
static func _testNodeManagerBinPaths(home: URL) -> [String] {
|
||||
self.nodeManagerBinPaths(home: home)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
Reference in New Issue
Block a user