Skip to content
Open
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
4 changes: 2 additions & 2 deletions Sources/SagaCLI/BuildCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ struct Build: ParsableCommand {
)

func run() throws {
print("Building site...")
log("Building site...")

let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
Expand All @@ -17,7 +17,7 @@ struct Build: ParsableCommand {
process.waitUntilExit()

if process.terminationStatus == 0 {
print("Build complete.")
log("Build complete.")
} else {
throw ExitCode(process.terminationStatus)
}
Expand Down
112 changes: 67 additions & 45 deletions Sources/SagaCLI/DevCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,10 +27,37 @@ struct Dev: ParsableCommand {
}
try cachePath.mkpath()

print("Building site...")
let buildResult = runBuild(cachePath: cachePath)
if !buildResult {
print("Initial build failed, starting server anyway...")
// Find the executable product name from Package.swift
guard let productName = findExecutableProduct() else {
print("Could not find an executable product in Package.swift")
throw ExitCode.failure
}

// Initial build
log("Building site...")
guard swiftBuild() else {
log("Initial build failed.")
throw ExitCode.failure
}

// Set up SIGUSR2 handler — the site process signals us when a build completes
let buildComplete = DispatchSemaphore(value: 0)
signal(SIGUSR2, SIG_IGN)
let sigusr2Source = DispatchSource.makeSignalSource(signal: SIGUSR2, queue: DispatchQueue(label: "Saga.Signal"))
sigusr2Source.setEventHandler { buildComplete.signal() }
sigusr2Source.resume()

// Launch the site process (stays alive, waiting for SIGUSR1 between rebuilds)
var siteProcess = launchSiteProcess(productName: productName, cachePath: cachePath)
if let siteProcess {
// Wait for the initial build to finish
buildComplete.wait()
guard siteProcess.isRunning else {
log("Initial build failed.")
throw ExitCode.failure
}
} else {
log("Failed to launch site process, starting server anyway...")
}

// Start the dev server
Expand All @@ -48,7 +75,7 @@ struct Dev: ParsableCommand {

// Give the server a moment to start
Thread.sleep(forTimeInterval: 0.5)
print("Development server running at http://localhost:\(port)/")
log("Development server running at http://localhost:\(port)/")

// Open the browser
openBrowser(url: "http://localhost:\(port)/")
Expand All @@ -66,13 +93,13 @@ struct Dev: ParsableCommand {

// Start monitoring
if !ignore.isEmpty {
print("Ignoring patterns: \(ignore.joined(separator: ", "))")
log("Ignoring patterns: \(ignore.joined(separator: ", "))")
}

var isRebuilding = false
let rebuildLock = NSLock()

let folderMonitor = FolderMonitor(paths: paths, ignoredPatterns: defaultIgnorePatterns + ignore) {
let folderMonitor = FolderMonitor(paths: paths, ignoredPatterns: defaultIgnorePatterns + ignore) { changedPaths in
rebuildLock.lock()
guard !isRebuilding else {
rebuildLock.unlock()
Expand All @@ -81,13 +108,39 @@ struct Dev: ParsableCommand {
isRebuilding = true
rebuildLock.unlock()

print("Change detected, rebuilding...")
let success = runBuild(cachePath: cachePath)
if success {
print("Rebuild complete.")
if changedPaths.contains(where: { $0.hasSuffix(".swift") }) {
// Swift code changed: kill the process, recompile, relaunch
log("Source code changed, recompiling...")
siteProcess?.terminate()
siteProcess?.waitUntilExit()

guard swiftBuild() else {
log("Build failed")
rebuildLock.lock()
isRebuilding = false
rebuildLock.unlock()
return
}

siteProcess = launchSiteProcess(productName: productName, cachePath: cachePath)
} else {
// Content changed: signal the running process to rebuild
log("Change detected, rebuilding...")
if let process = siteProcess, process.isRunning {
kill(process.processIdentifier, SIGUSR1)
} else {
// Process died, relaunch
siteProcess = launchSiteProcess(productName: productName, cachePath: cachePath)
}
}

// Wait for the site process to signal build completion
buildComplete.wait()

if let process = siteProcess, process.isRunning {
server.sendReload()
} else {
print("Rebuild failed.")
log("Rebuild failed")
}

rebuildLock.lock()
Expand All @@ -100,50 +153,19 @@ struct Dev: ParsableCommand {
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT, queue: signalsQueue)
sigintSrc.setEventHandler {
print("\nShutting down...")
siteProcess?.terminate()
server.stop()
Foundation.exit(0)
}
sigintSrc.resume()
signal(SIGINT, SIG_IGN)

print("Watching for changes in: \(watch.joined(separator: ", "))")
log("Watching for changes in: \(watch.joined(separator: ", "))")

// Prevent folderMonitor from being deallocated
withExtendedLifetime(folderMonitor) {
// Keep running
dispatchMain()
}
}

private func runBuild(cachePath: Path) -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["swift", "run"]
process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)

var env = ProcessInfo.processInfo.environment
env["SAGA_DEV"] = "1"
env["SAGA_CACHE_DIR"] = cachePath.string
process.environment = env

do {
try process.run()
process.waitUntilExit()
return process.terminationStatus == 0
} catch {
print("Build error: \(error)")
return false
}
}

private func openBrowser(url: String) {
#if os(macOS)
Process.launchedProcess(launchPath: "/usr/bin/open", arguments: [url])
#elseif os(Linux)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["xdg-open", url]
try? process.run()
#endif
}
}
25 changes: 10 additions & 15 deletions Sources/SagaCLI/FolderMonitor.swift
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import Foundation

class FolderMonitor {
private let callback: () -> Void
private let callback: (Set<String>) -> Void
private let ignoredPatterns: [String]
private let basePath: String
private let paths: [String]
private var knownFiles: [String: Date] = [:]
private var timer: DispatchSourceTimer?

init(paths: [String], ignoredPatterns: [String] = [], folderDidChange: @escaping () -> Void) {
init(paths: [String], ignoredPatterns: [String] = [], folderDidChange: @escaping (Set<String>) -> Void) {
self.paths = paths
callback = folderDidChange
self.ignoredPatterns = ignoredPatterns
Expand All @@ -30,35 +30,30 @@ class FolderMonitor {
private func checkForChanges() {
let currentFiles = scanFiles()

var changed = false
var changedPaths: Set<String> = []

// Check for new or modified files
for (path, modDate) in currentFiles {
if let previousDate = knownFiles[path] {
if modDate > previousDate {
changed = true
break
changedPaths.insert(path)
}
} else {
// New file
changed = true
break
changedPaths.insert(path)
}
}

// Check for deleted files
if !changed {
for path in knownFiles.keys {
if currentFiles[path] == nil {
changed = true
break
}
for path in knownFiles.keys {
if currentFiles[path] == nil {
changedPaths.insert(path)
}
}

if changed {
if !changedPaths.isEmpty {
knownFiles = currentFiles
callback()
callback(changedPaths)
}
}

Expand Down
111 changes: 111 additions & 0 deletions Sources/SagaCLI/Utils.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import Foundation
import SagaPathKit

private let logDateFormatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss"
return f
}()

func log(_ message: String) {
print("\(logDateFormatter.string(from: Date())) | \(message)")
}

/// Find the first executable product name using `swift package dump-package`.
func findExecutableProduct() -> String? {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["swift", "package", "dump-package"]
process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = FileHandle.nullDevice

do {
try process.run()
process.waitUntilExit()
guard process.terminationStatus == 0 else { return nil }

let data = pipe.fileHandleForReading.readDataToEndOfFile()
guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any],
let products = json["products"] as? [[String: Any]]
else { return nil }

// Find the first executable product
for product in products {
if let type = product["type"] as? [String: Any],
type["executable"] != nil,
let name = product["name"] as? String
{
return name
}
}

// Fall back to the first executable target (packages without explicit products)
if let targets = json["targets"] as? [[String: Any]] {
for target in targets {
if let type = target["type"] as? String,
type == "executable",
let name = target["name"] as? String
{
return name
}
}
}

return nil
} catch {
return nil
}
}

func swiftBuild() -> Bool {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["swift", "build"]
process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)
process.environment = ProcessInfo.processInfo.environment

do {
try process.run()
process.waitUntilExit()
return process.terminationStatus == 0
} catch {
print("Build error: \(error)")
return false
}
}

func launchSiteProcess(productName: String, cachePath: Path) -> Process? {
let binPath = FileManager.default.currentDirectoryPath + "/.build/debug/\(productName)"

let process = Process()
process.executableURL = URL(fileURLWithPath: binPath)
process.currentDirectoryURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath)

var env = ProcessInfo.processInfo.environment
env["SAGA_DEV"] = "1"
env["SAGA_CACHE_DIR"] = cachePath.string
env["SAGA_DEV_PID"] = "\(ProcessInfo.processInfo.processIdentifier)"
process.environment = env

do {
try process.run()
return process
} catch {
print("Launch error: \(error)")
return nil
}
}

func openBrowser(url: String) {
#if os(macOS)
Process.launchedProcess(launchPath: "/usr/bin/open", arguments: [url])
#elseif os(Linux)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/env")
process.arguments = ["xdg-open", url]
try? process.run()
#endif
}