feat: add permissions commands and dev tasks
This commit is contained in:
parent
beb10cebcd
commit
5582c26016
@ -5,6 +5,7 @@
|
||||
- Show reminders with filters (today/tomorrow/week/overdue/upcoming/completed/all/date)
|
||||
- Manage lists (list, create, rename, delete)
|
||||
- Add, edit, complete, and delete reminders
|
||||
- Authorization status and permission prompt command
|
||||
- JSON and plain output modes for scripting
|
||||
- Flexible date parsing (relative, ISO 8601, and common formats)
|
||||
- GitHub Actions CI with lint, tests, and coverage gate
|
||||
|
||||
45
Makefile
Normal file
45
Makefile
Normal file
@ -0,0 +1,45 @@
|
||||
SHELL := /bin/bash
|
||||
|
||||
.PHONY: help format lint test check build remindctl clean
|
||||
|
||||
help:
|
||||
@printf "%s\n" \
|
||||
"make format - swift format in-place" \
|
||||
"make lint - swift format lint + swiftlint" \
|
||||
"make test - sync version + swift test (coverage enabled)" \
|
||||
"make check - lint + test + coverage gate" \
|
||||
"make build - release build into bin/ (codesigned)" \
|
||||
"make remindctl - clean rebuild + run debug binary (ARGS=...)" \
|
||||
"make clean - swift package clean"
|
||||
|
||||
format:
|
||||
swift format --in-place --recursive Sources Tests
|
||||
|
||||
lint:
|
||||
swift format lint --recursive Sources Tests
|
||||
swiftlint
|
||||
|
||||
test:
|
||||
scripts/generate-version.sh
|
||||
swift test --enable-code-coverage
|
||||
|
||||
check:
|
||||
$(MAKE) lint
|
||||
$(MAKE) test
|
||||
scripts/check-coverage.sh
|
||||
|
||||
build:
|
||||
scripts/generate-version.sh
|
||||
mkdir -p bin
|
||||
swift build -c release --product remindctl
|
||||
cp .build/release/remindctl bin/remindctl
|
||||
codesign --force --sign - --identifier com.steipete.remindctl bin/remindctl
|
||||
|
||||
remindctl:
|
||||
scripts/generate-version.sh
|
||||
swift package clean
|
||||
swift build -c debug --product remindctl
|
||||
./.build/debug/remindctl $(ARGS)
|
||||
|
||||
clean:
|
||||
swift package clean
|
||||
13
README.md
13
README.md
@ -16,6 +16,12 @@ pnpm build
|
||||
# binary at ./bin/remindctl
|
||||
```
|
||||
|
||||
## Development
|
||||
```bash
|
||||
make remindctl ARGS="status" # clean build + run
|
||||
make check # lint + test + coverage gate
|
||||
```
|
||||
|
||||
## Requirements
|
||||
- macOS 14+ (Sonoma or later)
|
||||
- Swift 6.2+
|
||||
@ -44,6 +50,8 @@ remindctl add --title "Call mom" --list Personal --due tomorrow
|
||||
remindctl edit 1 --title "New title" --due 2026-01-04
|
||||
remindctl complete 1 2 3
|
||||
remindctl delete 4A83 --force
|
||||
remindctl status # permission status
|
||||
remindctl authorize # request permissions
|
||||
```
|
||||
|
||||
## Output formats
|
||||
@ -59,5 +67,6 @@ Accepted by `--due` and filters:
|
||||
- ISO 8601 (`2026-01-03T12:34:56Z`)
|
||||
|
||||
## Permissions
|
||||
On first run, macOS will prompt for Reminders access. You can manage access in:
|
||||
System Settings → Privacy & Security → Reminders.
|
||||
Run `remindctl authorize` to trigger the system prompt. If access is denied, enable
|
||||
Terminal (or remindctl) in System Settings → Privacy & Security → Reminders.
|
||||
If running over SSH, grant access on the Mac that runs the command.
|
||||
|
||||
@ -14,9 +14,17 @@ public enum RemindCoreError: LocalizedError, Sendable, Equatable {
|
||||
public var errorDescription: String? {
|
||||
switch self {
|
||||
case .accessDenied:
|
||||
return "Access to Reminders denied. Grant access in System Settings > Privacy & Security > Reminders."
|
||||
return [
|
||||
"Reminders access denied.",
|
||||
"Run `remindctl authorize` to trigger the prompt, then allow Terminal (or remindctl)",
|
||||
"in System Settings > Privacy & Security > Reminders.",
|
||||
"If running over SSH, grant access on the Mac that runs the command.",
|
||||
].joined(separator: " ")
|
||||
case .writeOnlyAccess:
|
||||
return "Reminders access is write-only. Full access is required to read reminders."
|
||||
return [
|
||||
"Reminders access is write-only.",
|
||||
"Switch to Full Access in System Settings > Privacy & Security > Reminders.",
|
||||
].joined(separator: " ")
|
||||
case .listNotFound(let name):
|
||||
return "List not found: \"\(name)\"."
|
||||
case .reminderNotFound(let id):
|
||||
|
||||
@ -10,21 +10,34 @@ public actor RemindersStore {
|
||||
}
|
||||
|
||||
public func requestAccess() async throws {
|
||||
let status = EKEventStore.authorizationStatus(for: .reminder)
|
||||
let status = Self.authorizationStatus()
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
let granted = try await requestFullAccess()
|
||||
if !granted {
|
||||
let updated = try await requestAuthorization()
|
||||
if updated != .fullAccess {
|
||||
throw RemindCoreError.accessDenied
|
||||
}
|
||||
case .denied, .restricted:
|
||||
throw RemindCoreError.accessDenied
|
||||
case .writeOnly:
|
||||
throw RemindCoreError.writeOnlyAccess
|
||||
case .fullAccess, .authorized:
|
||||
case .fullAccess:
|
||||
break
|
||||
@unknown default:
|
||||
throw RemindCoreError.operationFailed("Unknown authorization status")
|
||||
}
|
||||
}
|
||||
|
||||
public static func authorizationStatus() -> RemindersAuthorizationStatus {
|
||||
RemindersAuthorizationStatus(eventKitStatus: EKEventStore.authorizationStatus(for: .reminder))
|
||||
}
|
||||
|
||||
public func requestAuthorization() async throws -> RemindersAuthorizationStatus {
|
||||
let status = Self.authorizationStatus()
|
||||
switch status {
|
||||
case .notDetermined:
|
||||
let granted = try await requestFullAccess()
|
||||
return granted ? .fullAccess : .denied
|
||||
default:
|
||||
return status
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
46
Sources/RemindCore/RemindersAuthorization.swift
Normal file
46
Sources/RemindCore/RemindersAuthorization.swift
Normal file
@ -0,0 +1,46 @@
|
||||
import EventKit
|
||||
import Foundation
|
||||
|
||||
public enum RemindersAuthorizationStatus: String, Codable, Sendable, Equatable {
|
||||
case notDetermined = "not-determined"
|
||||
case restricted = "restricted"
|
||||
case denied = "denied"
|
||||
case writeOnly = "write-only"
|
||||
case fullAccess = "full-access"
|
||||
|
||||
public init(eventKitStatus: EKAuthorizationStatus) {
|
||||
switch eventKitStatus {
|
||||
case .notDetermined:
|
||||
self = .notDetermined
|
||||
case .restricted:
|
||||
self = .restricted
|
||||
case .denied:
|
||||
self = .denied
|
||||
case .writeOnly:
|
||||
self = .writeOnly
|
||||
case .fullAccess, .authorized:
|
||||
self = .fullAccess
|
||||
@unknown default:
|
||||
self = .denied
|
||||
}
|
||||
}
|
||||
|
||||
public var isAuthorized: Bool {
|
||||
self == .fullAccess
|
||||
}
|
||||
|
||||
public var displayName: String {
|
||||
switch self {
|
||||
case .notDetermined:
|
||||
return "Not determined"
|
||||
case .restricted:
|
||||
return "Restricted"
|
||||
case .denied:
|
||||
return "Denied"
|
||||
case .writeOnly:
|
||||
return "Write-only"
|
||||
case .fullAccess:
|
||||
return "Full access"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,8 @@ struct CommandRouter {
|
||||
EditCommand.spec,
|
||||
CompleteCommand.spec,
|
||||
DeleteCommand.spec,
|
||||
StatusCommand.spec,
|
||||
AuthorizeCommand.spec,
|
||||
]
|
||||
let descriptor = CommandDescriptor(
|
||||
name: rootName,
|
||||
|
||||
39
Sources/remindctl/Commands/AuthorizeCommand.swift
Normal file
39
Sources/remindctl/Commands/AuthorizeCommand.swift
Normal file
@ -0,0 +1,39 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum AuthorizeCommand {
|
||||
static var spec: CommandSpec {
|
||||
CommandSpec(
|
||||
name: "authorize",
|
||||
abstract: "Request Reminders access",
|
||||
discussion: "Triggers the Reminders permission prompt when available.",
|
||||
signature: CommandSignatures.withRuntimeFlags(CommandSignature()),
|
||||
usageExamples: [
|
||||
"remindctl authorize",
|
||||
"remindctl authorize --json",
|
||||
"remindctl authorize --quiet",
|
||||
]
|
||||
) { _, runtime in
|
||||
let store = RemindersStore()
|
||||
let current = RemindersStore.authorizationStatus()
|
||||
let status: RemindersAuthorizationStatus
|
||||
if current == .notDetermined {
|
||||
status = try await store.requestAuthorization()
|
||||
} else {
|
||||
status = current
|
||||
}
|
||||
|
||||
OutputRenderer.printAuthorizationStatus(status, format: runtime.outputFormat)
|
||||
|
||||
switch status {
|
||||
case .fullAccess:
|
||||
return
|
||||
case .writeOnly:
|
||||
throw RemindCoreError.writeOnlyAccess
|
||||
case .notDetermined, .denied, .restricted:
|
||||
throw RemindCoreError.accessDenied
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
27
Sources/remindctl/Commands/StatusCommand.swift
Normal file
27
Sources/remindctl/Commands/StatusCommand.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import Commander
|
||||
import Foundation
|
||||
import RemindCore
|
||||
|
||||
enum StatusCommand {
|
||||
static var spec: CommandSpec {
|
||||
CommandSpec(
|
||||
name: "status",
|
||||
abstract: "Show Reminders authorization status",
|
||||
discussion: "Reports the current Reminders permission state without prompting.",
|
||||
signature: CommandSignatures.withRuntimeFlags(CommandSignature()),
|
||||
usageExamples: [
|
||||
"remindctl status",
|
||||
"remindctl status --json",
|
||||
"remindctl status --plain",
|
||||
]
|
||||
) { _, runtime in
|
||||
let status = RemindersStore.authorizationStatus()
|
||||
OutputRenderer.printAuthorizationStatus(status, format: runtime.outputFormat)
|
||||
if runtime.outputFormat == .standard, !status.isAuthorized {
|
||||
for line in PermissionsHelp.guidanceLines(for: status) {
|
||||
Swift.print(line)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -15,6 +15,11 @@ struct ListSummary: Codable, Sendable, Equatable {
|
||||
let overdueCount: Int
|
||||
}
|
||||
|
||||
struct AuthorizationSummary: Codable, Sendable, Equatable {
|
||||
let status: String
|
||||
let authorized: Bool
|
||||
}
|
||||
|
||||
enum OutputRenderer {
|
||||
static func printReminders(_ reminders: [ReminderItem], format: OutputFormat) {
|
||||
switch format {
|
||||
@ -70,6 +75,19 @@ enum OutputRenderer {
|
||||
}
|
||||
}
|
||||
|
||||
static func printAuthorizationStatus(_ status: RemindersAuthorizationStatus, format: OutputFormat) {
|
||||
switch format {
|
||||
case .standard:
|
||||
Swift.print("Reminders access: \(status.displayName)")
|
||||
case .plain:
|
||||
Swift.print(status.rawValue)
|
||||
case .json:
|
||||
printJSON(AuthorizationSummary(status: status.rawValue, authorized: status.isAuthorized))
|
||||
case .quiet:
|
||||
Swift.print(status.isAuthorized ? "1" : "0")
|
||||
}
|
||||
}
|
||||
|
||||
private static func printRemindersStandard(_ reminders: [ReminderItem]) {
|
||||
let sorted = ReminderFiltering.sort(reminders)
|
||||
guard !sorted.isEmpty else {
|
||||
|
||||
27
Sources/remindctl/PermissionsHelp.swift
Normal file
27
Sources/remindctl/PermissionsHelp.swift
Normal file
@ -0,0 +1,27 @@
|
||||
import RemindCore
|
||||
|
||||
enum PermissionsHelp {
|
||||
static let settingsPath = "System Settings > Privacy & Security > Reminders"
|
||||
|
||||
static func guidanceLines(for status: RemindersAuthorizationStatus) -> [String] {
|
||||
switch status {
|
||||
case .fullAccess:
|
||||
return []
|
||||
case .notDetermined:
|
||||
return [
|
||||
"Run `remindctl authorize` to trigger the system prompt.",
|
||||
"If needed, open \(settingsPath) and allow Terminal (or remindctl).",
|
||||
]
|
||||
case .denied, .restricted:
|
||||
return [
|
||||
"Grant access in \(settingsPath) for Terminal (or remindctl).",
|
||||
"If running over SSH, grant access on the Mac that runs the command.",
|
||||
]
|
||||
case .writeOnly:
|
||||
return [
|
||||
"Switch to Full Access in \(settingsPath).",
|
||||
"If running over SSH, grant access on the Mac that runs the command.",
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
23
Tests/RemindCoreTests/AuthorizationStatusTests.swift
Normal file
23
Tests/RemindCoreTests/AuthorizationStatusTests.swift
Normal file
@ -0,0 +1,23 @@
|
||||
import EventKit
|
||||
import Testing
|
||||
|
||||
@testable import RemindCore
|
||||
|
||||
@MainActor
|
||||
struct AuthorizationStatusTests {
|
||||
@Test("Authorization status mapping")
|
||||
func mapping() {
|
||||
#expect(RemindersAuthorizationStatus(eventKitStatus: .fullAccess) == .fullAccess)
|
||||
#expect(RemindersAuthorizationStatus(eventKitStatus: .writeOnly) == .writeOnly)
|
||||
#expect(RemindersAuthorizationStatus(eventKitStatus: .denied) == .denied)
|
||||
#expect(RemindersAuthorizationStatus(eventKitStatus: .restricted) == .restricted)
|
||||
#expect(RemindersAuthorizationStatus(eventKitStatus: .notDetermined) == .notDetermined)
|
||||
}
|
||||
|
||||
@Test("Authorization display names")
|
||||
func displayNames() {
|
||||
#expect(RemindersAuthorizationStatus.fullAccess.displayName == "Full access")
|
||||
#expect(RemindersAuthorizationStatus.writeOnly.displayName == "Write-only")
|
||||
#expect(RemindersAuthorizationStatus.denied.displayName == "Denied")
|
||||
}
|
||||
}
|
||||
@ -10,11 +10,15 @@ struct HelpPrinterTests {
|
||||
ShowCommand.spec,
|
||||
ListCommand.spec,
|
||||
AddCommand.spec,
|
||||
StatusCommand.spec,
|
||||
AuthorizeCommand.spec,
|
||||
]
|
||||
let lines = HelpPrinter.renderRoot(version: "0.0.0", rootName: "remindctl", commands: specs)
|
||||
let joined = lines.joined(separator: "\n")
|
||||
#expect(joined.contains("show"))
|
||||
#expect(joined.contains("list"))
|
||||
#expect(joined.contains("add"))
|
||||
#expect(joined.contains("status"))
|
||||
#expect(joined.contains("authorize"))
|
||||
}
|
||||
}
|
||||
|
||||
@ -4,12 +4,14 @@
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"version:sync": "scripts/generate-version.sh",
|
||||
"remindctl": "pnpm -s version:sync && swift run remindctl",
|
||||
"remindctl": "pnpm -s version:sync && swift package clean && swift run remindctl",
|
||||
"start": "pnpm -s remindctl",
|
||||
"format": "swift format --in-place --recursive Sources Tests",
|
||||
"lint": "swift format lint --recursive Sources Tests && swiftlint",
|
||||
"test": "pnpm -s version:sync && swift test --enable-code-coverage",
|
||||
"coverage": "scripts/check-coverage.sh",
|
||||
"check": "pnpm -s lint && pnpm -s test && pnpm -s coverage",
|
||||
"clean": "swift package clean",
|
||||
"build": "pnpm -s version:sync && mkdir -p bin && swift build -c release --product remindctl && cp .build/release/remindctl bin/remindctl && codesign --force --sign - --identifier com.steipete.remindctl bin/remindctl"
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user