Files
cairnquire/apps/macos-sync/Sources/cairnquire-cli/main.swift
2026-06-01 10:53:28 -04:00

399 lines
13 KiB
Swift

import Foundation
import Darwin
import CairnquireSync
// MARK: - CLI
enum CLIError: Error {
case invalidArguments
case missingServerURL
case missingToken
case fileNotFound
}
final class TerminalProgress: @unchecked Sendable {
private let lock = NSLock()
private var lastPercent = -1
func update(_ progress: Double) {
let percent = min(max(Int(progress * 100), 0), 100)
lock.lock()
defer { lock.unlock() }
guard percent != lastPercent else { return }
lastPercent = percent
print("\rUploading... \(percent)%", terminator: "")
fflush(stdout)
}
func finishLine() {
print("")
}
}
func readClipboard() -> String? {
let task = Process()
task.launchPath = "/usr/bin/pbpaste"
let pipe = Pipe()
task.standardOutput = pipe
do {
try task.run()
let data = pipe.fileHandleForReading.readDataToEndOfFile()
return String(data: data, encoding: .utf8)
} catch {
return nil
}
}
struct CLI {
static func run() async {
let args = Array(CommandLine.arguments.dropFirst())
do {
try await execute(args: args)
} catch {
print("Error: \(error.localizedDescription)")
exit(1)
}
}
static func execute(args: [String]) async throws {
guard let command = args.first else {
printUsage()
return
}
switch command {
case "upload", "up":
try await uploadCommand(args: Array(args.dropFirst()))
case "note", "n":
try await noteCommand(args: Array(args.dropFirst()))
case "sync":
try await syncCommand(args: Array(args.dropFirst()))
case "config":
try await configCommand(args: Array(args.dropFirst()))
case "help", "-h", "--help":
printHelp(for: args.dropFirst().first)
default:
print("Unknown command: \(command)")
printUsage()
}
}
// MARK: - Commands
static func uploadCommand(args: [String]) async throws {
if args.contains("--help") || args.contains("-h") {
printUploadUsage()
return
}
guard let filePath = args.first else {
printUploadUsage()
throw CLIError.invalidArguments
}
let config = ConfigStore.shared.load()
guard let token = config.apiToken else {
print("No API token configured. Run: cairnquire-cli config --token <token>")
throw CLIError.missingToken
}
guard let baseURL = config.serverBaseURL else {
print("No server URL configured. Run: cairnquire-cli config --server <url>")
throw CLIError.missingServerURL
}
let fileURL = URL(fileURLWithPath: filePath)
guard FileManager.default.fileExists(atPath: filePath) else {
print("File not found: \(filePath)")
throw CLIError.fileNotFound
}
let client = APIClient(baseURL: baseURL, apiToken: token)
let manager = UploadManager(client: client, config: config)
print("Uploading \(fileURL.lastPathComponent)...")
let progress = TerminalProgress()
let result = try await uploadFileResolvingDuplicates(manager: manager, fileURL: fileURL, progress: progress)
progress.finishLine()
print("✓ Uploaded successfully")
print(" URL: \(result.url)")
print(" Hash: \(result.hash)")
print(" Copied to clipboard")
}
static func noteCommand(args: [String]) async throws {
if args.contains("--help") || args.contains("-h") {
printNoteUsage()
return
}
let config = ConfigStore.shared.load()
guard let token = config.apiToken else {
print("No API token configured. Run: cairnquire-cli config --token <token>")
throw CLIError.missingToken
}
guard let baseURL = config.serverBaseURL else {
print("No server URL configured. Run: cairnquire-cli config --server <url>")
throw CLIError.missingServerURL
}
let content: String
if args.contains("--clipboard") || args.isEmpty {
guard let clipboard = readClipboard() else {
print("Clipboard is empty")
throw CLIError.invalidArguments
}
content = clipboard
} else {
// Read from stdin or args
if args.first == "-" {
content = String(data: FileHandle.standardInput.availableData, encoding: .utf8) ?? ""
} else {
content = args.joined(separator: " ")
}
}
guard !content.isEmpty else {
print("No content provided")
throw CLIError.invalidArguments
}
let title = args.first(where: { $0.hasPrefix("--title=") })?.replacingOccurrences(of: "--title=", with: "")
let client = APIClient(baseURL: baseURL, apiToken: token)
let manager = UploadManager(client: client, config: config)
print("Creating note...")
let result = try await manager.createNote(from: content, title: title)
print("✓ Note created")
print(" Path: \(result.path)")
print(" Title: \(result.title)")
print(" Hash: \(result.hash)")
}
static func syncCommand(args: [String]) async throws {
if args.contains("--help") || args.contains("-h") {
printSyncUsage()
return
}
let config = ConfigStore.shared.load()
guard let token = config.apiToken else {
print("No API token configured")
throw CLIError.missingToken
}
guard let baseURL = config.serverBaseURL else {
print("No server URL configured")
throw CLIError.missingServerURL
}
guard let syncFolder = config.syncFolder else {
print("No sync folder configured")
throw CLIError.invalidArguments
}
let client = APIClient(baseURL: baseURL, apiToken: token)
let engine = SyncEngine(client: client, config: config)
print("Syncing \(syncFolder)...")
try await engine.performSync()
print("✓ Sync complete")
}
static func configCommand(args: [String]) async throws {
if args.contains("--help") || args.contains("-h") {
printConfigUsage()
return
}
var config = ConfigStore.shared.load()
var i = 0
while i < args.count {
let arg = args[i]
guard i + 1 < args.count else {
print("Missing value for \(arg)")
throw CLIError.invalidArguments
}
let value = args[i + 1]
switch arg {
case "--server":
config.serverURL = value
case "--token":
config.apiToken = value
case "--folder":
config.syncFolder = value
case "--upload-folder":
config.defaultUploadFolder = value
default:
print("Unknown config option: \(arg)")
throw CLIError.invalidArguments
}
i += 2
}
await ConfigStore.shared.save(config)
print("Configuration saved:")
print(" Server: \(config.serverURL)")
print(" Sync Folder: \(config.syncFolder ?? "(none)")")
print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")")
print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")")
}
static func uploadFileResolvingDuplicates(
manager: UploadManager,
fileURL: URL,
progress: TerminalProgress
) async throws -> UploadResult {
var duplicateAction: UploadDuplicateAction?
while true {
do {
return try await manager.uploadFile(
fileURL,
duplicateAction: duplicateAction,
onProgress: { value in progress.update(value) }
)
} catch let error as APIError where error.isUploadConflict {
progress.finishLine()
duplicateAction = try promptForDuplicate(error, filename: fileURL.lastPathComponent)
print("Uploading \(fileURL.lastPathComponent)...")
}
}
}
static func promptForDuplicate(_ error: APIError, filename: String) throws -> UploadDuplicateAction {
if error.conflictType == "document" {
print("A document named \(filename) already exists in the inbox.")
if let existingPath = error.existingPath, !existingPath.isEmpty {
print("Existing document: \(existingPath)")
}
print("[k] Keep both [r] Replace existing [c] Cancel")
print("Choice: ", terminator: "")
fflush(stdout)
switch readLine()?.lowercased() {
case "k", "keep", "keep-both":
return .rename
case "r", "replace", "overwrite":
return .overwrite
default:
throw UploadCancelledError()
}
}
print("\(filename) was already uploaded.")
if let existingURL = error.existingUrl, !existingURL.isEmpty {
print("Existing URL: \(existingURL)")
}
print("[u] Use existing URL [c] Cancel")
print("Choice: ", terminator: "")
fflush(stdout)
switch readLine()?.lowercased() {
case "u", "use", "reuse":
return .reuse
default:
throw UploadCancelledError()
}
}
static func printUsage() {
print("""
Cairnquire CLI - macOS sync and upload tool
USAGE:
cairnquire-cli <command> [options]
COMMANDS:
upload, up <file> Upload a file and copy URL to clipboard
note, n [text] Create a new note from clipboard or arguments
sync Sync the configured folder once
config [options] Configure the shared app and CLI settings
help [command] Show general or command-specific help
EXAMPLES:
cairnquire-cli upload ~/Documents/report.pdf
cairnquire-cli note --clipboard --title="Meeting Notes"
echo "Hello World" | cairnquire-cli note -
cairnquire-cli config --server http://localhost:8080 --token cq_pat_xxx
cairnquire-cli sync
Run 'cairnquire-cli help <command>' for details.
""")
}
static func printHelp(for command: String?) {
switch command {
case "upload", "up":
printUploadUsage()
case "note", "n":
printNoteUsage()
case "sync":
printSyncUsage()
case "config":
printConfigUsage()
default:
printUsage()
}
}
static func printUploadUsage() {
print("""
USAGE:
cairnquire-cli upload <file>
Upload one file and copy its canonical Cairnquire URL to the clipboard.
Readable text files become rendered documents. Other files remain attachments.
If the inbox name or attachment content already exists, the CLI asks what to do.
""")
}
static func printNoteUsage() {
print("""
USAGE:
cairnquire-cli note [--clipboard] [--title="Title"]
cairnquire-cli note <text>
cairnquire-cli note -
Create a markdown note from clipboard content, arguments, or stdin.
""")
}
static func printSyncUsage() {
print("""
USAGE:
cairnquire-cli sync
Run one bidirectional sync for the folder configured with:
cairnquire-cli config --folder <path>
The menubar app also watches this folder when auto-sync is enabled in Settings.
""")
}
static func printConfigUsage() {
print("""
USAGE:
cairnquire-cli config [options]
OPTIONS:
--server <url> Set the Cairnquire server URL
--token <token> Set the API token
--folder <path> Set the local folder used by sync
--upload-folder <path> Set the server folder for readable uploads
The menubar app and CLI use the same saved configuration.
""")
}
}
// MARK: - Entry Point
@main
struct CairnquireCLI {
static func main() async {
await CLI.run()
}
}