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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## Unreleased

- Add `attic status --json`: emits library, backup-progress, retry-queue,
and S3 manifest stats as a single stable JSON object on stdout, for
dashboards and monitoring scripts. (#7)
- Add optional `storageClass` config setting. When set (e.g. `"GLACIER_IR"`),
original asset uploads send `x-amz-storage-class` on single PUTs and
multipart initiations. Manifest and metadata objects keep the provider
Expand Down
2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ let package = Package(
),
.testTarget(
name: "AtticCoreTests",
dependencies: ["AtticCore"],
dependencies: ["AtticCore", "AtticCLI"],
path: "Tests/AtticCoreTests"
),
]
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,14 @@ summary (count, max attempts, oldest first-failed timestamp).

```bash
attic status
attic status --json # machine-readable: single JSON object on stdout
```

`--json` emits the same data as a stable JSON object (library counts, backup
progress, pending lanes, retry queue, manifest entries, last-backup
timestamp) for dashboards and monitoring scripts. Optional sections are
omitted — not null — when unavailable.

### backup

Export pending assets and upload originals + metadata JSON to S3.
Expand Down
94 changes: 20 additions & 74 deletions Sources/AtticCLI/Dependencies.swift
Original file line number Diff line number Diff line change
Expand Up @@ -199,82 +199,28 @@ enum Dependencies {
}

