Skip to content
Merged
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
14 changes: 12 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,22 @@ let package = Package(
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
],
targets: [
.executableTarget(
name: "bcli",
.target(
name: "BearCLICore",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
],
path: "Sources/BearCLICore"
),
.executableTarget(
name: "bcli",
dependencies: ["BearCLICore"],
path: "Sources/bcli"
),
.testTarget(
name: "BearCLITests",
dependencies: ["BearCLICore"],
path: "Tests/BearCLITests"
),
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import Glibc

/// A minimal HTTP server that serves an Apple Sign-In page
/// and waits for the browser to POST back a ckWebAuthToken.
class AuthServer {
public class AuthServer {
private let preferredPort: UInt16 = 19222
private let timeoutSeconds: Int = 120
private var serverSocket: Int32 = -1
Expand Down Expand Up @@ -457,7 +457,7 @@ class AuthServer {

// MARK: - Public Interface

func startAndWaitForToken() -> String? {
public func startAndWaitForToken() -> String? {
do {
let (sock, port) = try createServerSocket()
serverSocket = sock
Expand Down Expand Up @@ -504,21 +504,21 @@ class AuthServer {
}
}

var port: UInt16 { actualPort }
public var port: UInt16 { actualPort }

func openInBrowser() {
public func openInBrowser() {
let p = Process()
p.executableURL = URL(fileURLWithPath: "/usr/bin/open")
p.arguments = ["http://localhost:\(actualPort)/"]
try? p.run()
}
}

enum AuthServerError: Error, CustomStringConvertible {
public enum AuthServerError: Error, CustomStringConvertible {
case socketCreationFailed(String)
case bindFailed(String)

var description: String {
public var description: String {
switch self {
case .socketCreationFailed(let msg): return "Socket creation failed: \(msg)"
case .bindFailed(let msg): return "Bind failed: \(msg)"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
import Foundation

/// Client for CloudKit Web Services REST API targeting Bear's iCloud container.
struct CloudKitAPI {
let auth: AuthConfig
public struct CloudKitAPI {
public let auth: AuthConfig

public init(auth: AuthConfig) {
self.auth = auth
}

private let baseURL = "https://api.apple-cloudkit.com/database/1/iCloud.net.shinyfrog.bear/production/private"
private let bearZone = CKZoneID(zoneName: "Notes", ownerRecordName: nil)
Expand Down Expand Up @@ -50,15 +54,15 @@ struct CloudKitAPI {

// MARK: - Zones

func listZones() async throws -> [CKZone] {
public func listZones() async throws -> [CKZone] {
struct Empty: Encodable {}
let response: CKZoneListResponse = try await post(path: "zones/list", body: Empty())
return response.zones
}

// MARK: - Query Notes

func queryNotes(
public func queryNotes(
trashed: Bool = false,
archived: Bool = false,
limit: Int = 50,
Expand Down Expand Up @@ -98,7 +102,7 @@ struct CloudKitAPI {
}

/// Query notes with pagination support, fetching all results
func queryAllNotes(
public func queryAllNotes(
trashed: Bool = false,
archived: Bool = false,
desiredKeys: [String]? = nil
Expand Down Expand Up @@ -151,7 +155,7 @@ struct CloudKitAPI {

// MARK: - Query Tags

func queryTags(limit: Int = 200) async throws -> [CKRecord] {
public func queryTags(limit: Int = 200) async throws -> [CKRecord] {
let query = CKQuery(
recordType: "SFNoteTag",
filterBy: [],
Expand All @@ -170,7 +174,7 @@ struct CloudKitAPI {

// MARK: - Lookup by ID

func lookupRecords(ids: [String], desiredKeys: [String]? = nil) async throws -> [CKRecord] {
public func lookupRecords(ids: [String], desiredKeys: [String]? = nil) async throws -> [CKRecord] {
let request = CKRecordLookupRequest(
records: ids.map { CKRecordRef(recordName: $0) },
zoneID: bearZone,
Expand All @@ -183,7 +187,7 @@ struct CloudKitAPI {

// MARK: - Download Asset (note text)

func downloadAsset(url: String) async throws -> String {
public func downloadAsset(url: String) async throws -> String {
guard let assetURL = URL(string: url) else {
throw BearCLIError.invalidURL(url)
}
Expand All @@ -203,7 +207,7 @@ struct CloudKitAPI {

// MARK: - Modify Records (create/update)

func modifyRecords(operations: [[String: AnyCodableValue]]) async throws -> [CKRecord] {
public func modifyRecords(operations: [[String: AnyCodableValue]]) async throws -> [CKRecord] {
let body: [String: AnyCodableValue] = [
"operations": .array(operations.map { .dictionary($0) }),
"zoneID": .dictionary(["zoneName": .string("Notes")]),
Expand All @@ -214,7 +218,7 @@ struct CloudKitAPI {
}

/// Create a new note. Returns the created CKRecord.
func createNote(title: String, text: String, tags: [String] = []) async throws -> CKRecord {
public func createNote(title: String, text: String, tags: [String] = []) async throws -> CKRecord {
let noteID = UUID().uuidString
let now = Int64(Date().timeIntervalSince1970 * 1000)

Expand Down Expand Up @@ -280,7 +284,7 @@ struct CloudKitAPI {
}

/// Update an existing note's text content.
func updateNote(record: CKRecord, newText: String) async throws -> CKRecord {
public func updateNote(record: CKRecord, newText: String) async throws -> CKRecord {
let now = Int64(Date().timeIntervalSince1970 * 1000)

// Extract title from the first H1 line of the new text, or keep existing
Expand Down Expand Up @@ -379,7 +383,7 @@ struct CloudKitAPI {
}

/// Trash a note (soft delete).
func trashNote(record: CKRecord) async throws -> CKRecord {
public func trashNote(record: CKRecord) async throws -> CKRecord {
let now = Int64(Date().timeIntervalSince1970 * 1000)
let newClock = incrementVectorClock(
record.fields["vectorClock"]?.value.stringValue ?? ""
Expand Down Expand Up @@ -585,7 +589,7 @@ struct CloudKitAPI {

// MARK: - Zone Changes (incremental sync)

func fetchZoneChanges(syncToken: String?, resultsLimit: Int = 200) async throws -> CKZoneChangeResult {
public func fetchZoneChanges(syncToken: String?, resultsLimit: Int = 200) async throws -> CKZoneChangeResult {
var zone: [String: AnyCodableValue] = [
"zoneID": .dictionary(["zoneName": .string("Notes")]),
"resultsLimit": .int(Int64(resultsLimit)),
Expand All @@ -607,7 +611,7 @@ struct CloudKitAPI {

// MARK: - Search (client-side title match)

func searchNotes(query searchTerm: String, limit: Int = 50) async throws -> [CKRecord] {
public func searchNotes(query searchTerm: String, limit: Int = 50) async throws -> [CKRecord] {
// CloudKit doesn't support full-text search natively.
// Fetch the lightweight index and filter client-side by title.
let allRecords = try await queryAllNotes(
Expand All @@ -634,15 +638,15 @@ struct CloudKitAPI {

// MARK: - Errors

enum BearCLIError: Error, CustomStringConvertible {
public enum BearCLIError: Error, CustomStringConvertible {
case authExpired
case authNotConfigured
case apiError(Int, String)
case networkError(String)
case invalidURL(String)
case noteNotFound(String)

var description: String {
public var description: String {
switch self {
case .authExpired:
return "Auth token expired. Run `bcli auth` to re-authenticate."
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ArgumentParser
import Foundation

struct AuthCommand: ParsableCommand {
static let configuration = CommandConfiguration(
public struct AuthCommand: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "auth",
abstract: "Authenticate with iCloud for Bear access"
)
Expand All @@ -13,7 +13,9 @@ struct AuthCommand: ParsableCommand {
@Flag(name: .long, help: "Force browser-based authentication even if already authenticated")
var browser: Bool = false

func run() throws {
public init() {}

public func run() throws {
let webAuthToken: String

if let t = token {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ArgumentParser
import Foundation

struct CreateNote: ParsableCommand {
static let configuration = CommandConfiguration(
public struct CreateNote: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a new Bear note"
)
Expand All @@ -22,7 +22,9 @@ struct CreateNote: ParsableCommand {
@Flag(name: .long, help: "Output created note ID only (for scripting)")
var quiet: Bool = false

func run() throws {
public init() {}

public func run() throws {
let auth = try loadAuth()
let api = CloudKitAPI(auth: auth)
let title = self.title
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ArgumentParser
import Foundation

struct EditNote: ParsableCommand {
static let configuration = CommandConfiguration(
public struct EditNote: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "edit",
abstract: "Edit a Bear note"
)
Expand All @@ -19,7 +19,9 @@ struct EditNote: ParsableCommand {
@Flag(name: .long, help: "Open in $EDITOR for interactive editing")
var editor: Bool = false

func run() throws {
public init() {}

public func run() throws {
let auth = try loadAuth()
let api = CloudKitAPI(auth: auth)
let noteID = self.noteID
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ArgumentParser
import Foundation

struct ExportNotes: ParsableCommand {
static let configuration = CommandConfiguration(
public struct ExportNotes: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "export",
abstract: "Export Bear notes as markdown files"
)
Expand All @@ -19,7 +19,9 @@ struct ExportNotes: ParsableCommand {
@Flag(name: .long, help: "Include YAML frontmatter with metadata")
var frontmatter: Bool = false

func run() throws {
public init() {}

public func run() throws {
let auth = try loadAuth()
let api = CloudKitAPI(auth: auth)
let outputDir = self.outputDir
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ArgumentParser
import Foundation

struct GetNote: ParsableCommand {
static let configuration = CommandConfiguration(
public struct GetNote: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "get",
abstract: "Get a Bear note's content"
)
Expand All @@ -16,7 +16,9 @@ struct GetNote: ParsableCommand {
@Flag(name: .long, help: "Output as JSON")
var json: Bool = false

func run() throws {
public init() {}

public func run() throws {
let auth = try loadAuth()
let api = CloudKitAPI(auth: auth)
let noteID = self.noteID
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ArgumentParser
import Foundation

struct ListNotes: ParsableCommand {
static let configuration = CommandConfiguration(
public struct ListNotes: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "ls",
abstract: "List Bear notes"
)
Expand All @@ -25,7 +25,9 @@ struct ListNotes: ParsableCommand {
@Flag(name: .long, help: "Output as JSON")
var json: Bool = false

func run() throws {
public init() {}

public func run() throws {
let auth = try loadAuth()
let api = CloudKitAPI(auth: auth)
let all = self.all
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ArgumentParser
import Foundation

struct ListTags: ParsableCommand {
static let configuration = CommandConfiguration(
public struct ListTags: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "tags",
abstract: "List all Bear tags"
)
Expand All @@ -13,7 +13,9 @@ struct ListTags: ParsableCommand {
@Flag(name: .long, help: "Output as JSON")
var json: Bool = false

func run() throws {
public init() {}

public func run() throws {
let auth = try loadAuth()
let api = CloudKitAPI(auth: auth)
let flat = self.flat
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import ArgumentParser
import Foundation

struct SearchNotes: ParsableCommand {
static let configuration = CommandConfiguration(
public struct SearchNotes: ParsableCommand {
public static let configuration = CommandConfiguration(
commandName: "search",
abstract: "Search Bear notes (full-text)"
)
Expand All @@ -19,7 +19,9 @@ struct SearchNotes: ParsableCommand {
@Flag(name: .long, help: "Skip auto-sync (use existing cache as-is)")
var noSync: Bool = false

func run() throws {
public init() {}

public func run() throws {
let auth = try loadAuth()
let api = CloudKitAPI(auth: auth)
let query = self.query
Expand Down
Loading