Skip to content
Closed
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
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.10
// swift-tools-version:6.0

import PackageDescription

Expand Down
28 changes: 12 additions & 16 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ $ brew install loopwerk/tap/saga
$ mint install loopwerk/saga-cli
```

**From source:**

```shell-session
$ git clone https://github.com/loopwerk/saga-cli.git
$ cd saga-cli
$ swift package experimental-install
```

> **Migrating from an older version?** The CLI previously lived inside the [Saga](https://github.com/loopwerk/Saga) repository. If you installed via Mint using `mint install loopwerk/Saga`, switch to `mint install loopwerk/saga-cli`.

## Commands
Expand All @@ -44,30 +52,18 @@ $ saga build

### `saga dev`

Start a development server with file watching and auto-reload:
Start a development server with auto-reload on port 3000:

```
$ saga dev
$ saga dev --port 8080
```

Options:

| Flag | Default | Description |
| ---------------- | -------------------- | ---------------------------------------------- |
| `--watch`, `-w` | `content`, `Sources` | Folders to watch for changes (repeatable) |
| `--output`, `-o` | `deploy` | Output folder for the built site |
| `--port`, `-p` | `3000` | Port for the development server |
| `--ignore`, `-i` | | Glob patterns for files to ignore (repeatable) |

Example with custom options:

```
$ saga dev --watch content --watch Sources --output deploy --port 8080 --ignore "*.tmp" --ignore "drafts/*"
```
> **Note:** saga-cli 2.x requires Saga 3.x or later.

## Requirements

Swift 5.5+ and macOS 12+.
Swift 6.0+ and macOS 14+ or Linux.

## License

Expand Down
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
185 changes: 90 additions & 95 deletions Sources/SagaCLI/DevCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,9 @@ struct Dev: ParsableCommand {
abstract: "Build, watch for changes, and serve the site with auto-reload."
)

@Option(name: .shortAndLong, help: "Folder to watch for changes. Can be specified multiple times.")
var watch: [String] = ["content", "Sources"]

@Option(name: .shortAndLong, help: "Output folder for the built site.")
var output: String = "deploy"

@Option(name: .shortAndLong, help: "Port for the development server.")
var port: Int = 3000

@Option(name: .shortAndLong, help: "Glob pattern for files to ignore. Can be specified multiple times.")
var ignore: [String] = []

func run() throws {
// Create a fresh cache directory for this dev session
let cachePath = Path.current + ".build/saga-cache"
Expand All @@ -27,19 +18,79 @@ 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
}

let coordinator = DevCoordinator(productName: productName, cachePath: cachePath, port: port)
try coordinator.start()
}
}

/// Manages the dev server lifecycle: site process, HTTP server, signal handling.
private final class DevCoordinator: @unchecked Sendable {
let productName: String
let cachePath: Path
let port: Int
var siteProcess: Process?
var server: DevServer?

init(productName: String, cachePath: Path, port: Int) {
self.productName = productName
self.cachePath = cachePath
self.port = port
}

func start() throws {
// Set up SIGUSR2 handler — Saga signals us when a build completes so we can reload browsers
signal(SIGUSR2, SIG_IGN)
let sigusr2Source = DispatchSource.makeSignalSource(signal: SIGUSR2, queue: DispatchQueue(label: "Saga.Signal"))
sigusr2Source.setEventHandler { [weak self] in self?.server?.sendReload() }
sigusr2Source.resume()

// Launch the site process. Saga watches its own files and rebuilds internally.
// Exit code 42 means "Swift source changed, recompile me".
siteProcess = launchSiteProcess(productName: productName, cachePath: cachePath)
guard siteProcess != nil else {
log("Failed to launch site process.")
throw ExitCode.failure
}

// Wait for the initial build to complete (SIGUSR2 or process exit)
let initialBuild = DispatchSemaphore(value: 0)
siteProcess?.terminationHandler = { _ in initialBuild.signal() }
let initialSigusr2 = DispatchSource.makeSignalSource(signal: SIGUSR2, queue: DispatchQueue(label: "Saga.InitialBuild"))
initialSigusr2.setEventHandler { initialBuild.signal() }
initialSigusr2.resume()
initialBuild.wait()
initialSigusr2.cancel()
siteProcess?.terminationHandler = nil

// Read the config file written by Saga to detect output path.
// If the config file doesn't exist, this is a Saga 2 site which is not supported.
guard let config = readSagaConfig() else {
log("This version of saga-cli requires Saga 3.x or later.")
siteProcess?.terminate()
throw ExitCode.failure
}

// Start the dev server
let server = DevServer(outputPath: output, port: port)
let devServer = DevServer(outputPath: config.output, port: port)
server = devServer

let serverQueue = DispatchQueue(label: "Saga.DevServer")
serverQueue.async {
do {
try server.start()
try devServer.start()
} catch {
print("Failed to start server: \(error)")
Foundation.exit(1)
Expand All @@ -48,102 +99,46 @@ 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)/")

// Turn watch folders into full paths
let currentPath = FileManager.default.currentDirectoryPath
let paths = watch.map { folder -> String in
if folder.hasPrefix("/") {
return folder
}
return currentPath + "/" + folder
}

let defaultIgnorePatterns = [".DS_Store"]

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

var isRebuilding = false
let rebuildLock = NSLock()

let folderMonitor = FolderMonitor(paths: paths, ignoredPatterns: defaultIgnorePatterns + ignore) {
rebuildLock.lock()
guard !isRebuilding else {
rebuildLock.unlock()
return
}
isRebuilding = true
rebuildLock.unlock()

print("Change detected, rebuilding...")
let success = runBuild(cachePath: cachePath)
if success {
print("Rebuild complete.")
server.sendReload()
} else {
print("Rebuild failed.")
}

rebuildLock.lock()
isRebuilding = false
rebuildLock.unlock()
}

// Handle Ctrl+C shutdown
let signalsQueue = DispatchQueue(label: "Saga.Signals")
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT, queue: signalsQueue)
sigintSrc.setEventHandler {
let sigintSrc = DispatchSource.makeSignalSource(signal: SIGINT, queue: DispatchQueue(label: "Saga.Signals"))
sigintSrc.setEventHandler { [weak self] in
print("\nShutting down...")
server.stop()
self?.siteProcess?.terminate()
self?.server?.stop()
Foundation.exit(0)
}
sigintSrc.resume()
signal(SIGINT, SIG_IGN)

print("Watching for changes in: \(watch.joined(separator: ", "))")
// Watch for process exits. Exit code 42 = Swift source changed, recompile and relaunch.
if let process = siteProcess {
watchProcess(process)
}

// Prevent folderMonitor from being deallocated
withExtendedLifetime(folderMonitor) {
// Keep running
withExtendedLifetime((sigusr2Source, sigintSrc)) {
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
}
}
func watchProcess(_ process: Process) {
process.terminationHandler = { [weak self] terminatedProcess in
guard let self, terminatedProcess.terminationStatus == 42 else { return }

log("Source code changed, recompiling...")
guard swiftBuild() else {
log("Build failed")
return
}

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
self.siteProcess = launchSiteProcess(productName: self.productName, cachePath: self.cachePath)
if let newProcess = self.siteProcess {
self.watchProcess(newProcess)
}
}
}
}
11 changes: 6 additions & 5 deletions Sources/SagaCLI/DevServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import NIOCore
import NIOHTTP1
import NIOPosix

final class DevServer {
final class DevServer: @unchecked Sendable {
private let outputPath: String
private let port: Int
private let group: MultiThreadedEventLoopGroup
Expand Down Expand Up @@ -73,7 +73,7 @@ final class SSEConnectionStore: @unchecked Sendable {
}
}

private final class HTTPHandler: ChannelInboundHandler {
private final class HTTPHandler: ChannelInboundHandler, @unchecked Sendable {
typealias InboundIn = HTTPServerRequestPart
typealias OutboundOut = HTTPServerResponsePart

Expand Down Expand Up @@ -152,10 +152,11 @@ private final class HTTPHandler: ChannelInboundHandler {
let head = HTTPResponseHead(version: .http1_1, status: .ok, headers: headers)
context.writeAndFlush(wrapOutboundOut(.head(head)), promise: nil)

sseConnections.add(context.channel)
let channel = context.channel
sseConnections.add(channel)

context.channel.closeFuture.whenComplete { [weak self] _ in
self?.sseConnections.remove(context.channel)
channel.closeFuture.whenComplete { [weak self] _ in
self?.sseConnections.remove(channel)
}
}

Expand Down
Loading