/// Guard that runs at the top of every command needing a v2 manifest.
/// Detects v1 and pending thumbnail cleanup; prompts the user (TTY),
/// runs migration with progress, or fails fast on non-TTY with a clear
/// next-step message. Fast-paths to a no-op when both phases are
/// already applied.
static func ensureManifestMigrated() async throws {
let isTTY = isatty(STDOUT_FILENO) != 0 && isatty(STDIN_FILENO) != 0
let (_, _, manifestStore) = try makeBackupDeps()
let manifest = try await loadManifest(store: manifestStore)

let needsV1 = manifest.isV1
let needsCleanup = !manifest.thumbnailsCleanupApplied
guard needsV1 || needsCleanup else { return }

// v1 path keeps its existing pre-runner prompt. The runner detects
// and runs cleanup in the same invocation, so no second prompt is
// needed up-front for a v1+cleanup-pending bucket — the runner's
// confirm closure handles it.
if needsV1 {
FileHandle.standardError.write(Data(MigrationPrompt.message(count: manifest.entries.count).utf8))
let decision = MigrationPrompt.decide(
isTTY: isTTY,
answer: { isTTY ? readLine() : nil },
)
switch decision {
case .nonInteractive:
FileHandle.standardError.write(Data(MigrationPrompt.nonInteractiveHint.utf8))
throw CLIError.migrationRequired
case .abort:
throw CLIError.migrationRequired
case .proceed:
break
}
}

let runner = try await makeMigrationRunner(progress: { msg in print(" \(msg)") })
let confirmCleanup: MigrationRunner.CleanupConfirmHandler = { count, bytes in
guard isTTY else { return .nonInteractive }
FileHandle.standardError.write(Data(
MigrationPrompt.cleanupMessage(count: count, totalBytes: bytes).utf8,
))
switch MigrationPrompt.decideCleanup(isTTY: isTTY, answer: { readLine() }) {
case .proceed: return .proceed
case .abort: return .abort
case .nonInteractive: return .nonInteractive
}
}

let report: MigrationReport
do {
report = try await runner.run(confirmCleanup: confirmCleanup)
} catch let MigrationRunnerError.thumbnailCleanupNonInteractive(count, bytes) {
FileHandle.standardError.write(Data(
("\nattic detected \(count) orphaned thumbnails (\(bytes) bytes) under " +
"`thumbnails/` and needs to delete them. Re-run from an interactive shell " +
"to confirm.\n").utf8,
))
throw CLIError.migrationRequired
} catch MigrationRunnerError.thumbnailCleanupDeclined {
throw CLIError.migrationRequired
} catch let MigrationRunnerError.thumbnailCleanupPartial(deleted, failed) {
FileHandle.standardError.write(Data(
("\nThumbnail cleanup partially failed: \(deleted) deleted, " +
"\(failed.count) failed. Re-run to retry.\n").utf8,
))
throw CLIError.migrationRequired
/// See ``MigrationGuard`` for the control flow. With `statusToStderr`
/// (JSON mode), progress and completion output is routed to stderr so
/// stdout stays reserved for the machine-readable document.
static func ensureManifestMigrated(statusToStderr: Bool = false) async throws {
let emitStatus: @Sendable (String) -> Void = if statusToStderr {
{ FileHandle.standardError.write(Data(($0 + "\n").utf8)) }
} else {
{ print($0) }
}

print("")
if needsV1 {
print("Migration complete: \(report.cloudMigrated) cloud, \(report.localFallback) local fallback.")
}
if report.thumbnailsDeleted > 0 {
let mb = Double(report.thumbnailsBytes) / (1024 * 1024)
print(" Thumbnails cleanup: \(report.thumbnailsDeleted) deleted (\(String(format: "%.1f", mb)) MB).")
}
print("")
let migrationGuard = MigrationGuard(
loadManifest: {
let (_, _, manifestStore) = try makeBackupDeps()
return try await loadManifest(store: manifestStore)
},
makeRunner: { progress in try await makeMigrationRunner(progress: progress) },
isTTY: isatty(STDOUT_FILENO) != 0 && isatty(STDIN_FILENO) != 0,
readAnswer: { readLine() },
emitPrompt: { FileHandle.standardError.write(Data($0.utf8)) },
emitStatus: emitStatus,
)
try await migrationGuard.ensureMigrated()
}
}

Expand Down
100 changes: 100 additions & 0 deletions Sources/AtticCLI/MigrationGuard.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import AtticCore
import Foundation

/// Guard that runs at the top of every command needing a v2 manifest.
/// Detects v1 and pending thumbnail cleanup; prompts the user (TTY), runs
/// migration with progress, or fails fast on non-TTY with a clear
/// next-step message. Fast-paths to a no-op when both phases are already
/// applied.
///
/// Collaborators are injected so the control flow and output routing are
/// testable without real S3, config, or PhotoKit —
/// `Dependencies.ensureManifestMigrated` wires the live set. Prompts and
/// error hints go to `emitPrompt` (stderr in production); progress lines
/// and completion summaries go to `emitStatus`, which JSON-mode commands
/// route to stderr so stdout stays reserved for machine-readable output.
struct MigrationGuard: Sendable {
/// Load the current manifest from S3.
var loadManifest: @Sendable () async throws -> Manifest
/// Build a fully-wired migration runner reporting through the handler.
var makeRunner: @Sendable (@escaping MigrationRunner.ProgressHandler) async throws -> MigrationRunner
var isTTY: Bool
var readAnswer: @Sendable () -> String?
/// Sink for prompts and error hints — stderr in production.
var emitPrompt: @Sendable (String) -> Void
/// Sink for progress lines and completion summaries — stdout for human
/// invocations, stderr when stdout carries JSON (`status --json`).
/// A trailing newline is appended by the sink.
var emitStatus: @Sendable (String) -> Void

func ensureMigrated() async throws {
let manifest = try await loadManifest()

let needsV1 = manifest.isV1
let needsCleanup = !manifest.thumbnailsCleanupApplied
guard needsV1 || needsCleanup else { return }

// v1 path keeps its existing pre-runner prompt. The runner detects
// and runs cleanup in the same invocation, so no second prompt is
// needed up-front for a v1+cleanup-pending bucket — the runner's
// confirm closure handles it.
if needsV1 {
emitPrompt(MigrationPrompt.message(count: manifest.entries.count))
let decision = MigrationPrompt.decide(
isTTY: isTTY,
answer: { isTTY ? readAnswer() : nil },
)
switch decision {
case .nonInteractive:
emitPrompt(MigrationPrompt.nonInteractiveHint)
throw CLIError.migrationRequired
case .abort:
throw CLIError.migrationRequired
case .proceed:
break
}
}

let emitStatus = emitStatus
let runner = try await makeRunner { msg in emitStatus(" \(msg)") }
let confirmCleanup: MigrationRunner.CleanupConfirmHandler = { [isTTY, emitPrompt, readAnswer] count, bytes in
guard isTTY else { return .nonInteractive }
emitPrompt(MigrationPrompt.cleanupMessage(count: count, totalBytes: bytes))
switch MigrationPrompt.decideCleanup(isTTY: isTTY, answer: { readAnswer() }) {
case .proceed: return .proceed
case .abort: return .abort
case .nonInteractive: return .nonInteractive
}
}

let report: MigrationReport
do {
report = try await runner.run(confirmCleanup: confirmCleanup)
} catch let MigrationRunnerError.thumbnailCleanupNonInteractive(count, bytes) {
emitPrompt(
"\nattic detected \(count) orphaned thumbnails (\(bytes) bytes) under " +
"`thumbnails/` and needs to delete them. Re-run from an interactive shell " +
"to confirm.\n",
)
throw CLIError.migrationRequired
} catch MigrationRunnerError.thumbnailCleanupDeclined {
throw CLIError.migrationRequired
} catch let MigrationRunnerError.thumbnailCleanupPartial(deleted, failed) {
emitPrompt(
"\nThumbnail cleanup partially failed: \(deleted) deleted, " +
"\(failed.count) failed. Re-run to retry.\n",
)
throw CLIError.migrationRequired
}

emitStatus("")
if needsV1 {
emitStatus("Migration complete: \(report.cloudMigrated) cloud, \(report.localFallback) local fallback.")
}
if report.thumbnailsDeleted > 0 {
let mb = Double(report.thumbnailsBytes) / (1024 * 1024)
emitStatus(" Thumbnails cleanup: \(report.thumbnailsDeleted) deleted (\(String(format: "%.1f", mb)) MB).")
}
emitStatus("")
}
}
56 changes: 45 additions & 11 deletions Sources/AtticCLI/StatusCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,42 +9,76 @@ struct StatusCommand: AsyncParsableCommand {
abstract: "Show library stats, backup progress, and S3 manifest info.",
)

@Flag(name: .long, help: "Emit status as a single JSON object on stdout.")
var json: Bool = false

func run() async throws {
try await Dependencies.ensureManifestMigrated()
let data = try await Self.buildDashboard(
ensureMigrated: { try await Dependencies.ensureManifestMigrated(statusToStderr: json) },
loadAssets: { try await Dependencies.loadAssetsAsync() },
loadLocalAvailability: { Dependencies.loadLocalAvailability() },
loadRetryQueue: { FileRetryQueueStore().load() },
loadBackupContext: {
let (config, _, manifestStore) = try Dependencies.makeBackupDeps()
let manifest = try await Dependencies.loadManifest(store: manifestStore)
return (bucket: config.bucket, manifest: manifest)
},
)

let isTTY = isatty(STDOUT_FILENO) != 0
let assets = try await Dependencies.loadAssetsAsync()
if json {
let out = try formatDashboardJSON(data)
FileHandle.standardOutput.write(out)
FileHandle.standardOutput.write(Data("\n".utf8))
} else {
StatusRenderer(isTTY: isatty(STDOUT_FILENO) != 0).render(data)
}
}

/// Assemble ``DashboardData`` from injected loaders. An unconfigured
/// install (`CLIError.notInitialized` from the migration guard or the
/// backup context) degrades to library-only data instead of failing,
/// so `status` works before `attic init`.
static func buildDashboard(
ensureMigrated: () async throws -> Void,
loadAssets: () async throws -> [AssetInfo],
loadLocalAvailability: () -> (any LocalAvailabilityProviding)?,
loadRetryQueue: () -> RetryQueue?,
loadBackupContext: () async throws -> (bucket: String, manifest: Manifest),
) async throws -> DashboardData {
do {
try await ensureMigrated()
} catch CLIError.notInitialized {
// No config — the backup/S3 section below degrades the same way.
}

let assets = try await loadAssets()
let library = StatusStats.computeLibraryStats(assets)
let types = StatusStats.computeUTIBreakdown(assets)
let localAvailability = Dependencies.loadLocalAvailability()
let retry = StatusStats.computeRetryInfo(FileRetryQueueStore().load())
let localAvailability = loadLocalAvailability()
let retry = StatusStats.computeRetryInfo(loadRetryQueue())

var backup: BackupStats?
var s3: S3Info?

do {
let (config, _, manifestStore) = try Dependencies.makeBackupDeps()
let manifest = try await Dependencies.loadManifest(store: manifestStore)
let (bucket, manifest) = try await loadBackupContext()
backup = StatusStats.computeBackupStats(
assets: assets,
manifest: manifest,
localAvailability: localAvailability,
)
s3 = StatusStats.computeS3Info(bucket: config.bucket, manifest: manifest)
s3 = StatusStats.computeS3Info(bucket: bucket, manifest: manifest)
} catch CLIError.notInitialized {
// No config — show library only with init hint
}

let data = DashboardData(
return DashboardData(
version: AtticCore.version,
library: library,
backup: backup,
s3: s3,
types: types,
retry: retry,
)

StatusRenderer(isTTY: isTTY).render(data)
}
}
Loading
Loading