diff --git a/.agents/skills/release/SKILL.md b/.agents/skills/release/SKILL.md new file mode 100644 index 000000000..22ad80d1d --- /dev/null +++ b/.agents/skills/release/SKILL.md @@ -0,0 +1,171 @@ +--- +name: release +description: Run the MistKit release runbook — verify the vX.Y.Z release branch is green, assemble the ReleaseNotes.md section and README roadmap entry, merge to main, tag as X.Y.Z, publish the GitHub pre-release, and roll the Examples' MISTKIT_BRANCH pins. Use when asked to cut, prepare, or ship a release. +argument-hint: "Which release? e.g. v1.0.0-beta.5" +disable-model-invocation: true +--- + +# MistKit Release Runbook + +## The naming rule + +```text +release branch v1.0.0-beta.5 ← with v +release tag 1.0.0-beta.5 ← without v +``` + +This asymmetry is deliberate. `setup-mistkit` resolves `MISTKIT_BRANCH` with +`git ls-remote`, which matches **tags as well as branches**, so pinning the wrong +kind of ref succeeds silently and greens example CI without ever compiling the +code under release. The pin requirement therefore *inverts* at release time: + +| Phase | `MISTKIT_BRANCH` must be | +|---|---| +| Before the release merge | the **branch** `v1.0.0-beta.5` | +| After publishing | the **tag** `1.0.0-beta.5` | + +`Scripts/release.sh pins --expect-branch` / `--expect-tag` asserts the ref *kind*, +which is the check `setup-mistkit` itself cannot make. + +## Stop conditions + +Abort and ask the user if: + +- `preflight` fails for any reason other than notes-not-yet-written. +- The milestone still has open issues (it warns; confirm the release is intended). +- The named branch is not the branch you are on. +- `main` has commits the release branch lacks. +- Any `git subrepo push` would be needed but the Example subrepos have local changes. + +## Phases + +Run everything from the release branch's own worktree +(`git trees add v1.0.0-beta.5 main` if it does not exist — never raw `git worktree`). + +### 1. Preflight + +```bash +./Scripts/release.sh preflight v1.0.0-beta.5 +``` + +Checks branch shape, clean tree, tag availability, gating CI (`MistKit`, +`MistDemo Integration`, `Examples` — *not* `Claude Code Review`, which is advisory), +pins, open milestone issues, then local `swift build`/`swift test`/`Scripts/lint.sh`. +Use `--skip-local` only when re-running after a green local pass. + +### 2. Fix the pre-release pins + +Must happen **before** the merge, so example CI actually tests this branch. + +```bash +./Scripts/release.sh pins --roll-to v1.0.0-beta.5 +git add Examples/*/.github/workflows/*.yml +git commit -m "ci(examples): pin MISTKIT_BRANCH to v1.0.0-beta.5" +git subrepo push Examples/BushelCloud +git subrepo push Examples/CelestraCloud +git push +``` + +When `Packages/MistKitConfiguration` lands (#407), add its subrepo push here too. + +Then confirm from a build log that `Setup MistKit` printed +`Pinning MistKit to v1.0.0-beta.5 @ ` with a sha matching the branch tip, +and re-run `./Scripts/release.sh pins --expect-branch v1.0.0-beta.5`. + +### 3. Assemble the notes + +Release notes are a **flat bullet list** — no `###` category subsections. + +```bash +./Scripts/release.sh notes-draft v1.0.0-beta.5 +``` + +This writes the `## 1.0.0-beta.5` section to the top of `ReleaseNotes.md` and +prints README roadmap candidates. Then, by hand: + +- Edit bullet wording and add issue refs: `* (#41, #42) by @user in `. +- Add a `### v1.0.0-beta.5` section to the README Roadmap, above `### Backlog / Post-beta`. +- Bump the README `from:` snippet to the **currently released** tag (the new one does not exist yet). + +```bash +./Scripts/release.sh check v1.0.0-beta.5 # must pass before proceeding +swift build && swift test && ./Scripts/lint.sh +git commit -am "docs: 1.0.0-beta.5 release notes and roadmap" && git push +``` + +### 4. Archive before merging + +Load-bearing under squash: once squashed commits are no longer reachable from +`main`, the archive tag is the only preservation mechanism. Do this even if you +plan a merge commit. + +```bash +git tag "backup/v1.0.0-beta.5-pre-merge" v1.0.0-beta.5 +git push origin "backup/v1.0.0-beta.5-pre-merge" +``` + +### 5. Release PR — ask before merging + +```bash +gh pr create --base main --head v1.0.0-beta.5 --title "v1.0.0 beta.5" \ + --body-file <(./Scripts/release.sh publish 1.0.0-beta.5 --dry-run 2>/dev/null) +``` + +**Ask the user which merge shape to use.** Release branches are the documented +merge-commit case (`gh pr merge --merge`) — unlike feature PRs, which are always +rebase (1 commit) or squash (2+). Squash keeps `main` linear but makes the +archive tag from phase 4 the *only* preservation mechanism. + +### 6. Tag — only after notes have landed + +Locate the existing `main` worktree (`git trees list`) — do not `git checkout main` +from the release worktree. From that `main` worktree: + +```bash +git pull --ff-only +./Scripts/release.sh verify-tag 1.0.0-beta.5 --at HEAD # must pass first +git tag 1.0.0-beta.5 # lightweight, no -a, no v +git push origin 1.0.0-beta.5 +``` + +`verify-tag --at HEAD` reads `ReleaseNotes.md` in the tree about to be tagged. +This is the guardrail for beta.3 and beta.4, both of which were tagged without +their own notes section. The `Release Check` workflow re-asserts it after the push. + +### 7. Publish + +```bash +./Scripts/release.sh publish 1.0.0-beta.5 +``` + +Builds the body from `ReleaseNotes.md`, swapping `## 1.0.0-beta.5` for +`## What's Changed`. Add the intro blurb by hand afterwards if wanted. + +### 8. Roll the pins to the tag + +```bash +./Scripts/release.sh pins --roll-to 1.0.0-beta.5 +./Scripts/release.sh pins --expect-tag 1.0.0-beta.5 +git commit -am "ci(examples): pin MISTKIT_BRANCH to released 1.0.0-beta.5" +git subrepo push Examples/BushelCloud +git subrepo push Examples/CelestraCloud +git push +``` + +When `Packages/MistKitConfiguration` lands (#407), add its subrepo push here too. + +### 9. Open the next beta + +Keep the released `v1.0.0-beta.5` branch and its worktree — do not delete either. +Create the next beta branch from `main`: + +```bash +git trees add v1.0.0-beta.6 main +``` + +## This runbook will not + +- Push the Example subrepos for you — it prints the `git subrepo push` commands. +- Delete a release branch after publication. +- Tag before `check` / `verify-tag` passes. +- Choose the merge shape without asking. diff --git a/.agents/skills/release/agents/openai.yaml b/.agents/skills/release/agents/openai.yaml new file mode 100644 index 000000000..bbd4fd5dd --- /dev/null +++ b/.agents/skills/release/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Release" + short_description: "Run the MistKit release runbook" +policy: + allow_implicit_invocation: false diff --git a/.claude/agent-notes.md b/.claude/agent-notes.md index 9096740dc..43e09a16e 100644 --- a/.claude/agent-notes.md +++ b/.claude/agent-notes.md @@ -19,3 +19,17 @@ Standing always/never directives and corrections from the human. Agents must rea - Repo-wide CI version bumps (Xcode/simulator/toolchain) DO extend into the `Examples/` subrepos — update BushelCloud and CelestraCloud in the same pass rather than deferring them to their own repos. - Follow sibling brightdigit repos (e.g. ConfigKeyKit) for current CI workflow shape before inventing a new one. - Feature-branch PRs: NEVER merge-commit. ALWAYS rebase if the PR has exactly 1 commit (`gh pr merge --rebase`); ALWAYS squash if it has 2+ commits (`gh pr merge --squash`). Count commits before merging. +- Release PRs (a `v*` release branch into `main`) are the one merge-commit case — the "never merge-commit" rule above covers FEATURE PRs only; still confirm the shape with the human before merging. +- NEVER tag a release before its `ReleaseNotes.md` section exists in the tree being tagged — run `./Scripts/release.sh verify-tag --at HEAD` first (beta.3 and beta.4 both shipped tags with no notes). +- Release notes are a FLAT bullet list for **new** entries — do not add `###` category subsections; preserve the existing beta.1–beta.4 sections. +- NEVER delete a released beta branch — after publication, create the next beta branch from `main` with `git trees add`. +- ALWAYS manage worktrees with `git trees` (`add`/`rm`/`list`/`clean`), never raw `git worktree`. +- MistKitConfiguration#1 is merged and `1.0.0-beta.1` is tagged (ConfigKeyKit `1.0.0-beta.3`). The published `main` manifest must stay tag-only — `dependency-policy.yml` enforces this on non-draft PRs to `main`. Branch pins live on the `mistkit-beta.5` integration branch, which must NEVER be merged to `main` or tagged. +- MistDemo's own `resolveBool` may now be replaced by ConfigKeyKit `read(_:)`: the boolean fix (ConfigKeyKit#8) shipped in `1.0.0-beta.3` (2026-08-31). Verify behaviour before swapping; this supersedes the earlier "keep resolveBool until tagged" directive. +- `git subrepo push Packages/MistKitConfiguration` refuses ("new changes upstream") and would clobber the standalone `url:` MistKit line with the monorepo `path:` overlay; push subrepo-only changes by applying them to a clone of the standalone repo instead, then record the pushed SHA in `.gitrepo`'s `commit =`. +- NEVER switch `Examples/*/Package.swift` to a tagged `from:` just because a monorepo package was released — the Examples dogfood UNRELEASED MistKit and must keep `path:` for every in-monorepo package, with CI rewriting them to branch-HEAD `revision:` pins via `setup-mistkitconfiguration` (takes both `mistkit-branch` and `mistkitconfiguration-branch`). A published tag is for downstream consumers, not for the Examples. +- `Packages/MistKitConfiguration` is scaffolding for the `v1.0.0-beta.5` release line ONLY. Feature PRs merge into `v1.0.0-beta.5` with the subrepo intact; the release PR `v1.0.0-beta.5` → `main` MUST delete it (and its `examples.yml` lane) so no MistKit release ships a package tracking that same unreleased release. +- MistKitConfiguration test gaps belong in `Packages/MistKitConfiguration/` (subrepo) or the standalone `brightdigit/MistKitConfiguration` repo — NEVER add MKC unit tests to the monorepo root `Tests/` or chase MKC patch coverage in MistKit's codecov upload; `codecov.yml` already ignores that path. +- Lint findings for agents: use `LINT_REPORT=1 ./Scripts/lint.sh` (human summary on stderr + JSON between `### MISTKIT_LINT_REPORT_* ###` markers on stdout) or `LINT_REPORT=json ./Scripts/lint.sh` (JSON only on stdout). Report mode is read-only and walks swift-format, SwiftLint, `swift build`, and Periphery; all four lint tools run with `--strict` so any warning or error fails the pipeline; success requires exit 0 and `summary.totalFindings == 0`. See `.claude/skills/fix-lint/SKILL.md`. +- Prefer force unwraps (`!`) over verbose `guard let … else { preconditionFailure }` for compile-time-known-safe values (constant URL literals, test fixtures); suppress lint with `swift-format-ignore: NeverForceUnwrap` plus a `swiftlint:disable force_unwrapping` / `swiftlint:enable force_unwrapping` block when a doc comment sits between the disable and the unwrap (`:next` only when the unwrap is the immediate next line). +- NEVER use `Task.sleep(for:)` / `Duration` clocks in MistKitTests — package deployment target is iOS 14 (and peers); use `Task.sleep(nanoseconds:)` instead (CI iOS simulator build failed on CourierTests for this). diff --git a/.claude/docs/README.md b/.claude/docs/README.md index 764196189..0a1bd37f7 100644 --- a/.claude/docs/README.md +++ b/.claude/docs/README.md @@ -1,290 +1,71 @@ -# CloudKit Documentation Reference +# MistKit Reference Documentation -This directory contains Apple's official documentation for CloudKit Web Services, CloudKit JS, and Swift Testing, downloaded for offline reference during MistKit development. +Offline copies of external documentation, kept here so agents can consult them +without network access. Each file carries its source URL and download date in a +header comment. -## File Overview +This file is the **only** router for this directory: if you add a doc, add a row +below, and if a doc is not listed here, it is unreachable in practice. -### webservices.md (289 KB) -**Source**: https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/ +## Dependency documentation -**Primary Use**: REST API implementation reference for MistKit's core functionality +Docs for packages MistKit actually depends on (see `Package.swift`). -**Key Topics**: -- **Authentication**: API tokens, server-to-server keys, web auth tokens -- **Request Composition**: URL structure, headers, request/response formats -- **Endpoints**: - - Records: `/records/query`, `/records/modify`, `/records/lookup`, `/records/changes` - - Zones: `/zones/list`, `/zones/lookup`, `/zones/modify`, `/zones/changes` - - Subscriptions: `/subscriptions/*` - - Users: `/users/current`, `/users/discover`, `/users/lookup/contacts` - - Assets: `/assets/upload` - - Tokens: `/tokens/create`, `/tokens/register` -- **Data Types**: Record dictionaries, field types, references, assets, locations -- **Error Handling**: Error response formats and codes +| Doc | Size | Consult when | +|-----|------|--------------| +| [swift-openapi-generator.md](swift-openapi-generator.md) | 235 KB | Configuring `openapi-generator-config.yaml`, type overrides, naming strategies, filtering the spec, troubleshooting generated code | +| [swift-openapi-runtime.md](swift-openapi-runtime.md) | 125 KB | Working with `Client`/`Types` in `Sources/MistKitOpenAPI/`, middleware, transports, `@_spi(Generated)` APIs, content types and streaming | +| [swift-configuration.md](swift-configuration.md) | 304 KB | MistDemo configuration — providers, precedence, key resolution. See also `mistdemo/swift-configuration-reference.md` for the MistKit-specific guide | +| [swift-log.md](swift-log.md) | 97 KB | Logger setup, metadata, log levels, handler behavior. See also AGENTS.md § Logging for MistKit's conventions | -**When to Consult**: -- Implementing any CloudKit REST API endpoint -- Understanding request/response formats -- Implementing authentication mechanisms -- Working with CloudKit-specific data types -- Debugging API responses and errors +## CloudKit API references ---- +| Doc | Size | Consult when | +|-----|------|--------------| +| [webservices.md](webservices.md) | 282 KB | **Authoritative REST reference.** Implementing any endpoint, authentication, request/response formats, data types, error codes | +| [cloudkitjs.md](cloudkitjs.md) | 183 KB | Understanding CloudKit concepts and operation flows; designing Swift types that mirror CloudKit structures | +| [QUICK_REFERENCE.md](QUICK_REFERENCE.md) | 9 KB | Fast lookup — endpoint shapes, field types, query filters, error codes, type mapping, known endpoint discrepancies | -### cloudkitjs.md (188 KB) -**Source**: https://developer.apple.com/documentation/cloudkitjs +Archived Apple docs are not always complete. Verify endpoint details against +Apple's live/archived reference when something looks wrong — see +`.claude/memory/reference_cloudkit_archived_endpoints.md`. -**Primary Use**: Understanding CloudKit concepts and data structures (adapted to Swift) +## Schema -**Key Topics**: -- **Configuration**: Container setup, authentication flows -- **Core Classes**: - - `CloudKit.Container`: Container access, authentication - - `CloudKit.Database`: Database operations (public/private/shared) - - `CloudKit.Record*`: Record operations and responses - - `CloudKit.RecordZone*`: Zone management - - `CloudKit.Subscription*`: Subscription handling - - `CloudKit.Notification*`: Push notification types -- **Operations**: - - Query operations with filters and sorting - - Batch operations for records - - Zone management - - Subscription management - - User discovery - - Sharing records -- **Response Objects**: All response types and their properties -- **Error Handling**: `CKError` structure and error types +| Doc | Size | Consult when | +|-----|------|--------------| +| [cloudkit-schema-reference.md](cloudkit-schema-reference.md) | 9 KB | Reading/modifying `.ckdb` files — grammar, field options, permissions, MistKit-specific notes | +| [sosumi-cloudkit-schema-source.md](sosumi-cloudkit-schema-source.md) | 8 KB | Authoritative schema-language grammar, identifier rules, system fields | +| [schema-design-workflow.md](schema-design-workflow.md) | 15 KB | End-to-end schema design and validation workflow | -**When to Consult**: -- Designing Swift types that mirror CloudKit structures -- Understanding CloudKit operation flows -- Implementing query builders -- Working with subscriptions and notifications -- Understanding CloudKit error handling patterns -- Comparing JS API patterns to REST API +## Tooling ---- +| Doc | Size | Consult when | +|-----|------|--------------| +| [cktool.md](cktool.md) / [cktool-full.md](cktool-full.md) | 6 / 4 KB | Native CLI for schema export/import, token management, seeding test data | +| [cktooljs.md](cktooljs.md) / [cktooljs-full.md](cktooljs-full.md) | 6 / 10 KB | JS library for programmatic schema deployment and CI automation | -### testing-enablinganddisabling.md (126 KB) -**Source**: https://developer.apple.com/documentation/testing/enablinganddisabling +## Testing -**Primary Use**: Writing modern Swift tests for MistKit +| Doc | Size | Consult when | +|-----|------|--------------| +| [testing-enablinganddisabling.md](testing-enablinganddisabling.md) | 123 KB | Swift Testing — `@Test`/`@Suite`, traits, parameterization, async, XCTest migration | +| [test-organization-guide.md](test-organization-guide.md) | 41 KB | How this repo organizes test files and parent types | -**Key Topics**: -- **Test Definition**: `@Test` macro for test functions -- **Test Organization**: `@Suite` for grouping tests -- **Conditional Testing**: `.enabled(if:)`, `.disabled()` traits -- **Parameterization**: Testing with collections of inputs -- **Async Testing**: `async`/`await` test support -- **Expectations**: `#expect()`, `#require()` macros -- **Migration**: Converting from XCTest to Swift Testing -- **Tags**: Categorizing tests with tags -- **Known Issues**: `.bug()` trait for tracking known failures -- **Parallelization**: Serial vs parallel execution +## MistDemo -**When to Consult**: -- Writing new test functions -- Setting up test suites -- Implementing parameterized tests -- Testing async/await code -- Migrating from XCTest -- Organizing test execution +`mistdemo/` holds the MistDemo design docs — start at +[mistdemo/README.md](mistdemo/README.md). `mistdemo/phases/` tracks +implementation phases. ---- +## Research -### swift-openapi-generator.md (235 KB) -**Source**: https://swiftpackageindex.com/apple/swift-openapi-generator/1.10.3/documentation/swift-openapi-generator +`research/` holds dated investigations into specific failures, kept for the +reasoning rather than as current reference. Each is a point-in-time record. -**Primary Use**: Code generation configuration and troubleshooting +## Related -**Key Topics**: -- **Generator Configuration**: YAML config options, naming strategies (defensive vs idiomatic), access modifiers -- **Type Overrides**: Replacing generated types with custom implementations (e.g., Foundation.UUID) -- **Document Filtering**: Generating subsets by operations, paths, or tags -- **Middleware System**: ClientMiddleware for auth, logging, retry logic -- **Transport Protocols**: ClientTransport abstraction, URLSession integration -- **Content Types**: JSON, multipart, URL-encoded, plain text, binary, streaming -- **Event Streams**: JSON Lines, JSON Sequence, Server-sent Events helpers -- **API Stability**: Understanding breaking vs non-breaking changes -- **Naming Strategies**: Defensive (safe) vs idiomatic (Swift-style) identifier mapping -- **Code Generation Modes**: Build plugin vs manual CLI invocation +Example-specific domain docs live with their examples, not here: -**When to Consult**: -- Configuring `openapi-generator-config.yaml` settings -- Setting up custom type overrides for CloudKit types -- Implementing authentication or logging middleware -- Understanding generated code structure and evolution -- Troubleshooting "Decl has a package access level" errors -- Filtering large OpenAPI specs for specific operations -- Working with streaming responses or multipart uploads - ---- - -### cktool.md / cktool-full.md -**Source**: https://developer.apple.com/icloud/ck-tool/ - -**Primary Use**: CloudKit command-line tool for schema and data management - -**Files**: -- `cktool.md` - Curated summary with command examples -- `cktool-full.md` - Complete Apple documentation - -**Key Topics**: -- **Authentication**: Management tokens, user tokens, Keychain storage -- **Schema Management**: - - Reset development schema to production - - Export schema to `.ckdb` files - - Import schema from files to development -- **Data Commands**: - - Query records with filters - - Create records with JSON field definitions -- **Automation**: CI/CD integration, test data seeding -- **Token Management**: Saving and managing CloudKit tokens - -**When to Consult**: -- Setting up development environments with CloudKit schemas -- Exporting/importing schemas for version control -- Automating schema deployment in CI/CD pipelines -- Seeding test data for MistKit integration tests -- Resetting development databases -- Understanding CloudKit Management API workflows - ---- - -### cktooljs.md / cktooljs-full.md -**Source**: https://developer.apple.com/documentation/cktooljs/ -**Version**: CKTool JS 1.2.15+ - -**Primary Use**: JavaScript library for CloudKit management operations - -**Files**: -- `cktooljs.md` - Curated summary with key information -- `cktooljs-full.md` - Complete Apple documentation (41 pages) - -**Key Topics**: -- **Core Modules**: - - `@apple/cktool.database` - CloudKit types and operations - - `@apple/cktool.target.nodejs` - Node.js configuration - - `@apple/cktool.target.browser` - Browser configuration -- **Capabilities**: - - Deploy schemas to Sandbox databases - - Seed databases with test data - - Restore Sandbox to production settings - - Create automated integration test scripts -- **API Components**: - - PromisesApi and CancellablePromise - - Configuration for server communication - - Container and ContainersResponse structures - - Error handling framework - -**When to Consult**: -- Building JavaScript-based CloudKit automation tools -- Creating CI/CD pipelines with Node.js -- Understanding CloudKit Management API from JS perspective -- Automating schema deployment programmatically -- Building developer tooling for CloudKit workflows -- Comparing management API patterns across platforms - ---- - -## Quick Reference: When to Use Each Doc - -### Implementing Core API Functionality -→ **webservices.md**: Authoritative source for all REST endpoints - -### Designing Type Systems -→ **cloudkitjs.md**: Model CloudKit's data structures in Swift - -### Configuring Code Generation -→ **swift-openapi-generator.md**: OpenAPI generator setup and troubleshooting - -### Writing Tests -→ **testing-enablinganddisabling.md**: Modern Swift Testing patterns - -### Schema Management & Automation -→ **cktool.md**: Native command-line tool for schema and data operations -→ **cktooljs.md**: JavaScript library for programmatic management - ---- - -## Integration with MistKit Development - -### Architecture Decisions -When designing MistKit's API surface: -1. Start with **webservices.md** for REST API capabilities -2. Reference **cloudkitjs.md** for ergonomic API design patterns -3. Adapt CloudKit JS patterns to Swift idioms (async/await, Result builders, etc.) - -### Implementation Workflow -1. **Plan**: Review endpoint in `webservices.md` -2. **Design**: Check `cloudkitjs.md` for conceptual patterns -3. **Implement**: Write Swift code with async/await -4. **Test**: Use patterns from `testing-enablinganddisabling.md` - -### Data Type Mapping -- CloudKit types → Swift types -- Reference **webservices.md** for wire format -- Reference **cloudkitjs.md** for semantic meaning - ---- - -## Common Patterns - -### Authentication Flow -1. **webservices.md** → Request signing, token formats -2. **cloudkitjs.md** → Authentication state management -3. **cktool.md** → Management token setup and storage - -### Record Operations -1. **webservices.md** → `/records/modify` endpoint structure -2. **cloudkitjs.md** → `Database.saveRecords()` operation flow -3. **cktool.md** → Creating test records via CLI - -### Query Operations -1. **webservices.md** → `/records/query` request format -2. **cloudkitjs.md** → Query filters, sort descriptors, pagination -3. **cktool.md** → Query filters via command-line - -### Error Handling -1. **webservices.md** → HTTP status codes, error response format -2. **cloudkitjs.md** → `CKError` codes and retry logic -3. **cktooljs.md** → Error handling in management operations - -### Development Workflows -1. **cktool.md** → Export/import schemas for version control -2. **cktooljs.md** → Programmatic schema deployment and automation -3. **webservices.md** → Understanding underlying API operations - ---- - -## Documentation Ecosystem Map - -``` -CloudKit Development & Operations -├── Runtime APIs (Application Level) -│ ├── webservices.md ────────── REST API reference -│ ├── cloudkitjs.md ─────────── JS SDK for web apps -│ └── MistKit ───────────────── Swift implementation -│ -├── Management APIs (Development Level) -│ ├── cktool.md ─────────────── Native CLI tool (Xcode) -│ └── cktooljs.md ───────────── JS library for automation -│ -├── Code Generation -│ └── swift-openapi-generator.md ─ Generate Swift from OpenAPI -│ -└── Testing - └── testing-enablinganddisabling.md ─ Swift Testing framework -``` - -## Notes - -- These docs are from Apple's official documentation and community sources -- Most content downloaded via llm.codes and filtered for relevance -- Last updated: November 4, 2025 -- Corresponds to: - - CloudKit Web Services (archived) - - CloudKit JS 1.0+ - - Swift Testing (Swift 6.0+) - - cktool (Xcode 13+) - - CKTool JS (latest) +- `Examples/BushelCloud/.claude/` — firmware/MobileAsset wikis, data-source research +- `Examples/CelestraCloud/.claude/` — public-database architecture, schema setup diff --git a/.claude/docs/SUMMARY.md b/.claude/docs/SUMMARY.md deleted file mode 100644 index aa1e0abc4..000000000 --- a/.claude/docs/SUMMARY.md +++ /dev/null @@ -1,425 +0,0 @@ -# Documentation Summary - -## Overview - -This directory contains three comprehensive Apple documentation files for MistKit development: - -1. **webservices.md** (289 KB) - CloudKit Web Services REST API Reference -2. **cloudkitjs.md** (188 KB) - CloudKit JS Framework Documentation -3. **testing-enablinganddisabling.md** (126 KB) - Swift Testing Framework Guide - ---- - -## webservices.md - CloudKit Web Services REST API - -**Source**: Apple's CloudKit Web Services Reference (Archived Documentation) - -### What It Covers - -#### Authentication & Security -- **API Token Authentication**: For web/client apps requiring user authentication - - Creating API tokens in CloudKit Dashboard - - Generating web authentication tokens - - Token lifecycle and refresh patterns - - Request signing with HMAC-SHA256 - -- **Server-to-Server Keys**: For backend/admin operations - - Certificate generation and key management - - Request authentication flow - - Signature computation - -#### Request Composition -- Base URL structure: `https://api.apple-cloudkit.com/database/1/{container}/{env}/{db}/{operation}` -- HTTP methods and headers -- Request body formats -- Response structures -- Error handling - -#### Core Endpoints - -**Records API** -- `POST /records/query` - Query records with filters and sorting -- `POST /records/modify` - Create, update, replace, delete records (batch) -- `POST /records/lookup` - Fetch specific records by ID -- `POST /records/changes` - Fetch incremental changes - -**Zones API** -- `POST /zones/list` - List all zones -- `POST /zones/lookup` - Fetch specific zones -- `POST /zones/modify` - Create, update, delete zones -- `POST /zones/changes` - Fetch zone changes - -**Subscriptions API** -- `POST /subscriptions/list` - List all subscriptions -- `POST /subscriptions/lookup` - Fetch specific subscriptions -- `POST /subscriptions/modify` - Create, update, delete subscriptions - -**Users API** -- `GET /users/current` - Get current authenticated user -- `POST /users/discover` - Discover users by email/phone -- `POST /users/lookup/contacts` - Lookup users from contacts -- `POST /users/lookup/email` - Lookup by email address -- `POST /users/lookup/phone` - Lookup by phone number - -**Assets API** -- `POST /assets/upload` - Upload binary assets -- Asset download URLs in record responses - -**Tokens API** -- `POST /tokens/create` - Create web auth token -- `POST /tokens/register` - Register for push notifications - -#### Data Types & Field Types - -**Basic Types** -- `STRING`, `INT64`, `DOUBLE`, `BYTES`, `DATE` - -**Complex Types** -- `LOCATION` - Latitude/longitude coordinates -- `REFERENCE` - References to other records (with delete actions) -- `ASSET` - File attachments with metadata - -**List Types** -- `STRING_LIST`, `INT64_LIST`, `DOUBLE_LIST`, `DATE_LIST` -- `LOCATION_LIST`, `REFERENCE_LIST` - -#### Error Handling -- Error response format -- Common error codes: - - `AUTHENTICATION_REQUIRED` - - `INVALID_ARGUMENTS` - - `NOT_FOUND` - - `CONFLICT` (for record changes) - - `ATOMIC_ERROR` (for batch operations) - - `ZONE_NOT_FOUND` - - `THROTTLED` - - `INTERNAL_ERROR` - -### Key Implementation Patterns - -1. **Batch Operations**: All modify operations support atomic batches -2. **Change Tracking**: Server sync tokens for incremental updates -3. **Conflict Resolution**: Record change tags (ETags) for optimistic locking -4. **Pagination**: Continuation markers for large result sets - ---- - -## cloudkitjs.md - CloudKit JS Framework - -**Source**: Apple's CloudKit JS Documentation (Current) - -### What It Covers - -#### Configuration & Setup -- Embedding CloudKit JS in web pages -- Container configuration with API tokens -- Environment selection (development/production) -- Authentication flows - -#### Core Classes - -**CloudKit Namespace** -- Configuration methods -- Global constants and enumerations -- Container access - -**CloudKit.Container** -- `publicCloudDatabase` - Access public database -- `privateCloudDatabase` - Access private database (requires auth) -- `sharedCloudDatabase` - Access shared database -- User authentication methods -- User discovery operations -- Share acceptance - -**CloudKit.Database** -- **Record Operations**: - - `saveRecords()` - Save/update records - - `fetchRecords()` - Fetch by record IDs - - `deleteRecords()` - Delete records - - `performQuery()` - Query with filters - - `newRecordsBatch()` - Batch builder - -- **Zone Operations**: - - `fetchAllRecordZones()` - List all zones - - `fetchRecordZones()` - Fetch specific zones - - `saveRecordZones()` - Create/update zones - - `deleteRecordZones()` - Delete zones - -- **Subscription Operations**: - - `fetchAllSubscriptions()` - List subscriptions - - `fetchSubscriptions()` - Fetch specific subscriptions - - `saveSubscriptions()` - Create/update subscriptions - - `deleteSubscriptions()` - Delete subscriptions - -- **Change Tracking**: - - `fetchDatabaseChanges()` - Database-level changes - - `fetchRecordZoneChanges()` - Zone-level changes - -#### Query System - -**Filter Comparators** -- Equality: `EQUALS`, `NOT_EQUALS` -- Comparison: `LESS_THAN`, `GREATER_THAN`, etc. -- String: `BEGINS_WITH`, `CONTAINS_ALL_TOKENS` -- List: `IN`, `LIST_CONTAINS` - -**Sort Descriptors** -- Field name -- Ascending/descending order - -**Pagination** -- `resultsLimit` for page size -- `continuationMarker` for next page - -#### Response Objects - -**CloudKit.Response** (base class) -- `isSuccess` - Operation status -- `hasErrors` - Error indication - -**CloudKit.RecordsResponse** -- `records` - Array of fetched records -- `errors` - Array of errors (by record) - -**CloudKit.QueryResponse** -- `records` - Query results -- `moreComing` - Has more pages -- `continuationMarker` - Token for next page - -**CloudKit.RecordsBatchBuilder** -- Fluent API for batch operations -- `create()`, `update()`, `replace()`, `delete()` -- `commit()` to execute - -**CloudKit.DatabaseChangesResponse** -- Changed zones since sync token -- New sync token - -**CloudKit.RecordZoneChangesResponse** -- Changed records in zones -- Deleted record IDs -- New sync tokens per zone - -#### Notification Types - -**CloudKit.Notification** (base) -- Notification ID and type -- Subscription ID - -**CloudKit.QueryNotification** -- Record change details -- Query subscription results - -**CloudKit.RecordZoneNotification** -- Zone change notifications - -#### Error Handling - -**CloudKit.CKError** -- `ckErrorCode` - CloudKit error code -- `isServerError` - Server vs client error -- `serverErrorCode` - Detailed server error -- Retry suggestions - -### Key Concepts - -1. **Databases**: Public (unauthenticated), Private (user), Shared (shared with user) -2. **Zones**: Logical groupings in private/shared databases (default zone always exists) -3. **Subscriptions**: Push notifications for record/zone changes -4. **Change Tracking**: Sync tokens for efficient synchronization -5. **Sharing**: Records can be shared between users - ---- - -## testing-enablinganddisabling.md - Swift Testing Framework - -**Source**: Apple's Swift Testing Documentation (Swift 6.0+) - -### What It Covers - -#### Test Definition - -**@Test Macro** -```swift -@Test("Display name") -func testFunction() async throws { } -``` -- No naming conventions required -- Can be defined anywhere (not just in classes) -- Supports async/await and throwing -- Can have custom display names - -**@Suite Macro** -```swift -@Suite("Feature Tests") -struct FeatureTests { - @Test func testA() { } - @Test func testB() { } -} -``` -- Groups related tests -- Uses Swift's type system -- Can nest suites -- Shares setup/teardown - -#### Test Traits - -**Conditional Execution** -- `.enabled(if: condition)` - Run only if true -- `.disabled()` - Never run -- `.disabled(if: condition)` - Skip if true -- `.disabled("Reason")` - Skip with explanation - -**Time Limits** -- `.timeLimit(.seconds(10))` - Fail if exceeds duration -- `.timeLimit(.minutes(1))` - -**Tags** -- `.tags(.critical)` - Categorize tests -- `.tags(.slow, .integration)` - Multiple tags -- Run specific tags from command line - -**Bug Tracking** -- `.bug("URL")` - Link to bug report -- `.bug(id: "12345")` - Bug number -- Associates tests with known issues - -**Parallelization** -- `.serialized` - Force serial execution -- Default is parallel in-process - -#### Test Parameterization - -**Single Collection** -```swift -@Test(arguments: [1, 2, 3, 4, 5]) -func validate(value: Int) { } -``` - -**Multiple Collections** -```swift -@Test(arguments: ["a", "b"], [1, 2]) -func combine(letter: String, number: Int) { } -``` - -**Zipped Collections** -```swift -@Test(arguments: zip(["a", "b"], [1, 2])) -func paired(letter: String, number: Int) { } -``` - -**Custom Arguments** -```swift -struct TestCase: CustomTestStringConvertible { - let input: String - let expected: Int -} - -@Test(arguments: [ - TestCase(input: "hello", expected: 5), - TestCase(input: "world", expected: 5) -]) -func validate(testCase: TestCase) { } -``` - -#### Expectations - -**#expect() - Continue on failure** -```swift -#expect(value == expected) -#expect(array.count > 0) -#expect(string.contains("test")) -``` - -**#require() - Stop on failure** -```swift -let value = try #require(optionalValue) // Stops if nil -#expect(value.isValid) -``` - -**Error Checking** -```swift -#expect(throws: MyError.invalid) { - try someOperation() -} -``` - -**Async Expectations** -```swift -await #expect { - try await asyncOperation() - return true -} -``` - -#### Migration from XCTest - -| XCTest | Swift Testing | -|--------|---------------| -| `class XCTestCase` | `@Suite struct` or `@Test func` | -| `func testFoo()` | `@Test func foo()` | -| `setUp()` | `init()` | -| `tearDown()` | `deinit` | -| `setUpWithError()` | `init() throws` | -| `tearDownWithError()` | N/A (use defer) | -| `XCTAssertEqual` | `#expect(a == b)` | -| `XCTAssertTrue` | `#expect(value)` | -| `XCTAssertNil` | `#expect(value == nil)` | -| `XCTUnwrap` | `try #require(value)` | -| `XCTAssertThrowsError` | `#expect(throws:)` | -| `addTeardownBlock` | `defer { }` | -| `continueAfterFailure` | `#expect()` (always continues) | - -#### Running Tests - -**Swift Package Manager** -```bash -swift test # Run all -swift test --filter TestName # Run specific -swift test --parallel # Parallel execution -``` - -**Xcode** -- Test navigator shows all `@Test` functions -- Run button next to each test -- Test report shows parameterized cases - ---- - -## How to Use These Docs Together - -### Implementing a Feature - -1. **Design Phase** - - Read `cloudkitjs.md` to understand operation semantics - - Check `webservices.md` for exact REST endpoint details - - Plan Swift API surface - -2. **Implementation Phase** - - Use `webservices.md` for request/response formats - - Map CloudKit types to Swift types - - Implement async/await patterns - - Add error handling - -3. **Testing Phase** - - Use `testing-enablinganddisabling.md` for test patterns - - Write parameterized tests for edge cases - - Test async operations - - Handle error paths - -### Example: Implementing Record Query - -1. **cloudkitjs.md** → Understand `Database.performQuery()` operation -2. **webservices.md** → Get exact POST `/records/query` format -3. Implement Swift async function -4. **testing-enablinganddisabling.md** → Write parameterized tests - ---- - -## Quick Navigation - -- **Need endpoint details?** → `webservices.md` -- **Need CloudKit concepts?** → `cloudkitjs.md` -- **Need test patterns?** → `testing-enablinganddisabling.md` -- **Need quick reference?** → `QUICK_REFERENCE.md` -- **Need integration guide?** → `README.md` diff --git a/.claude/docs/cloudkit-schema-plan.md b/.claude/docs/cloudkit-schema-plan.md deleted file mode 100644 index 067a98b20..000000000 --- a/.claude/docs/cloudkit-schema-plan.md +++ /dev/null @@ -1,561 +0,0 @@ -# CloudKit Schema Plan for Bushel & Demo App - -## Overview - -Three CloudKit record types for tracking macOS restore images, Xcode releases, and Swift versions with their compatibility relationships. Optimized for Bushel's virtualization use case and demonstrating MistKit's capabilities. - -## Record Types - -### 1. RestoreImage - -**Purpose**: macOS IPSW files for Apple Virtualization framework (VirtualMac restore images) - -**Fields**: -- `version` (String, indexed) - macOS version: "14.2.1", "15.0 Beta 3" -- `buildNumber` (String, indexed) - Build identifier: "23C71", "24A5264n" -- `releaseDate` (Date, indexed) - Official release date -- `downloadURL` (String) - Direct IPSW download link -- `fileSize` (Int64) - File size in bytes -- `sha256Hash` (String) - SHA-256 checksum for integrity verification -- `sha1Hash` (String) - SHA-1 hash (from MESU/ipsw.me for compatibility) -- `isSigned` (Boolean, indexed) - Whether Apple still signs this restore image -- `isPrerelease` (Boolean, indexed) - Beta/RC release indicator -- `source` (String) - Data source: "ipsw.me", "mrmacintosh.com", "mesu.apple.com" -- `notes` (String) - Additional metadata or release notes - -**Indexes**: -- `version` - For version lookups -- `buildNumber` - Unique identifier queries -- `releaseDate` - Chronological sorting -- `isSigned` - Filter to signed-only images -- `isPrerelease` - Filter beta vs final releases -- Compound: `(isSigned, releaseDate)` - "Latest signed releases" - -### 2. XcodeVersion - -**Purpose**: Xcode releases with macOS requirements and bundled Swift versions - -**Fields**: -- `version` (String, indexed) - Xcode version: "15.1", "15.2 Beta 3" -- `buildNumber` (String) - Build identifier: "15C65" -- `releaseDate` (Date, indexed) - Release date -- `downloadURL` (String) - Optional developer.apple.com download link -- `fileSize` (Int64) - Download size in bytes -- `isPrerelease` (Boolean, indexed) - Beta/RC indicator -- `minimumMacOS` (Reference) - Link to minimum RestoreImage record required -- `includedSwiftVersion` (Reference) - Link to bundled Swift compiler -- `sdkVersions` (String) - JSON of SDKs: `{"macOS": "14.2", "iOS": "17.2", "watchOS": "10.2"}` -- `notes` (String) - Release notes or additional info - -**Indexes**: -- `version` - Version lookups -- `releaseDate` - Chronological sorting -- `isPrerelease` - Filter production vs beta - -### 3. SwiftVersion - -**Purpose**: Swift compiler releases bundled with Xcode - -**Fields**: -- `version` (String, indexed) - Swift version: "5.9", "5.10", "6.0" -- `releaseDate` (Date, indexed) - Release date -- `downloadURL` (String) - Optional swift.org toolchain download -- `isPrerelease` (Boolean) - Beta/snapshot indicator -- `notes` (String) - Release notes - -**Indexes**: -- `version` - Version lookups -- `releaseDate` - Chronological sorting - -## Relationship Model - -**Simplified unidirectional references:** - -``` -RestoreImage "14.2.1" - - No outbound references - -XcodeVersion "15.1" - ├─ minimumMacOS → RestoreImage "13.5" - └─ includedSwiftVersion → SwiftVersion "5.9.2" - -SwiftVersion "5.9.2" - - No outbound references -``` - -**Example Query (Bushel use case):** -```swift -// 1. Get RestoreImage by version -let restoreImage = queryRestoreImage(version: "14.2.1") - -// 2. Find compatible Xcode versions -let xcodeVersions = queryXcode(where: minimumMacOS.version <= "14.2.1") - -// 3. Display restore image with compatible dev tools -``` - -## Data Sources - -### RestoreImage Records - -#### Primary Source - ipsw.me API via IPSWDownloads Swift Package - -- **Device**: `VirtualMac2,1` (Apple Virtual Machine restore images) -- **Coverage**: 46 final releases from macOS 12.4 (May 2022) onwards -- **Provides**: version, buildid, sha256sum, sha1sum, md5sum, filesize, url, releasedate, signed status -- **Format**: Clean JSON API -- **Package**: https://github.com/brightdigit/IPSWDownloads - -**Example API Response:** -```json -{ - "identifier": "VirtualMac2,1", - "version": "26.1", - "buildid": "25B78", - "sha1sum": "479d6bb78f069062ca016d496fd50804b673e815", - "md5sum": "e270ede6a1eba02253ac42bcd76dab4b", - "sha256sum": "e0217b3cd0f2edb9ab3294480bef5af2a0be43e86d84a15fab6bca31d3802ee8", - "filesize": 18718884780, - "url": "https://updates.cdn-apple.com/2025FallFCS/fullrestores/089-04148/791B6F00-A30B-4EB0-B2E3-257167F7715B/UniversalMac_26.1_25B78_Restore.ipsw", - "releasedate": "2025-11-03T21:34:29Z", - "uploaddate": "2025-10-30T05:50:08Z", - "signed": true -} -``` - -#### Secondary Source - Mr. Macintosh Database - -- **URL**: https://mrmacintosh.com/apple-silicon-m1-full-macos-restore-ipsw-firmware-files-database/ -- **Coverage**: Beta/RC releases including Big Sur 11.x through current -- **Provides**: version, build, releasedate, download url, signing status, beta/RC classification -- **Total additional entries**: ~100+ beta/RC versions -- **Format**: HTML scraping required - -**Data Available:** -- Version number (e.g., "26.1 Beta 4") -- Build identifier (e.g., "25B5072a") -- Release date (formatted as MM/DD or MM/DD/YY) -- Download URL (Apple CDN links) -- Signing status ("YES" or "N/A") -- Release classification (Final, RC, Beta with numbering) - -#### Freshness Detection - Apple MESU XML - -- **URL**: https://mesu.apple.com/assets/macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml -- **Coverage**: Single entry - currently signed latest release only -- **Provides**: BuildVersion, ProductVersion, FirmwareURL, FirmwareSHA1 -- **Purpose**: Detect new releases immediately, trigger sync if version not in database -- **Update Frequency**: Real-time by Apple - -**Example XML Structure:** -```xml - - - 25B78 - 26.1 - https://updates.cdn-apple.com/.../UniversalMac_26.1_25B78_Restore.ipsw - 479d6bb78f069062ca016d496fd50804b673e815 - - -``` - -**Total Coverage**: ~140+ restore images from Big Sur 11.0 (2020) to current - -### XcodeVersion Records - -#### Primary Source - xcodereleases.com - -- **URL**: https://xcodereleases.com -- **Coverage**: Community-maintained comprehensive database -- **Provides**: version, build, release date, minimum macOS, bundled Swift version, SDK versions -- **Includes**: Both release and beta versions -- **Format**: Likely requires scraping or API if available - -#### Secondary Source - Apple Developer Release Notes - -- **Purpose**: Official documentation for validation -- **Use**: Manual curation for critical metadata - -### SwiftVersion Records - -#### Primary Source - swiftversion.net - -- **URL**: https://swiftversion.net -- **Coverage**: Community-maintained Swift version database -- **Provides**: version, release date, Xcode bundling information -- **Includes**: Comprehensive historical coverage -- **Format**: Likely requires scraping or API if available - -#### Secondary Source - swift.org Official Releases - -- **URL**: https://swift.org -- **Purpose**: Primary validation source -- **Use**: GitHub releases for version tracking - -## Database Configuration - -- **Schema Level**: Container (schema applies to both public and private databases) -- **Database**: Public Database (readable by all, writable with authentication) -- **Write Target**: Demo app writes to public database via `database: .public` parameter -- **Zone**: Default Zone (sufficient for this use case) -- **Write Access**: API token authentication for sync tool (MistKit) -- **Read Access**: Public (Bushel queries directly using native CloudKit framework) -- **Permissions**: `GRANT READ TO "_world"` makes records publicly readable -- **Container**: User-configurable (e.g., `iCloud.com.yourcompany.Bushel`) - -## Data Import Strategy - -### Sync Command Workflow - -#### 1. Fetch from ipsw.me (via IPSWDownloads package) - -```swift -// Query VirtualMac2,1 device for all firmwares -let device = try await ipswDownloads.device("VirtualMac2,1") -let firmwares = device.firmwares - -// Map to RestoreImage records with complete metadata -let restoreImages = firmwares.map { firmware in - RestoreImageRecord( - version: firmware.version, - buildNumber: firmware.buildid, - releaseDate: firmware.releasedate, - downloadURL: firmware.url, - fileSize: firmware.filesize, - sha256Hash: firmware.sha256sum, - sha1Hash: firmware.sha1sum, - isSigned: firmware.signed, - isPrerelease: false, // ipsw.me only has finals - source: "ipsw.me" - ) -} -``` - -#### 2. Fetch from Mr. Macintosh - -```swift -// Scrape database for beta/RC versions -let mrMacHTML = try await fetchHTML("https://mrmacintosh.com/...") -let betaReleases = parseMrMacTable(mrMacHTML) - -// Filter duplicates (match on version + build) -let uniqueBetas = betaReleases.filter { beta in - !restoreImages.contains(where: { $0.buildNumber == beta.buildNumber }) -} - -// Add beta-specific RestoreImage records -restoreImages.append(contentsOf: uniqueBetas.map { beta in - RestoreImageRecord( - version: beta.version, - buildNumber: beta.build, - releaseDate: beta.releaseDate, - downloadURL: beta.url, - isSigned: beta.signed, - isPrerelease: true, // Beta/RC - source: "mrmacintosh.com" - ) -}) -``` - -#### 3. Check MESU XML - -```swift -// Parse for latest signed release -let mesuXML = try await fetchMESU() -let latestRelease = parseMESUXML(mesuXML) - -// If version not in database, add from MESU -if !restoreImages.contains(where: { $0.buildNumber == latestRelease.buildNumber }) { - restoreImages.append(RestoreImageRecord( - version: latestRelease.productVersion, - buildNumber: latestRelease.buildVersion, - downloadURL: latestRelease.firmwareURL, - sha1Hash: latestRelease.firmwareSHA1, - isSigned: true, - isPrerelease: false, - source: "mesu.apple.com" - )) -} - -// Update signing status for existing records -// (MESU only lists currently signed version, so others are unsigned) -``` - -#### 4. Fetch Xcode data (xcodereleases.com) - -```swift -// Parse versions and requirements -let xcodeReleases = try await fetchXcodeReleases() - -// Create XcodeVersion records with References to RestoreImage -let xcodeRecords = xcodeReleases.map { release in - XcodeVersionRecord( - version: release.version, - buildNumber: release.build, - releaseDate: release.date, - isPrerelease: release.isBeta, - minimumMacOS: referenceToRestoreImage(version: release.minMacOS), - includedSwiftVersion: referenceToSwiftVersion(version: release.swiftVersion), - sdkVersions: release.sdks.toJSON() - ) -} -``` - -#### 5. Fetch Swift data (swiftversion.net) - -```swift -// Parse versions and metadata -let swiftReleases = try await fetchSwiftVersions() - -// Create SwiftVersion records -let swiftRecords = swiftReleases.map { release in - SwiftVersionRecord( - version: release.version, - releaseDate: release.date, - isPrerelease: release.isBeta - ) -} - -// Link from XcodeVersion records (already done in step 4) -``` - -#### 6. Upsert to CloudKit - -```swift -// Use record IDs based on version+build for idempotency -for image in restoreImages { - let recordID = CKRecord.ID(recordName: "RestoreImage-\(image.buildNumber)") - - // Update existing records if data changed - // Create new records for new versions - try await cloudKit.save(image, withID: recordID) -} - -// Same for XcodeVersion and SwiftVersion records -``` - -### Export Command - -Simple JSON dump for inspection/debugging: - -```json -{ - "restoreImages": [ - { - "version": "14.2.1", - "buildNumber": "23C71", - "releaseDate": "2024-01-22T00:00:00Z", - "downloadURL": "https://updates.cdn-apple.com/.../UniversalMac_14.2.1_23C71_Restore.ipsw", - "fileSize": 17892345678, - "sha256Hash": "abc123...", - "isSigned": true, - "isPrerelease": false, - "source": "ipsw.me" - } - ], - "xcodeVersions": [...], - "swiftVersions": [...] -} -``` - -## Demo CLI Application - -### Two Commands - -#### 1. `sync` - Import/update all data from sources to CloudKit - -```bash -# Full sync - fetch all data from all sources -./demo sync - -# Incremental sync - only check for new versions -./demo sync --incremental - -# Dry run - preview changes without writing to CloudKit -./demo sync --dry-run - -# Sync specific record types only -./demo sync --restore-images-only -./demo sync --xcode-only -./demo sync --swift-only -``` - -**Implementation:** -- Uses MistKit for all CloudKit operations -- Implements async/await throughout -- Handles rate limiting and batch operations -- Provides progress output -- Logs all changes made - -#### 2. `export` - Export CloudKit data to JSON - -```bash -# Export all records to stdout -./demo export - -# Write to file -./demo export --output data.json - -# Export specific record types -./demo export --restore-images-only -./demo export --xcode-only - -# Pretty-print JSON -./demo export --pretty - -# Filter exports -./demo export --signed-only -./demo export --no-betas -``` - -**Implementation:** -- Queries CloudKit for all records -- Serializes to JSON -- Supports filtering and formatting options - -## Bushel Integration Pattern - -Bushel will use **native CloudKit framework** (not MistKit) to query the public database: - -### Example Queries - -#### 1. Get all signed restore images, sorted by date - -```swift -let query = CKQuery( - recordType: "RestoreImage", - predicate: NSPredicate(format: "isSigned == true") -) -query.sortDescriptors = [NSSortDescriptor(key: "releaseDate", ascending: false)] - -let results = try await publicDatabase.records(matching: query) -``` - -#### 2. Filter to final releases only (no betas) - -```swift -let query = CKQuery( - recordType: "RestoreImage", - predicate: NSPredicate(format: "isSigned == true AND isPrerelease == false") -) -``` - -#### 3. Find compatible Xcode versions for a restore image - -```swift -// For a given restore image version, find Xcode versions that can run on it -let query = CKQuery( - recordType: "XcodeVersion", - predicate: NSPredicate(format: "minimumMacOS.version <= %@", "14.2.1") -) -``` - -#### 4. Get Swift version for an Xcode release - -```swift -// Fetch Xcode record -let xcodeRecord = try await publicDatabase.record(for: xcodeRecordID) - -// Fetch referenced Swift version -let swiftReference = xcodeRecord["includedSwiftVersion"] as! CKRecord.Reference -let swiftRecord = try await publicDatabase.record(for: swiftReference.recordID) -``` - -### Display Patterns - -Bushel can display: -- Restore image metadata: version, build, size, signing status, release date -- Compatible Xcode versions for each restore image -- Swift versions bundled with Xcode -- Filtering options: final vs beta, signed vs unsigned -- Search by version number or build - -## Implementation Plan - -### Phase 1: Schema Documentation ✓ - -- [x] Create `cloudkit-schema-plan.md` with complete schema definition -- [x] Document all fields, indexes, relationships -- [x] Include query patterns and examples - -### Phase 2: Swift Model Types - -- [ ] Define Codable structs matching CloudKit schema - - `RestoreImageRecord` - - `XcodeVersionRecord` - - `SwiftVersionRecord` -- [ ] Create CloudKit field mapping helpers -- [ ] Implement Reference type handling for relationships - -### Phase 3: Data Fetchers - -- [ ] Integrate IPSWDownloads package for ipsw.me -- [ ] Implement Mr. Macintosh HTML scraper -- [ ] Implement MESU XML parser -- [ ] Implement xcodereleases.com parser (research API/scraping approach) -- [ ] Implement swiftversion.net parser (research API/scraping approach) - -### Phase 4: Demo CLI with MistKit - -- [ ] Setup Swift Package with MistKit dependency -- [ ] Setup CloudKit container and configure authentication -- [ ] Implement `sync` command with data pipeline -- [ ] Implement `export` command for inspection -- [ ] Add Swift ArgumentParser for CLI interface -- [ ] Add logging and error handling - -### Phase 5: Blog Post Integration - -- [ ] Demonstrate MistKit usage patterns in blog post -- [ ] Show CloudKit querying with async/await -- [ ] Highlight practical real-world use case -- [ ] Document lessons learned -- [ ] Include code examples from demo app - -## Reference Documentation - -### MobileAsset Framework - -Key insights from TheAppleWiki MobileAsset documentation: - -- MESU (mesu.apple.com) serves **static XML plists** containing asset metadata -- MESU is **not a MobileAsset** - it's a special firmware manifest system -- The macOS IPSW XML is one of three special plists: - - `macos/com_apple_macOSIPSW/com_apple_macOSIPSW.xml` - - `bridgeos/com_apple_bridgeOSIPSW/com_apple_bridgeOSIPSW.xml` - - `visionos/com_apple_visionOSIPSW/com_apple_visionOSIPSW.xml` -- These contain URLs for **.ipsw files for the latest version** only -- MESU is intentionally limited to current signed releases - -### Firmware Wiki - -Key insights from TheAppleWiki Firmware documentation: - -- Main firmware manifest for iOS/iPod/Apple TV/HomePod mini: https://s.mzstatic.com/version -- Separate manifests for macOS, bridgeOS, visionOS (listed above) -- MESU serves only the **latest signed version**, updated in real-time by Apple -- Historical versions and beta releases require community databases like ipsw.me - -### ipsw.me API - -- **Devices endpoint**: https://api.ipsw.me/v4/devices -- **Device firmware endpoint**: https://api.ipsw.me/v4/device/{identifier} -- **VirtualMac identifier**: `VirtualMac2,1` for Apple Virtualization framework -- Comprehensive coverage: 46 final releases from macOS 12.4 onwards -- Complete metadata: SHA-256, SHA-1, MD5, file sizes, release dates, signing status - -## Next Steps - -1. Save MobileAsset and Firmware wiki documentation for future reference -2. Update Task 5 subtasks in Task Master with refined implementation plan -3. Begin Swift model type definitions -4. Research xcodereleases.com and swiftversion.net data access methods -5. Setup CloudKit container configuration -6. Begin demo app scaffolding with MistKit integration - -## Notes - -- This schema is designed for **public database** read access by Bushel -- Demo app uses **MistKit** to populate and maintain CloudKit data -- Bushel uses **native CloudKit framework** to query the data -- Blog post will showcase both approaches as a complete ecosystem diff --git a/.claude/docs/https_-swiftpackageindex.com-brightdigit-SyndiKit-0.6.1-documentation-syndikit.md b/.claude/docs/https_-swiftpackageindex.com-brightdigit-SyndiKit-0.6.1-documentation-syndikit.md deleted file mode 100644 index 1a0a1c1da..000000000 --- a/.claude/docs/https_-swiftpackageindex.com-brightdigit-SyndiKit-0.6.1-documentation-syndikit.md +++ /dev/null @@ -1,488 +0,0 @@ - - -# https://swiftpackageindex.com/brightdigit/SyndiKit/0.6.1/documentation/syndikit - -Framework - -# SyndiKit - -Swift Package for Decoding RSS Feeds. - -## Overview - -Built on top of XMLCoder, **SyndiKit** provides models and utilities for decoding RSS feeds of various formats and extensions. - -### Features - -- Import of RSS 2.0, Atom, and JSONFeed formats - -- Extensions for various formats such as: - -- iTunes-compatabile podcasts - -- YouTube channels - -- WordPress export data -- User-friendly errors - -- Abstractions for format-agnostic parsing - -### Requirements - -**Apple Platforms** - -- Xcode 13.3 or later - -- Swift 5.5.2 or later - -- iOS 15.4 / watchOS 8.5 / tvOS 15.4 / macOS 12.3 or later deployment targets - -**Linux** - -- Ubuntu 18.04 or later - -### Installation - -Swift Package Manager is Apple’s decentralized dependency manager to integrate libraries to your Swift projects. It is now fully integrated with Xcode 11. - -To integrate **SyndiKit** into your project using SPM, specify it in your Package.swift file: - -let package = Package( -... -dependencies: [\ -.package(url: "https://github.com/brightdigit/SyndiKit", from: "0.3.0")\ -], -targets: [\ -.target(\ -name: "YourTarget",\ -dependencies: ["SyndiKit", ...]),\ -...\ -] -) - -If this is for an Xcode project simply import the Github repository at: - -### Decoding Your First Feed - -You can get started decoding your feed by creating your first `SynDecoder`. Once you’ve created you decoder you can decode using `decode(_:)`: - -let decoder = SynDecoder() -let empowerAppsData = Data(contentsOf: "empowerapps-show.xml")! -let empowerAppsRSSFeed = try decoder.decode(empowerAppsData) - -### Working with Abstractions - -Rather than working directly with the various formats, **SyndiKit** abstracts many of the common properties of the various formats. This enables developers to be agnostic regarding the specific format. - -let decoder = SynDecoder() - -// decoding a RSS 2.0 feed -let empowerAppsData = Data(contentsOf: "empowerapps-show.xml")! -let empowerAppsRSSFeed = try decoder.decode(empowerAppsData) -print(empowerAppsRSSFeed.title) // Prints "Empower Apps" - -// decoding a Atom feed from YouTube -let kiloLocoData = Data(contentsOf: "kilo.youtube.xml")! -let kiloLocoAtomFeed = try decoder.decode(kiloLocoData) -print(kiloLocoAtomFeed.title) // Prints "Kilo Loco" - -For a mapping of properties: - -| Feedable | RSS 2.0 `channel` | Atom `AtomFeed` | JSONFeed `JSONFeed` | -| --- | --- | --- | --- | -| `title` | `title` | `title` | `title` | -| `siteURL` | `link` | `siteURL` | `title` | -| `summary` | `description` | `summary` | `homePageUrl` | -| `updated` | `lastBuildDate` | `pubDate` or `published` | `nil` | -| `authors` | `author` | `authors` | `author` | -| `copyright` | `copyright` | `nil` | `nil` | -| `image` | `url` | `links`.`first` | `nil` | -| `children` | `items` | `entries` | `items` | - -### Specifying Formats - -If you wish to access properties of specific formats, you can attempt to cast the objects to see if they match: - -let empowerAppsRSSFeed = try decoder.decode(empowerAppsData) -if let rssFeed = empowerAppsRSSFeed as? RSSFeed { -print(rssFeed.channel.title) // Prints "Empower Apps" -} - -let kiloLocoAtomFeed = try decoder.decode(kiloLocoData) -if let atomFeed = kiloLocoAtomFeed as? AtomFeed { -print(atomFeed.title) // Prints "Kilo Loco" -} - -### Accessing Extensions - -In addition to supporting RSS, Atom, and JSONFeed, **SyndiKit** also supports various RSS extensions for specific media including: YouTube, iTunes, and WordPress. - -You can access these properties via their specific feed formats or via the `media` property on `Entryable`. - -let empowerAppsRSSFeed = try decoder.decode(empowerAppsData) -switch empowerAppsRSSFeed.children.last?.media { -case .podcast(let podcast): -print(podcast.title) // print "WWDC 2018 - What Does It Mean For Businesses?" -default: -print("Not a Podcast! 🤷‍♂️") -} - -let kiloLocoAtomFeed = try decoder.decode(kiloLocoData) -switch kiloLocoAtomFeed.children.last?.media { -case .video(.youtube(let youtube): -print(youtube.videoID) // print "SBJFl-3wqx8" -print(youtube.channelID) // print "UCv75sKQFFIenWHrprnrR9aA" -default: -print("Not a Youtube Video! 🤷‍♂️") -} - -| `MediaContent` | Actual Property | -| --- | --- | -| `title` | `itunesTitle` | -| `episode` | `itunesEpisode` | -| `author` | `itunesAuthor` | -| `subtitle` | `itunesSubtitle` | -| `summary` | `itunesSummary` | -| `explicit` | `itunesExplicit` | -| `duration` | `itunesDuration` | -| `image` | `itunesImage` | -| `channelID` | `youtubeChannelID` | -| `videoID` | `youtubeVideoID` | - -## Topics - -### Decoding an RSS Feed - -`class SynDecoder` - -An object that decodes instances of Feedable from JSON or XML objects. - -### Basic Feeds - -The basic types used by **SyndiKit** for traversing the feed in abstract manner without needing the specific properties from the various feed formats. - -`protocol Feedable` - -Basic abstract Feed - -`protocol Entryable` - -Basic Feed type with abstract properties. - -`struct Author` - -a person, corporation, or similar entity. - -`protocol EntryCategory` - -Abstract category type. - -`enum EntryID` - -An identifier for an entry based on the RSS guid. - -### Abstract Media Types - -Abstract media types which can be pulled for the various `Entryable` objects. - -`protocol PodcastEpisode` - -A protocol representing a podcast episode. - -`enum MediaContent` - -A struct representing an Atom category. Represents different types of media content. - -`enum Video` - -A struct representing an Atom category. An enumeration representing different types of videos. - -### XML Primitive Types - -In many cases, types are encoded in non-matching types but are intended to strong-typed for various formats. These primitives are setup to make XML decoding easier while retaining their intended strong-type. - -`struct CData` - -#CDATA XML element. - -`struct XMLStringInt` - -XML Element which contains a `String` parsable into a `Integer`. - -`struct ListString` - -A struct representing a list of values that can be encoded/decoded as a comma-separated string. Useful for handling feed formats where multiple values are stored in a single string field. - -### Syndication Updates - -Properties from the RDF Site Summary Syndication Module concerning how often it is updated a feed is updated. - -`struct SyndicationUpdate` - -Properties concerning how often it is updated a feed is updated. - -`enum SyndicationUpdatePeriod` - -Describes the period over which the channel format is updated. - -`typealias SyndicationUpdateFrequency` - -Used to describe the frequency of updates in relation to the update period. A positive integer indicates how many times in that period the channel is updated. - -### Atom Feed Format - -Specific properties related to the Atom format. - -`struct AtomFeed` - -A struct representing an Atom category. An XML-based Web content and metadata syndication format. - -`struct AtomEntry` - -A struct representing an entry in an Atom feed. - -`struct AtomCategory` - -A struct representing an Atom category. A struct representing an Atom category. - -`struct AtomMedia` - -A struct representing an Atom category. Media structure which enables content publishers and bloggers to syndicate multimedia content such as TV and video clips, movies, images and audio. - -`struct AtomMediaGroup` - -A group of media elements in an Atom feed. - -`struct Link` - -A struct representing a link with a URL and optional relationship type. Used in various feed formats to represent hyperlinks with metadata. - -### JSON Feed Format - -Specific properties related to the JSON Feed format. - -`struct JSONFeed` - -A struct representing an Atom category. A struct representing a JSON feed. - -`struct JSONItem` - -A struct representing an Atom category. A struct representing an item in JSON format. - -### OPML Feed Formate - -`struct OPML` - -A struct representing an OPML (Outline Processor Markup Language) document. OPML is an XML format for outlines that can be used to exchange subscription lists between feed readers. It consists of a version, head section with metadata, and body section with outline elements. - -`enum OutlineType` - -### RSS Feed Format - -Specific properties related to the RSS Feed format. - -`struct RSSFeed` - -A struct representing an Atom category. RSS is a Web content syndication format. - -`struct RSSChannel` - -A struct representing an Atom category. A struct representing information about the channel (metadata) and its contents. - -`struct RSSImage` - -Represents a GIF, JPEG, or PNG image. - -`struct RSSItem` - -A struct representing an RSS item/entry. RSS items contain the individual pieces of content within an RSS feed, including title, link, description, publication date, and various media attachments. - -`struct RSSItemCategory` - -A struct representing an Atom category. A struct representing a category for an RSS item. - -`struct Enclosure` - -A struct representing an enclosure for a resource. - -### Podcast Extensions - -Specific properties related to . - -`struct PodcastPerson` - -A struct representing a person associated with a podcast. - -`struct PodcastSeason` - -A struct representing a season of a podcast. - -`struct PodcastChapters` - -A struct representing chapters of a podcast. - -`struct PodcastLocation` - -A struct representing the location of a podcast. - -`struct PodcastSoundbite` - -A struct representing a soundbite from a podcast. - -`struct PodcastTranscript` - -A struct representing a podcast transcript. - -`struct PodcastFunding` - -A struct representing funding information for a podcast. - -`struct PodcastLocked` - -A struct representing a locked podcast. - -### WordPress Extensions - -Specific extension properties provided by WordPress. - -`enum WordPressElements` - -A namespace for WordPress related elements. - -`struct WordPressPost` - -A struct representing a WordPress post. - -`typealias WPTag` - -A typealias for `WordPressElements.Tag` - -`typealias WPCategory` - -A typealias for `WordPressElements.Category` - -`typealias WPPostMeta` - -A typealias for `WordPressElements.PostMeta`. - -`enum WordPressError` - -An error type representing a missing field in a WordPress post. - -### YouTube Extensions - -Specific type abstracting the id properties a YouTube RSS Feed. - -`protocol YouTubeID` - -A struct representing an Atom category. A protocol abstracting the ID properties of a YouTube RSS Feed. - -### iTunes Extensions - -Specific extension properties provided by iTunes regarding mostly podcasts and their episodes. - -`typealias iTunesImage` - -A type alias for iTunes image links. - -`struct iTunesOwner` - -A struct representing an Atom category. A struct representing the owner of an iTunes account. - -`typealias iTunesEpisode` - -A struct representing an Atom category. A type alias for an iTunes episode. - -`struct iTunesDuration` - -A struct representing the duration of an iTunes track. - -### Site Directories - -Types related to the format used by the . - -`protocol SiteDirectory` - -A protocol for site directories. - -`struct SiteCollectionDirectory` - -A directory of site collections. - -`protocol SiteDirectoryBuilder` - -A protocol for building site directories. - -`struct CategoryDescriptor` - -A struct representing an Atom category. A descriptor for a category. - -`struct CategoryLanguage` - -A struct representing an Atom category. A struct representing a category in a specific language. - -`struct Site` - -A struct representing a website. - -`struct SiteCategory` - -A struct representing an Atom category. A struct representing a site category. - -`struct SiteCollectionDirectoryBuilder` - -A builder for creating a site collection directory. - -`struct SiteLanguage` - -A struct representing an Atom category. A struct representing a site language. - -`struct SiteLanguageCategory` - -A struct representing an Atom category. A struct representing a category of site languages. - -`struct SiteLanguageContent` - -A struct representing an Atom category. A struct representing the content of a site in a specific language. - -`typealias SiteCategoryType` - -A type alias representing a site category. - -`typealias SiteCollection` - -A collection of site language content. - -`typealias SiteLanguageType` - -A type representing the language of a website. - -`typealias SiteStub` - -A type alias for `SiteLanguageCategory.Site`. - -- SyndiKit -- Overview -- Features -- Requirements -- Installation -- Decoding Your First Feed -- Working with Abstractions -- Specifying Formats -- Accessing Extensions -- License -- Topics - -| -| - ---- - diff --git a/.claude/docs/mistdemo/configuration.md b/.claude/docs/mistdemo/configuration.md index 469855f7b..ee0fb20d3 100644 --- a/.claude/docs/mistdemo/configuration.md +++ b/.claude/docs/mistdemo/configuration.md @@ -1,5 +1,9 @@ # Configuration Management +> **Source of truth:** configuration keys now live as typed values in +> `Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/` (`MistDemoKeys`). +> The five CloudKit credential keys share their names with `MistKitConfiguration`, +> BushelCloud and CelestraCloud; every other key carries `envPrefix: "CLOUDKIT"`. MistDemo supports flexible configuration through files, profiles, environment variables, and command-line arguments using Apple's Swift Configuration package. ## Configuration Sources @@ -102,12 +106,12 @@ profiles: **Note**: Swift Configuration automatically transforms keys: - Dots (`.`) become underscores (`_`) for environment variables -- Example: `container.identifier` → `CONTAINER_IDENTIFIER` environment variable -- When CommandLineArgumentsProvider is added: dots become hyphens for CLI args (`container.identifier` → `--container-identifier`) +- Example: `cloudkit.container-id` → `CLOUDKIT_CONTAINER_ID` environment variable +- CLI args: dots and dashes both become hyphens (`cloudkit.container-id` → `--cloudkit-container-id`) | Key | Type | Environment Variable (Auto-transformed) | Default | Description | |-----|------|---------------------|---------|-------------| -| `container.identifier` | String | `CONTAINER_IDENTIFIER` | `iCloud.com.brightdigit.MistDemo` | Container identifier | +| `cloudkit.container-id` | String | `CLOUDKIT_CONTAINER_ID` | `iCloud.com.brightdigit.MistDemo` | Container identifier | | `api.token` | String | `API_TOKEN` | Empty string | API token (secret) | | `environment` | String | `ENVIRONMENT` | `development` | CloudKit environment | | `database` | String | `DATABASE` | Varies by auth method | Database type | diff --git a/.claude/docs/mistdemo/overview.md b/.claude/docs/mistdemo/overview.md index 914af7f29..cf8a27244 100644 --- a/.claude/docs/mistdemo/overview.md +++ b/.claude/docs/mistdemo/overview.md @@ -1,5 +1,9 @@ # MistDemo Overview +> **Source of truth:** configuration keys now live as typed values in +> `Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/` (`MistDemoKeys`). +> The five CloudKit credential keys share their names with `MistKitConfiguration`, +> BushelCloud and CelestraCloud; every other key carries `envPrefix: "CLOUDKIT"`. ## Architecture MistDemo is a CLI tool with a subcommand architecture where each CloudKit Web Services operation maps to a dedicated subcommand. The tool demonstrates MistKit's capabilities while providing a practical interface for CloudKit operations. @@ -40,7 +44,7 @@ All subcommands accept these global options: | Option | Short | Environment Variable | Default | Description | |--------|-------|---------------------|---------|-------------| -| `--environment` | `-e` | `CLOUDKIT_ENVIRONMENT` | `development` | CloudKit environment | +| `--cloudkit-environment` | | `CLOUDKIT_ENVIRONMENT` | `development` | CloudKit environment | | `--database` | `-d` | `CLOUDKIT_DATABASE` | `public` | Database type | Valid values: @@ -53,9 +57,9 @@ Valid values: | Option | Environment Variable | Description | |--------|---------------------|-------------| -| `--key-id` | `CLOUDKIT_KEY_ID` | Server-to-server key ID (required for public database) | -| `--private-key-file` | `CLOUDKIT_PRIVATE_KEY_PATH` | Path to ECDSA private key PEM file | -| `--private-key` | `CLOUDKIT_PRIVATE_KEY` | ECDSA private key as inline string | +| `--cloudkit-key-id` | `CLOUDKIT_KEY_ID` | Server-to-server key ID (required for public database) | +| `--cloudkit-private-key-path` | `CLOUDKIT_PRIVATE_KEY_PATH` | Path to ECDSA private key PEM file | +| `--cloudkit-private-key` | `CLOUDKIT_PRIVATE_KEY` | ECDSA private key as inline string | **Private/shared database** — web authentication: @@ -113,7 +117,7 @@ For server-side applications using key-based authentication. ```bash mistdemo query \ - --key-id YOUR_KEY_ID \ + --cloudkit-key-id YOUR_KEY_ID \ --private-key-file path/to/key.pem ``` diff --git a/.claude/docs/mistdemo/phases/phase-1-core-infrastructure.md b/.claude/docs/mistdemo/phases/phase-1-core-infrastructure.md index 0534b062f..f40c091da 100644 --- a/.claude/docs/mistdemo/phases/phase-1-core-infrastructure.md +++ b/.claude/docs/mistdemo/phases/phase-1-core-infrastructure.md @@ -185,7 +185,7 @@ targets: [ **Note**: MistDemo uses Swift Configuration (v1.0.0+) for configuration management, replacing ArgumentParser during Phase 1 (issue #212). The current architecture uses manual argument parsing with hierarchical provider resolution (CLI → Environment → Defaults). See `MistDemoConfig.swift` for the implementation pattern. **Reference Documentation:** -- [Swift Configuration Guide](.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md) +- [Swift Configuration Guide](.claude/docs/swift-configuration.md) - [MistDemo Swift Configuration Reference](./swift-configuration-reference.md) ## File Structure diff --git a/.claude/docs/mistdemo/swift-configuration-reference.md b/.claude/docs/mistdemo/swift-configuration-reference.md index e6e9c5c5f..16a37c0fb 100644 --- a/.claude/docs/mistdemo/swift-configuration-reference.md +++ b/.claude/docs/mistdemo/swift-configuration-reference.md @@ -1,5 +1,9 @@ # Swift Configuration Reference for MistDemo +> **Source of truth:** configuration keys now live as typed values in +> `Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/` (`MistDemoKeys`). +> The five CloudKit credential keys share their names with `MistKitConfiguration`, +> BushelCloud and CelestraCloud; every other key carries `envPrefix: "CLOUDKIT"`. ## Overview MistDemo uses [Swift Configuration](https://github.com/apple/swift-configuration) (v1.0.0+) for hierarchical configuration management. This replaced ArgumentParser during Phase 1 (issue #212) to provide a more flexible, cross-platform configuration system. @@ -48,12 +52,12 @@ Swift Configuration uses different naming conventions for different sources: | Swift Key | CLI Flag | Environment Variable | |-----------|----------|---------------------| -| `container.identifier` | `--container-identifier` | `CONTAINER_IDENTIFIER` | -| `api.token` | `--api-token` | `API_TOKEN` | -| `web.auth.token` | `--web-auth-token` | `WEB_AUTH_TOKEN` | -| `environment` | `--environment` | `ENVIRONMENT` | -| `database` | `--database` | `DATABASE` | -| `output.format` | `--output-format` | `OUTPUT_FORMAT` | +| `cloudkit.container-id` | `--cloudkit-container-id` | `CLOUDKIT_CONTAINER_ID` | +| `api.token` | `--api-token` | `CLOUDKIT_API_TOKEN` | +| `web.auth.token` | `--web-auth-token` | `CLOUDKIT_WEB_AUTH_TOKEN` | +| `cloudkit.environment` | `--cloudkit-environment` | `CLOUDKIT_ENVIRONMENT` | +| `database` | `--database` | `CLOUDKIT_DATABASE` | +| `output.format` | `--output-format` | `CLOUDKIT_OUTPUT_FORMAT` | | `query.limit` | `--query-limit` | `QUERY_LIMIT` | | `query.zone` | `--query-zone` | `QUERY_ZONE` | @@ -276,7 +280,7 @@ let package = Package( **Problem**: Configuration key returns `nil` even though it's set. **Solution**: Check key name transformation: -- CLI: `--container-identifier` → Key: `"container.identifier"` +- CLI: `--cloudkit-container-id` → Key: `"cloudkit.container-id"` - ENV: `CONTAINER_IDENTIFIER` → Key: `"container.identifier"` ### Environment Variable Not Working @@ -312,10 +316,10 @@ export CONTAINER_IDENTIFIER="iCloud.com.example.App" **Solution**: Verify the exact flag format: ```bash # Wrong (uses =) -mistdemo --container-identifier=iCloud.com.example.App +mistdemo --cloudkit-container-id=iCloud.com.example.App # Correct (uses space) -mistdemo --container-identifier iCloud.com.example.App +mistdemo --cloudkit-container-id iCloud.com.example.App # Also correct (short form if defined) mistdemo -c iCloud.com.example.App @@ -339,7 +343,7 @@ let providers: [ConfigurationProvider] = [ - **Official Package**: [apple/swift-configuration](https://github.com/apple/swift-configuration) - **Package Index**: [Swift Configuration 1.0.0 Documentation](https://swiftpackageindex.com/apple/swift-configuration/1.0.0/documentation/configuration) -- **Local Reference**: [.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md](../.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md) +- **Local Reference**: [.claude/docs/swift-configuration.md](../.claude/docs/swift-configuration.md) - **MistDemo Implementation**: `Examples/MistDemo/Sources/MistDemo/Configuration/MistDemoConfig.swift` - **ConfigKeyKit Strategy**: [configkeykit-strategy.md](./configkeykit-strategy.md) diff --git a/.claude/docs/protocol-extraction-continuation.md b/.claude/docs/protocol-extraction-continuation.md deleted file mode 100644 index c3a8905a3..000000000 --- a/.claude/docs/protocol-extraction-continuation.md +++ /dev/null @@ -1,559 +0,0 @@ -# MistKit Protocol Extraction - Continuation Guide - -## Current State (As of this session) - -### ✅ Completed Work - -#### Phase 1: Critical Build Fixes -- ✅ Added missing `processLookupRecordsResponse` method -- ✅ Complete boolean support in FieldValue system -- ✅ All 157 tests passing -- ✅ Build successful - -#### Phase 2: API Consolidation -- ✅ Deprecated `CloudKitService+RecordModification.swift` methods -- ✅ Enhanced `CloudKitService+WriteOperations.swift` with optional `recordName` -- ✅ Clear migration path established - -#### Phase 3: Simple Cleanup -- ✅ Deleted `Examples/MistDemo` directory -- ✅ Linting verified (only pre-existing style warnings) - -### 🔄 Remaining Work (Phase 3 continuation) - -The major work item remaining is **extracting Bushel's protocol-oriented patterns into MistKit core**. This is a 6-8 hour task that will significantly improve the developer experience. - ---- - -## Quick Verification Commands - -Before starting, verify the current state: - -```bash -cd /Users/leo/Documents/Projects/MistKit - -# Should build cleanly -swift build - -# Should show 157/157 tests passing -swift test - -# Should show current branch -git branch --show-current -# Expected: blog-post-examples-code-celestra - -# Verify Bushel example exists -ls Examples/Bushel/Sources/Bushel/Protocols/ -# Should show: CloudKitRecord.swift, RecordManaging.swift, etc. - -# Verify MistDemo was deleted -ls Examples/ -# Should show only: Bushel, Celestra (no MistDemo) -``` - ---- - -## Remaining Tasks Breakdown - -### Task 1: Extract CloudKitRecord Protocol (2-3 hours) - -**Source:** `Examples/Bushel/Sources/Bushel/Protocols/CloudKitRecord.swift` - -**Destination:** `Sources/MistKit/Protocols/CloudKitRecord.swift` - -**What to extract:** -```swift -public protocol CloudKitRecord: Codable, Sendable { - static var cloudKitRecordType: String { get } - var recordName: String { get } - func toCloudKitFields() -> [String: FieldValue] - static func from(recordInfo: RecordInfo) -> Self? - static func formatForDisplay(_ recordInfo: RecordInfo) -> String -} -``` - -**Steps:** -1. Create `Sources/MistKit/Protocols/` directory -2. Copy `CloudKitRecord.swift` from Bushel to new location -3. Update imports (should only need `Foundation`) -4. Make protocol `public` (it's currently internal in Bushel) -5. Update file header with MistKit copyright - -**Testing:** -- Build should succeed -- Create a simple test conforming a test struct to `CloudKitRecord` -- Verify protocol requirements are clear - ---- - -### Task 2: Extract RecordManaging Protocol (1-2 hours) - -**Source:** `Examples/Bushel/Sources/Bushel/Protocols/RecordManaging.swift` - -**Destination:** `Sources/MistKit/Protocols/RecordManaging.swift` - -**What to extract:** -```swift -public protocol RecordManaging { - func queryRecords(recordType: String) async throws -> [RecordInfo] - func executeBatchOperations(_ operations: [RecordOperation], recordType: String) async throws -} -``` - -**Key Decision:** The protocol in Bushel throws untyped errors, but MistKit uses `throws(CloudKitError)`. - -**Recommendation:** Use untyped `throws` for protocol flexibility, implementations can be more specific. - -**Steps:** -1. Copy `RecordManaging.swift` to `Sources/MistKit/Protocols/` -2. Make protocol `public` -3. Update to use MistKit's `RecordInfo` and `RecordOperation` types -4. Update file header - ---- - -### Task 3: Add FieldValue Convenience Extensions (2 hours) - -**Source:** `Examples/Bushel/Sources/Bushel/Extensions/FieldValue+Extensions.swift` - -**Destination:** `Sources/MistKit/Extensions/FieldValue+Convenience.swift` - -**What to add:** -```swift -extension FieldValue { - public var stringValue: String? { - if case .string(let value) = self { return value } - return nil - } - - public var intValue: Int? { - if case .int64(let value) = self { return value } - return nil - } - - public var boolValue: Bool? { - if case .boolean(let value) = self { return value } - return nil - } - - public var dateValue: Date? { - if case .date(let value) = self { return value } - return nil - } - - public var referenceValue: Reference? { - if case .reference(let value) = self { return value } - return nil - } - - // Add similar for: doubleValue, bytesValue, locationValue, assetValue, listValue -} -``` - -**Note:** Check if Bushel has these - they're **essential** for the `CloudKitRecord` protocol to work ergonomically. - -**Testing:** -```swift -let fields: [String: FieldValue] = ["name": .string("Test")] -XCTAssertEqual(fields["name"]?.stringValue, "Test") -XCTAssertNil(fields["name"]?.intValue) -``` - ---- - -### Task 4: Add RecordManaging Generic Extensions (3-4 hours) - -**Source:** `Examples/Bushel/Sources/Bushel/Protocols/RecordManaging+Generic.swift` - -**Destination:** `Sources/MistKit/Extensions/RecordManaging+Generic.swift` - -**What to extract:** -```swift -public extension RecordManaging { - func sync(_ records: [T]) async throws { - // Convert records to RecordOperation array - // Call executeBatchOperations - } - - func query(_ type: T.Type) async throws -> [T] { - // Query by cloudKitRecordType - // Convert RecordInfo results using T.from() - } - - func list(_ type: T.Type) async throws -> [RecordInfo] { - // Query and return raw RecordInfo - } -} -``` - -**Critical Implementation Details:** - -1. **Batch Size Handling** (CloudKit limit: 200 operations) -```swift -func sync(_ records: [T]) async throws { - let operations = records.map { record in - RecordOperation.create( - recordType: T.cloudKitRecordType, - recordName: record.recordName, - fields: record.toCloudKitFields() - ) - } - - // Split into chunks of 200 - for chunk in operations.chunked(size: 200) { - try await executeBatchOperations(chunk, recordType: T.cloudKitRecordType) - } -} -``` - -2. **Error Handling** - See Bushel's implementation for handling partial failures - -**Testing:** -- Create test struct conforming to `CloudKitRecord` -- Test sync with < 200 records -- Test sync with > 200 records (batching) -- Test query operations -- Verify type safety - ---- - -### Task 5: Add CloudKitService Conformance (1 hour) - -**Destination:** `Sources/MistKit/Service/CloudKitService+RecordManaging.swift` - -**Implementation:** -```swift -@available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) -extension CloudKitService: RecordManaging { - public func queryRecords(recordType: String) async throws -> [RecordInfo] { - // Use existing queryRecords implementation - try await self.queryRecords( - recordType: recordType, - desiredKeys: nil, - filters: [], - sortDescriptors: [] - ) - } - - public func executeBatchOperations(_ operations: [RecordOperation], recordType: String) async throws { - _ = try await self.modifyRecords(operations) - } -} -``` - -**Testing:** -- Verify CloudKitService now conforms to RecordManaging -- Test that generic extensions work on CloudKitService instances - ---- - -### Task 6: Update Bushel to Import from MistKit (1 hour) - -**Files to update:** -- `Examples/Bushel/Sources/Bushel/Protocols/CloudKitRecord.swift` - DELETE -- `Examples/Bushel/Sources/Bushel/Protocols/RecordManaging.swift` - DELETE -- `Examples/Bushel/Sources/Bushel/Protocols/RecordManaging+Generic.swift` - DELETE -- All Bushel source files that reference these protocols - -**Changes:** -```swift -// OLD: -import protocol Bushel.CloudKitRecord - -// NEW: -import MistKit -// CloudKitRecord is now part of MistKit -``` - -**Verification:** -```bash -cd Examples/Bushel -swift build -# Should build successfully using MistKit's protocols -``` - ---- - -### Task 7: Add Tests for Protocols (2-3 hours) - -**Create:** `Tests/MistKitTests/Protocols/CloudKitRecordTests.swift` - -**Test Coverage:** -```swift -@Test("CloudKitRecord protocol conformance") -func testCloudKitRecordConformance() async throws { - struct TestRecord: CloudKitRecord { - static var cloudKitRecordType: String { "TestRecord" } - var recordName: String - var name: String - var count: Int - - func toCloudKitFields() -> [String: FieldValue] { - ["name": .string(name), "count": .int64(count)] - } - - static func from(recordInfo: RecordInfo) -> TestRecord? { - guard let name = recordInfo.fields["name"]?.stringValue, - let count = recordInfo.fields["count"]?.intValue else { - return nil - } - return TestRecord(recordName: recordInfo.recordName, name: name, count: count) - } - - static func formatForDisplay(_ recordInfo: RecordInfo) -> String { - recordInfo.recordName - } - } - - let record = TestRecord(recordName: "test-1", name: "Test", count: 42) - #expect(record.toCloudKitFields()["name"]?.stringValue == "Test") - #expect(record.toCloudKitFields()["count"]?.intValue == 42) -} - -@Test("RecordManaging generic operations") -func testRecordManagingSync() async throws { - // Test with mock CloudKitService - // Verify sync operations work - // Verify batching works (test with > 200 records) -} -``` - ---- - -### Task 8: Advanced Features (Optional - 2-3 hours) - -**Only if time permits:** - -**Source:** `Examples/Bushel/Sources/Bushel/Protocols/CloudKitRecordCollection.swift` - -This uses Swift 6.0 variadic generics for type-safe multi-record-type operations: - -```swift -protocol CloudKitRecordCollection { - associatedtype RecordTypeSetType: RecordTypeIterating - static var recordTypes: RecordTypeSetType { get } -} -``` - -**Enables:** -```swift -try await service.syncAllRecords(swiftVersions, restoreImages, xcodeVersions) -``` - -**Decision:** This is advanced and can be deferred. The core protocols provide 90% of the value. - ---- - -## Important Context & Decisions - -### Why Extract to Core? - -1. **Reduces Boilerplate:** From ~50 lines to ~20 lines per model -2. **Type Safety:** Compile-time guarantees, eliminates stringly-typed APIs -3. **Production Tested:** Bushel uses this in production syncing 1000+ records -4. **DX Improvement:** This was the #1 request from early users - -### Key Design Principles - -1. **Additive Only:** No breaking changes to existing APIs -2. **Protocol-Oriented:** Enables testing via mocking -3. **Swift 6 Ready:** All types are `Sendable` -4. **Documentation First:** Every public API needs examples - -### Potential Issues to Watch - -1. **Boolean Confusion:** CloudKit uses int64 (0/1) on wire, Swift uses Bool - - Document this clearly in `CloudKitRecord` protocol docs - - FieldValue convenience extensions handle the conversion - -2. **Batch Limits:** CloudKit has 200 operations per request limit - - The generic `sync()` must chunk operations - - See Bushel's implementation for reference - -3. **Error Handling:** Bushel's `RecordInfo.isError` pattern is fragile - - Consider improving error handling in MistKit's implementation - - Maybe add typed errors for batch operations - ---- - -## File Reference Map - -### Source Files in Bushel (to extract from) - -``` -Examples/Bushel/Sources/Bushel/ -├── Protocols/ -│ ├── CloudKitRecord.swift → Extract to MistKit/Protocols/ -│ ├── RecordManaging.swift → Extract to MistKit/Protocols/ -│ ├── RecordManaging+Generic.swift → Extract to MistKit/Extensions/ -│ ├── CloudKitRecordCollection.swift → Optional (advanced) -│ └── RecordTypeSet.swift → Optional (advanced) -├── Extensions/ -│ └── FieldValue+Extensions.swift → Extract to MistKit/Extensions/ -└── Services/ - └── BushelCloudKitService.swift → Reference for conformance example -``` - -### Target Structure in MistKit - -``` -Sources/MistKit/ -├── Protocols/ -│ ├── CloudKitRecord.swift ← NEW -│ ├── RecordManaging.swift ← NEW -│ └── CloudKitRecordCollection.swift ← NEW (optional) -├── Extensions/ -│ ├── FieldValue+Convenience.swift ← NEW -│ ├── RecordManaging+Generic.swift ← NEW -│ └── RecordManaging+RecordCollection.swift ← NEW (optional) -└── Service/ - └── CloudKitService+RecordManaging.swift ← NEW (conformance) - -Tests/MistKitTests/ -└── Protocols/ - ├── CloudKitRecordTests.swift ← NEW - └── RecordManagingTests.swift ← NEW -``` - ---- - -## Example Domain Models to Test With - -Use these as test cases (from Bushel): - -### Simple Model: -```swift -struct SwiftVersionRecord: CloudKitRecord { - static var cloudKitRecordType: String { "SwiftVersion" } - var recordName: String - var version: String - var releaseDate: Date - - // Implement protocol requirements... -} -``` - -### Complex Model with References: -```swift -struct XcodeVersionRecord: CloudKitRecord { - static var cloudKitRecordType: String { "XcodeVersion" } - var recordName: String - var version: String - var buildNumber: String - var releaseDate: Date - var swiftVersion: Reference // Reference to SwiftVersionRecord - var macOSVersion: Reference // Reference to another record - - // Implement protocol requirements... -} -``` - ---- - -## Testing Checklist - -Before considering this work complete: - -- [ ] All protocols compile and are public -- [ ] CloudKitService conforms to RecordManaging -- [ ] Generic extensions work with test models -- [ ] FieldValue convenience extensions work -- [ ] Batch operations handle 200+ record limit -- [ ] Bushel example builds using MistKit protocols -- [ ] New tests added with >90% coverage -- [ ] Documentation updated with examples -- [ ] `swift build` succeeds -- [ ] `swift test` shows all tests passing -- [ ] No new lint violations introduced -- [ ] CHANGELOG.md updated - ---- - -## Useful Commands - -```bash -# Build just MistKit -swift build --target MistKit - -# Build Bushel example -cd Examples/Bushel && swift build - -# Build Celestra example -cd Examples/Celestra && swift build - -# Run specific test suite -swift test --filter CloudKitRecordTests - -# Check protocol conformance -swift build -Xswiftc -debug-constraints 2>&1 | grep "CloudKitRecord" - -# Find protocol usage -rg "CloudKitRecord" Examples/Bushel/Sources/ - -# Generate documentation -swift package generate-documentation -``` - ---- - -## Questions to Consider - -When implementing, think about: - -1. **Should `formatForDisplay` be required or have a default implementation?** - - Recommendation: Provide default that returns `recordName` - -2. **Should RecordManaging support transactions/atomic operations?** - - Recommendation: Add optional `atomic` parameter to `executeBatchOperations` - -3. **How to handle partial failures in batch operations?** - - Recommendation: Return `[Result]` instead of throwing - -4. **Should we provide convenience initializers for common record types?** - - Recommendation: Yes, add `CloudKitRecord.create(fields:)` helper - ---- - -## Estimated Timeline - -| Task | Time | Priority | -|------|------|----------| -| Extract CloudKitRecord | 2-3h | HIGH | -| Extract RecordManaging | 1-2h | HIGH | -| FieldValue convenience extensions | 2h | HIGH | -| RecordManaging generic extensions | 3-4h | HIGH | -| CloudKitService conformance | 1h | HIGH | -| Update Bushel to import from MistKit | 1h | HIGH | -| Add comprehensive tests | 2-3h | HIGH | -| Advanced features (variadic generics) | 2-3h | LOW | -| Documentation & examples | 1-2h | MEDIUM | - -**Total: 13-20 hours** (8-14 hours for core features only) - ---- - -## Success Criteria - -The protocol extraction is complete when: - -1. ✅ A new model conforming to `CloudKitRecord` requires <25 lines of code -2. ✅ Bushel example builds using MistKit's protocols (no local duplicates) -3. ✅ Generic `sync()` and `query()` operations work with any `CloudKitRecord` -4. ✅ All tests pass with >90% coverage on new code -5. ✅ Documentation includes before/after examples showing DX improvement -6. ✅ No breaking changes to existing MistKit APIs - ---- - -## Contact Points - -If stuck, reference these key files: - -- **Error Handling Pattern:** `Sources/MistKit/CloudKitError.swift` -- **Existing Protocol Example:** `Sources/MistKit/TokenManager.swift` -- **Testing Patterns:** `Tests/MistKitTests/Core/FieldValue/FieldValueTests.swift` -- **Bushel Production Usage:** `Examples/Bushel/Sources/Bushel/Commands/SyncCommand.swift` - ---- - -Good luck! This is high-value work that will significantly improve the MistKit developer experience. 🚀 diff --git a/.claude/docs/research/asset-filechecksum.md b/.claude/docs/research/asset-filechecksum.md new file mode 100644 index 000000000..be69b0532 --- /dev/null +++ b/.claude/docs/research/asset-filechecksum.md @@ -0,0 +1,168 @@ +# Reverse-engineering CloudKit's asset `fileChecksum` + +**Date:** 2026-09-04 +**Container:** `iCloud.com.brightdigit.MistDemo` / `development` / public database (web-auth) +**Question:** How is the `fileChecksum` on a CloudKit `ASSET`/`ASSETID` field computed, and can a +client verify downloaded asset bytes against it? + +**Bottom line:** `fileChecksum` is **deterministic and content-addressed**, but it is **not +client-derivable**. It is minted server-side by the CDN, returned in the `assets/upload` receipt, and +is an opaque token that Apple's own documentation labels only `[SIGNATURE]`. ~1,500 candidate +constructions over three byte-exact samples all failed. `Asset.matches(data:)` and +`Asset.download(using:)` as currently written (SHA-256 of plaintext, base64 or hex) **cannot ever +succeed** against a real CloudKit asset. + +--- + +## 1. Samples + +All PNGs produced by `PNGData.generate(withSizeInKB:)` +(`Examples/MistDemo/Sources/MistDemoKit/Integration/PNGData.swift`). The generator was reimplemented +in Python and **verified byte-exact**: generated lengths and SHA-1s match both the live upload sizes +and the bytes downloaded back from the CDN. + +| `--asset-size` | bytes | sha1(plaintext) | sha256(plaintext)[:20] | observed `fileChecksum` | prefix | 20-byte body | +|---|---|---|---|---|---|---| +| 7 | 7320 | `f77d1cf229bcd696f01e655b4a7814320aa31509` | `9d3c…` | `AfQRAtjnqPAbe1u9gaUx81u01Uis` | `01` | `f41102d8e7a8f01b7b5bbd81a531f35bb4d548ac` | +| 50 | 51682 | `e5d44d02e25223f26a70dd7c6cb6e57603c628d3` | `8e6d2a5a81e0d603915bd5eb020c2905854f2178` | `AUStEc+gPyq1KTFbGO3RbXVpusut` | `01` | `44ad11cfa03f2ab529315b18edd16d7569bacbad` | +| 100 | 102933 | `59de06c35010dfd1cdc1d3798a4e88fc1c65f506` | `1d49ff05dba92c11b17579a5641b792ed7b49920` | `AZqG2xi2/dCW6cKE4VPySz6q2pRk` | `01` | `9a86db18b6fdd096e9c284e153f24b3eaada9464` | +| (shared-zone phase) | 51200 | — (not regenerated) | — | `AWbGPi+3CS5arEas6JNFb9tbw1zY` | `01` | `66c63e2fb7092e5aac46ace893456fdb5bc35cd8` | + +Every checksum decodes to exactly **21 bytes = a constant `0x01` version prefix + a 20-byte body**. +Body one-bit counts are 81, 83, 81 out of 160 — statistically uniform, consistent with a +cryptographic digest rather than a structured identifier. + +The same string appears URL-safe-encoded as the CDN path component: +`https://cvws.icloud-content.com/B/AfQRAtjnqPAbe1u9gaUx81u01Uis/${f}?…` — i.e. it doubles as the +content address. + +## 2. Established facts + +### 2.1 It is deterministic (content-addressed) + +Uploading the **same bytes** in two independent `mistdemo test-public` runs, into two different +records, produced **identical** checksums: + +| bytes | run 1 | run 2 | same? | +|---|---|---|---| +| 51200 | `AWbGPi+3CS5arEas6JNFb9tbw1zY` | `AWbGPi+3CS5arEas6JNFb9tbw1zY` | ✅ | +| 102933 | `AZqG2xi2/dCW6cKE4VPySz6q2pRk` | `AZqG2xi2/dCW6cKE4VPySz6q2pRk` | ✅ | + +The 51682 sample also reproduced the value recorded in an earlier, separate session +(`AUStEc+gPyq1KTFbGO3RbXVpusut`). **No per-upload nonce or per-record key is involved** — the value +is a pure function of the content (plus, possibly, fixed container-scoped context). + +### 2.2 The CDN stores and serves unmodified plaintext + +Downloading the 7320-byte asset back from its `downloadURL` returned HTTP 200, exactly 7320 bytes, +**byte-identical** to the locally generated PNG. So the checksum is *not* a digest over an encrypted +or otherwise transformed server-side representation — the plaintext is what is stored. (No +`wrappingKey` was present in any upload response.) + +### 2.3 It is minted server-side, not by the client + +MistKit does not compute it. `CloudKitService+AssetUpload.swift:106` reads it straight out of the +CDN's `singleFileUpload` response: + +```swift +return Asset( + fileChecksum: uploadResponse.singleFile.fileChecksum, + … +) +``` + +The client's only role is to pass the value through into the subsequent `records/modify`. Apple's +archived [Uploading Assets](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/UploadAssets.html) +documents the response purely as opaque pass-through data, giving `fileChecksum` the placeholder +`[SIGNATURE]` and specifying **no algorithm**. No primary Apple source in `.claude/docs/` documents +one either (`QUICK_REFERENCE.md:109` names the field but not its computation). + +### 2.4 The upload receipt embeds a fragment of the checksum + +The `receipt` is a binary (protobuf-like) blob. For the 51682 sample it contains the subsequence +`52 04 69bacbad` — a length-4 field whose value `69bacbad` is exactly the **last 4 bytes of the +checksum body** (`…7569bacbad`). This confirms the checksum body is a server-side artifact carried +inside the signed receipt, and reinforces that it is minted by Apple's infrastructure rather than +derived by either endpoint. + +## 3. Hypotheses tested — all negative + +Every construction below was evaluated against **all three byte-exact samples**; a hypothesis was +only ever going to be accepted on a ≥2-sample reproduction. **Total: ~1,500 probes, zero matches.** + +| Family | Variants tried | Result | +|---|---|---| +| Plain digests | sha1, sha224, sha256, sha384, sha512, sha512_224/256, md5, sha3_224/256/384/512, blake2b, blake2s, ripemd160, sm3, md5-sha1, shake_128/256 — **every 20-byte sliding window** of each digest, not just the first | ❌ | +| blake2 with `digest_size=20` | blake2b-160, blake2s-160 | ❌ | +| Length framing | size as le64/be64/le32/be32/decimal-ASCII, prepended / appended / both, over sha1, sha256, sha512, md5, blake2b, sha3_256 | ❌ | +| Version-byte framing | `0x00`/`0x01`/`0x02`/`0x0100`/`0x0001` pre- and post-pended, over sha1/sha256 | ❌ | +| HMAC | 16 candidate keys (empty, zero-blocks, container id `iCloud.com.brightdigit.MistDemo`, `CloudKit`, `cloudkit`, `Apple`, `com.apple.cloudkit`, `com.apple.Dataclass.CloudKit`, `_defaultZone`, `development`, `public`, `singleFileUpload`, record type) × sha1/sha256/sha512/md5/blake2b, **and** with key/message swapped | ❌ | +| Data-derived keys | `hmac(k=sha256(data), m=len)`, `hmac(k=len, m=data)` | ❌ | +| Chunk trees | leaf ∈ {sha1, sha256, md5, blake2b}, root ∈ {sha1, sha256}, chunk sizes 512 B → 32 MiB (all powers of two) plus 51200/65535/102400; flat concatenation, truncated-leaf concatenation, index-framed leaves, and **binary Merkle trees** | ❌ | +| Representations | hashing the lowercase-hex, uppercase-hex, standard-base64, and url-safe-base64 encodings of the data | ❌ | +| Nested / iterated | `a(b(data))` and `a(b(data) ‖ data)` across sha1/sha256/md5/sha512/blake2b | ❌ | +| hash160 style | `ripemd160(sha256(d))`, `ripemd160(sha1(d))`, `sha1(sha256(d))`, `ripemd160(blake2b(d))`, etc. | ❌ | +| Protobuf-style wrappers | `H(tag ‖ inner_digest ‖ size)` with tags `01`, `00`, `0a14`, `1220`, size in both endiannesses | ❌ | +| PNG-internal | sha1/sha256 of the IDAT payload only, and of the inflated raw pixel data | ❌ | +| Encrypted-representation | ruled out empirically — CDN returns byte-identical plaintext (§2.2) | ❌ | + +Also ruled out by construction: the 21-byte length excludes plain SHA-256 (32 B); the reproducibility +result (§2.1) excludes any per-upload nonce or random salt. + +## 4. Verdict + +**`fileChecksum` is not client-derivable from the asset bytes.** + +The strongest evidence: + +1. **Apple never documents an algorithm.** The archived Web Services Reference — the only primary + source — treats the value as opaque pass-through and calls it `[SIGNATURE]`. A signature, not a + digest, is the natural reading of a value the client is told only to echo back. +2. **The client never computes it.** It arrives from the CDN in the upload receipt; MistKit's sole + involvement is forwarding it (§2.3). +3. **The receipt embeds part of it** (§2.4), placing its provenance inside Apple's server-side signed + blob. +4. **~1,500 constructions over three byte-exact samples produced no match** (§3), including the + entire space of common digests at every truncation offset. +5. The `0x01` prefix plus a 20-byte body most plausibly denotes a **versioned server-side signature + or truncated keyed digest** whose key lives on Apple's infrastructure — unobtainable by a client + by definition. + +It remains conceivable that the body is some unkeyed digest under a framing not tried here, but the +combination of (1)–(4) makes a **server-held key** the far likelier explanation, and no amount of +client-side search can close that gap. + +### Consequence for MistKit + +`Asset.matches(data:)` (`Sources/MistKit/Models/FieldValues/Asset+Checksum.swift`) compares +`fileChecksum` against base64/hex of **SHA-256 of the plaintext**. Per the table above, that can +never match a real CloudKit asset. Because `Asset.download(using:)` treats a mismatch as fatal +(`CloudKitError.assetChecksumMismatch`) and refuses to return unverified bytes, **downloading any +genuine CloudKit asset currently always throws** — which is exactly the failing download-verify phase +in `mistdemo test-public`. + +Options, in rough order of preference: + +1. **Drop checksum verification as a precondition for returning bytes.** Return the downloaded data; + the transport is already TLS-authenticated against Apple's CDN. Optionally expose `fileChecksum` + as an opaque identity/caching token, which is what it demonstrably is. +2. **Verify size instead.** `size` from the asset dictionary is meaningful, client-checkable, and + catches truncated downloads — the realistic failure mode. +3. **Keep `matches(data:)` but re-document it** as a local-integrity helper against a + caller-supplied digest, not as CloudKit checksum validation. + +Whichever is chosen, the current documentation comments asserting `fileChecksum` is "SHA-256 of the +plaintext" are factually wrong and should be corrected. + +## 5. Reproducing + +```bash +cd Examples/MistDemo +swiftly run +6.4.x-snapshot-2026-06-15 swift run mistdemo test-public \ + --record-count 1 --asset-size 50 --verbose +``` + +Grep the output for `"fileChecksum"` alongside the adjacent `"size"`. The Python reimplementation of +`PNGData.generate` used to reconstruct exact upload bytes (verified against CDN downloads) is +reproduced by porting `PNGData.swift`: solid-color RGB PNG, filter byte 0 per scanline, zlib +*stored* (uncompressed) DEFLATE blocks, square side = `round(sqrt(sizeKB * 1024 / 3))`. diff --git a/.claude/docs/research/windows-6.2-ci-failure-462.md b/.claude/docs/research/windows-6.2-ci-failure-462.md new file mode 100644 index 000000000..5c1f9065d --- /dev/null +++ b/.claude/docs/research/windows-6.2-ci-failure-462.md @@ -0,0 +1,50 @@ +# Windows Swift 6.2 CI failure — `462-web-auth-token-rotation` + +**Date:** 2026-09-02 +**Failing job:** `Build on Windows (windows-2022, swift-6.2-release, 6.2-RELEASE)` +**Primary log:** `/Users/leo/Downloads/windows-fialure.txt` (run `33640879394`) + +## Verdict + +**Swift 6.2 Windows toolchain silently aborts while emitting `MistKitTests`.** Library targets succeed. Same commit is green on Windows 6.1 and 6.3. Tip-over is MistKitTests size/complexity on this branch relative to `main`. Reproducible (not a flake). Closest match: wasm silent exit-1 signature, but toolchain-version-specific. + +## Evidence + +| Run | SHA | Windows 6.2 | +|-----|-----|-------------| +| `33523875168` | `4ccaa624` | fail | +| `33640879394` | `a6c50236` | fail | +| rerun | `a6c50236` | fail (not flake) | +| `33656677094` | `a36fddd` (actor→class) | fail — class workaround did **not** help | +| `33657978639` | `4605cf7`/`77d707a` (Package.swift Windows exclude) | success | + +Dies after compiling MistKitTests sources with exit 1, **no** `error:` / stack dump, and **no** `Emitting module MistKitTests`. On success paths, emit appears then wrap/link. + +## Mitigation (current) + +Keep `@Suite` / `@Test` / mocks always compiled. Omit only each tip-over **test body** on **Windows × Swift 6.2**, with `Issue.record` in the `#else` (Swift Testing `#if canImport(…)` style), plus `.disabled(if: Platform.isWindowsSwift62)` on the `@Suite` so CI does not fail if tests run: + +```swift +@Suite("…", .disabled(if: Platform.isWindowsSwift62)) +internal struct Example { + @Test("…") + internal func example() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + // real body + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } +} +``` + +`Platform.isWindowsSwift62` lives in `Tests/MistKitTests/Helpers/Platform.swift`. Traits alone cannot fix emit-module — the `#if` is load-bearing. + +Windows 6.1/6.3 and all non-Windows platforms still compile and run the real bodies. + +## Dead ends + +- Restoring `WebAuthTokenManager` as a locked class (vs actor) — red herring. +- `Package.swift` `#if os(Windows)` `exclude:` — worked but was broader than needed (all Windows). +- Swift Testing `.disabled(if:)` alone — execution-only; does not shrink emit-module. +- Wrapping whole `@Suite` / files in `#if` — works but coarser than body gates. diff --git a/.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md b/.claude/docs/swift-configuration.md similarity index 100% rename from .claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md rename to .claude/docs/swift-configuration.md diff --git a/.claude/docs/https_-swiftpackageindex.com-apple-swift-log-main-documentation-logging.md b/.claude/docs/swift-log.md similarity index 100% rename from .claude/docs/https_-swiftpackageindex.com-apple-swift-log-main-documentation-logging.md rename to .claude/docs/swift-log.md diff --git a/.claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-generator-1.10.3-documentation-swift-openapi-generator.md b/.claude/docs/swift-openapi-generator.md similarity index 100% rename from .claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-generator-1.10.3-documentation-swift-openapi-generator.md rename to .claude/docs/swift-openapi-generator.md diff --git a/.claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-runtime-1.9.0-documentation-openapiruntime.md b/.claude/docs/swift-openapi-runtime.md similarity index 100% rename from .claude/docs/https_-swiftpackageindex.com-apple-swift-openapi-runtime-1.9.0-documentation-openapiruntime.md rename to .claude/docs/swift-openapi-runtime.md diff --git a/.claude/memory/MEMORY.md b/.claude/memory/MEMORY.md index 889ab1441..0527ad011 100644 --- a/.claude/memory/MEMORY.md +++ b/.claude/memory/MEMORY.md @@ -12,8 +12,9 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native ## Index - [CloudKit archived endpoints not in local docs](reference_cloudkit_archived_endpoints.md) — Verify CloudKit endpoints (e.g. assets/rereference) against Apple's archived reference, not just .claude/docs/webservices.md -- [CloudKit Zone Dictionary has exactly 3 keys](reference_cloudkit_zone_dictionary.md) — zoneID/syncToken/atomic only; isEager, modify-request `atomic`, and zone create options do NOT exist +- [CloudKit Zone Dictionary has exactly 3 keys](reference_cloudkit_zone_dictionary.md) — archived docs: zoneID/syncToken/atomic; live change feeds also carry `deleted` + `zoneID.zoneType`; wire owner key is `ownerRecordName` (issue #444) - [wasm CI failure signatures](reference_wasm_ci_signatures.md) — Two distinct wasm failures: silent exit-1 (OOM on big test target) vs curl exit-7 (SDK download flake, just re-run) +- [Windows 6.2 MistKitTests emit abort](reference_windows_62_mistkittests_emit_abort.md) — Swift 6.2 Windows silent exit-1 while emitting MistKitTests; gate tip-over test bodies with `#if` + `Issue.record` (not actor revert) - [Swift Testing availability guard](feedback_swift_testing_availability.md) — Never annotate @Suite types with @available; use guard #available inside @Test functions instead - [GitHub Action pinning preference](feedback_action_pinning.md) — Use @v for brightdigit-owned actions; pin third-party actions explicitly - [CI Swift matrix preferences](feedback_ci_swift_matrix.md) — Keep Swift 6.1 in full matrix; in-dev Swift branches (6.4 snapshots) ride in the build-ubuntu matrix via an `image` override (ConfigKeyKit pattern), never a separate job @@ -35,9 +36,21 @@ Project-scoped agent memory for MistKit. This directory **replaces** any native - [macOS APNs entitlement key](project_macos_aps_entitlement_key.md) — macOS needs `com.apple.developer.aps-environment`; iOS uses `aps-environment`. codesign silently strips the wrong-platform key. - [RecordResult pattern throughout API](feedback_record_result_pattern_throughout.md) — Surface per-item modify failures with the RecordResult success-or-failure pattern everywhere (subscriptions, zones…), not just records - [Subrepo-local fixes belong in the subrepo](feedback_subrepo_fixes_belong_in_subrepo.md) — Changes isolated to an Example subrepo (e.g. CelestraCloud copyright headers) go in that subrepo's own repo, not a parent MistKit branch +- [Subrepo directive is about local hygiene only](feedback_subrepo_directive_scope.md) — cross-cutting changes originating in the parent DO belong on a parent branch; don't over-apply the subrepo rule - [beta.4 worktree layout](project_beta4_worktree_layout.md) — Remaining v1.0.0-beta.4 issues are developed in parallel worktrees under MistKit.git/wt-, PR'd to the v1.0.0-beta.4 base - [#419 already fixed in beta.3](project_419_fixed_in_beta3.md) — MistDemoApp view inits shipped in 5a58120; verified building on macOS Swift 6.3.2, do not re-implement - [Never git stash in this multi-worktree repo](feedback_never_git_stash_multiworktree.md) — The stash stack is shared across worktrees; a pop in one can bury a sibling branch's WIP. Commit instead. - [cloudkit.share wire casing](project_cloudkit_share_record_type_casing.md) — Live API wants `cloudkit.share` (lowercase k); archived docs' `cloudKit.share` yields "Cannot share - no such record exists to share" - [MISTKIT_BRANCH pin resolves tags too](project_mistkit_branch_pin_resolves_tags.md) — `git ls-remote` matches tags; a tag value silently pins the old release and greens example CI without testing the branch -- [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — `examples.yml` must use a Swift container that can parse each example's Package.swift (6.4 nightly for Bushel/Celestra; 6.3 for MistDemo) +- [MistKit release process](project_release_process.md) — Branch `vX` vs tag `X`; pins invert at release; retain release branches; notes are a flat bullet list for new entries +- [Use git trees, not git worktree](feedback_use_git_trees_not_git_worktree.md) — Manage worktrees with `git trees add/rm/list/clean`; `add` pushes to origin unless `--no-push` +- [git trees add bases new worktrees on main](reference_git_trees_add_bases_on_main.md) — not on the invoking worktree's branch; reset onto the intended base, then first push needs --force-with-lease +- [Examples workflow tracks subrepo tools-version](feedback_examples_workflow_tracks_tools_version.md) — MistDemo/Bushel/Celestra/MKC are tools-version 6.4; examples.yml + MistDemo.yml use 6.4 nightly / Xcode 27 +- [ConfigKey bases must be dash-case](reference_configkey_cli_flag_dash_case.md) — snake_case silently breaks CLI flags and secret redaction; ENV works either way, so it hides the bug +- [ConfigKeyKit ConfigValueReading](reference_configkeykit_configvaluereading.md) — ConfigKeyKit#1 shipped in-core in 1.0.0-beta.2; there is no ConfigKeyKitConfiguration package +- [Path-package identity is the directory name](project_path_package_identity_collision.md) — a `path:` MistKit + a transitive `url:` MistKit = duplicate-target build failure; resolve still succeeds +- [MistKitConfiguration subrepo overlay](project_mistkitconfiguration_subrepo_overlay.md) — Package.swift differs by one line between monorepo and standalone; `git subrepo push` clobbers it +- [Dogfood pins are branch pins, not tags](project_dogfood_pins_are_branch_pins_not_tags.md) — tagging a monorepo package does NOT mean Examples switch to `from:`; they keep `path:` and CI pins both deps to branch HEADs +- [MistKitConfiguration integration branch](project_mkc_integration_branch.md) — subrepo tracks `mistkit-beta.5`; it is beta.5-line scaffolding and must be DELETED in the `→ main` release PR +- [Draft-gated CI needs ready_for_review](reference_draft_gated_ci_needs_ready_for_review.md) — an `if: draft == false` job stays `skipped` forever unless `types:` lists `ready_for_review`; re-running replays the stale payload +- [FieldValue.bytes is domain Data](project_fieldvalue_bytes_is_base64_string.md) — wire/generated BytesValue stays base64 String; do not infer .bytes from untagged strings diff --git a/.claude/memory/feedback_examples_workflow_tracks_tools_version.md b/.claude/memory/feedback_examples_workflow_tracks_tools_version.md index ce10945bd..24cc29136 100644 --- a/.claude/memory/feedback_examples_workflow_tracks_tools_version.md +++ b/.claude/memory/feedback_examples_workflow_tracks_tools_version.md @@ -4,8 +4,8 @@ description: MistKit examples.yml must use a Swift container that can parse each type: feedback --- -`BushelCloud` and `CelestraCloud` declare `swift-tools-version: 6.4`. Their own CI already runs on `swiftlang/swift:nightly-6.4.x-noble`. MistKit's parent `.github/workflows/examples.yml` must use a matching container for those matrix cells — a shared `swift:6.3` container fails immediately with "using Swift tools version 6.4.0 but the installed version is 6.3.x". +`MistDemo`, `BushelCloud`, `CelestraCloud`, and `MistKitConfiguration` declare `swift-tools-version: 6.4`. Their CI runs on `swiftlang/swift:nightly-6.4.x-noble` (Linux) and `runs-on: xcode-27` (Apple). MistKit's parent `.github/workflows/examples.yml` must use a matching container for those matrix cells — a shared `swift:6.3` container fails immediately with "using Swift tools version 6.4.0 but the installed version is 6.3.x". -`MistDemo` stays on tools-version 6.2 / container `swift:6.3`. Prefer a per-example `matrix.include` with `container:` rather than one image for all three. +`.github/workflows/MistDemo.yml` likewise uses only the 6.4 nightly on Linux and Xcode 27 on macOS (Windows/Android lanes stay disabled until a 6.4 toolchain exists there). Prefer a per-example `matrix.include` with `container:` in `examples.yml` rather than one image for all four. -When bumping an example's tools-version (or adopting a nightly-only toolchain), update `examples.yml` in the same pass. +When bumping an example's tools-version (or adopting a nightly-only toolchain / a 6.4-only path dependency), update `examples.yml` and that example's dedicated workflow in the same pass. diff --git a/.claude/memory/feedback_subrepo_directive_scope.md b/.claude/memory/feedback_subrepo_directive_scope.md new file mode 100644 index 000000000..15f1b04b7 --- /dev/null +++ b/.claude/memory/feedback_subrepo_directive_scope.md @@ -0,0 +1,28 @@ +--- +name: feedback_subrepo_directive_scope +description: "The subrepo-fixes directive governs subrepo-LOCAL hygiene only; cross-cutting changes that originate in the parent do belong on a parent branch" +metadata: + node_type: memory + type: feedback +--- + +`feedback_subrepo_fixes_belong_in_subrepo` is about **subrepo-local hygiene** — +a change concerning only content already inside the subrepo (the cited case is +CelestraCloud copyright headers). It does **not** block cross-cutting work that +originates in the parent repo and lands in a subrepo. + +**Why:** Asked to move MistKit's `.claude/docs/` domain files into +`Examples/BushelCloud/` and `Examples/CelestraCloud/`, I read the directive as +forbidding it and put a false choice to Leo ("do it in the subrepo, or don't move +them"). He pushed back. The directive's own "How to apply" line carves this out: +*"only touch subrepo files when the change is part of that cross-cutting +concern"* — and the adjacent agent-notes line says repo-wide CI bumps DO extend +into the Examples subrepos in the same pass. + +**How to apply:** Ask where the change *originates*. Originates inside the +subrepo and concerns only it → route to the standalone repo. Originates in the +parent, or decides what the parent owns → parent branch is correct; the only +real constraint is the mechanical one of getting it upstream later. Quote the +directive before invoking it. Related: +[[feedback_subrepo_fixes_belong_in_subrepo]], +[[project_examples_dir_is_for_mistkit_dev]]. diff --git a/.claude/memory/feedback_use_git_trees_not_git_worktree.md b/.claude/memory/feedback_use_git_trees_not_git_worktree.md new file mode 100644 index 000000000..a5cc36ffa --- /dev/null +++ b/.claude/memory/feedback_use_git_trees_not_git_worktree.md @@ -0,0 +1,31 @@ +--- +name: feedback_use_git_trees_not_git_worktree +description: Create and remove worktrees in this repo with `git trees add/rm`, never raw `git worktree` +metadata: + type: feedback +--- + +Always manage worktrees in this repo with **`git trees`** (brightdigit's tool, installed +at `~/.local/bin/git-trees`) — never raw `git worktree add` / `git worktree remove`. + +**Why:** On 2026-09-01, while planning the release runbook, I proposed +`git worktree add ../release-tooling -b release-tooling main`. Leo corrected me: "use git +trees instead of git worktree." The project is a bare-repo + sibling-worktree layout +(`MistKit.git/` alongside `main/`, `v1.0.0-beta.5/`, …) that `git trees` created and +maintains; raw `git worktree` skips the upstream/push wiring and the layout metadata. + +**How to apply** (`git trees --help` for the full list): + +| Task | Command | +|---|---| +| New branch + worktree off a base | `git trees add [base]` | +| Without pushing to origin | `git trees add [base] --no-push` | +| Print path (to `cd`) | `git trees add --print-path` | +| List worktrees | `git trees list [--json]` | +| Remove worktree + branch | `git trees rm --apply` | +| Sweep merged/gone branches | `git trees clean --merged \| --gone [--apply]` | +| Fetch / update worktrees | `git trees sync [worktree] [--pull]` | + +`add` creates the branch on origin via `git push -u origin HEAD` unless `--no-push` (or +`TREES_NO_PUSH`) is set, and prints the worktree path but cannot `cd` your shell. +Related: [[feedback_never_git_stash_multiworktree]], [[project_beta4_worktree_layout]]. diff --git a/.claude/memory/project_beta4_worktree_layout.md b/.claude/memory/project_beta4_worktree_layout.md index 2fa3b36c3..52daf03e3 100644 --- a/.claude/memory/project_beta4_worktree_layout.md +++ b/.claude/memory/project_beta4_worktree_layout.md @@ -19,6 +19,6 @@ Layout: `MistKit.git/wt-/`, branch named `##-issue-slug` (multi-iss Grouping rule used: issues touching the same `openapi.yaml` path family share a branch, since each one requires a `./Scripts/generate-openapi.sh` regeneration and separate branches would collide in `Sources/MistKitOpenAPI/`. -**#407 (MistKitConfiguration package) was deliberately excluded** — it is blocked on brightdigit/ConfigKeyKit#1 shipping and tagging, and it creates a new `Packages/` subrepo, which is not safe to do unattended. +**#407 (MistKitConfiguration package)** — the stated ConfigKeyKit#1 blocker is resolved: that bridge shipped in-core as `ConfigValueReading` in ConfigKeyKit 1.0.0-beta.2, and the `ConfigKeyKitConfiguration` package its diagram assumed never existed (see [[reference-configkeykit-configvaluereading]]). Verification also showed most of #407's other premises stale — `Environment` parsing, the credential types and the service factory are all already in MistKit. Reframed as: unify BushelCloud + CelestraCloud first (PR 1), then extract the now-identical surface into a new `brightdigit/MistKitConfiguration` repo + `Packages/` subrepo (PR 2). Creating that repo and the `git subrepo push`es stay user actions. Related: [[project_examples_dir_is_for_mistkit_dev]], [[feedback_subrepo_fixes_belong_in_subrepo]] diff --git a/.claude/memory/project_dogfood_pins_are_branch_pins_not_tags.md b/.claude/memory/project_dogfood_pins_are_branch_pins_not_tags.md new file mode 100644 index 000000000..e47089b2a --- /dev/null +++ b/.claude/memory/project_dogfood_pins_are_branch_pins_not_tags.md @@ -0,0 +1,41 @@ +--- +name: project-dogfood-pins-are-branch-pins-not-tags +description: "Examples keep path: deps for BOTH MistKit and MistKitConfiguration; CI rewrites both to branch-HEAD revision pins. Releasing MistKitConfiguration does NOT mean the Examples switch to its tag." +metadata: + type: project +--- + +Tagging MistKitConfiguration does **not** mean `Examples/*/Package.swift` should +switch to `from: ""`. The Examples exist to dogfood **unreleased** MistKit, so +they must reach every in-monorepo package the same way: `path:` locally, rewritten +to a **branch-HEAD `revision:` pin** in CI. + +`.github/actions/setup-mistkitconfiguration` (in the MistKitConfiguration repo) +already does exactly this — it takes `mistkit-branch` **and** +`mistkitconfiguration-branch` and rewrites *both* path deps in one pass. Its own +description states the constraint: *"a path: MistKit and a url: MistKit cannot +coexist."* + +**Why a tag is the wrong lever here.** Pointing only MistKitConfiguration at a tag +breaks the Examples two ways, both verified 2026-08-31: + +1. Tagged MistKitConfiguration depends on MistKit by `url:`, which collides with the + Examples' sibling `path:` MistKit — the duplicate-target failure in + [[project_path_package_identity_collision]]. Appears at `swift build`, not + `resolve`. +2. Resolving that by *also* moving MistKit to its released tag makes MistDemo fail to + compile: it uses `ZoneType` and `ZoneInfo.deleted` (issue #444), which exist only + on the development branch. Building an Example against the last release defeats + the point of `Examples/`. + +So the release train and the dogfood wiring are independent. A published tag is for +**downstream consumers**; the Examples stay on branch pins until the feature they +exercise has actually shipped. + +**Apply:** when a monorepo package gets tagged, leave `Examples/*/Package.swift` +alone. If an Example lane needs the collision resolved in CI, add +`setup-mistkitconfiguration` with both branch inputs rather than rewriting manifests. + +Related: [[project_examples_dir_is_for_mistkit_dev]], +[[project_mistkitconfiguration_subrepo_overlay]], +[[feedback_setup_action_lives_in_owned_repo]] diff --git a/.claude/memory/project_fieldvalue_bytes_is_base64_string.md b/.claude/memory/project_fieldvalue_bytes_is_base64_string.md new file mode 100644 index 000000000..b21eea652 --- /dev/null +++ b/.claude/memory/project_fieldvalue_bytes_is_base64_string.md @@ -0,0 +1,48 @@ +--- +name: project_fieldvalue_bytes_is_base64_string +description: FieldValue.bytes is Data in the domain; the wire and generated BytesValue stay a base64 String. Do not infer .bytes from untagged strings. +metadata: + type: project +--- + +`FieldValue.bytes` is `case bytes(Data)` (`Sources/MistKit/Models/FieldValues/FieldValue.swift`). +Base64 encode/decode happens at the conversion boundary. This is a **domain-layer** +change only (issue #467): `Sources/MistKitOpenAPI/` and `openapi.yaml` are untouched — +the generated `BytesValue` remains `typealias … = Swift.String` because the wire format +is still base64 text. + +- Encode: `value.base64EncodedString()` in `FieldValueRequest`, `ListValuePayload`, and + `FieldValue+Codable`. +- Decode: `Data(base64Encoded:)` at the `FieldValue` boundary. `ScalarPayload.bytes` + still holds the raw wire `String` so `requireString` stays total. +- Malformed tagged `BYTES` throws `ConversionError.typeValueMismatch` (no new public + error case) via `reportAndThrow()`, passing the **unwrapped** string as `value`. +- `bytesValue: String?` returns `base64EncodedString()`. `dataValue: Data?` matches + `.bytes` only — never fall back to decoding a `.string` payload. + +## Base64 has no false-positive signal + +Base64 carries no header, checksum, or self-identifying structure. Validity is only a +character-set-and-length check, so **any** `[A-Za-z0-9+/]` string whose length is a +multiple of 4 decodes successfully: + +| Input | `Data(base64Encoded:)` | +|---|---| +| `"test"`, `"user"`, `"name"`, `"true"`, `"data"`, `"Chen"` | 3 bytes of garbage | +| `"hello"` (len 5), `"Mei"` (len 3) | `nil` | + +`"Chen"` is from Apple's own example record (`.claude/docs/webservices.md`, +`"lastName" : {"value" : "Chen"}`) — a four-letter surname is indistinguishable from base64. + +**Therefore: never infer `.bytes` by attempting a base64 decode**, neither in the scalar +decode chain nor as a `dataValue` fallback for `.string`. Untagged base64 continues to +resolve as `.string`. + +## Untagged BYTES responses — frequency unmeasured + +When a response omits `type`, first-match-wins inference claims a base64 string as +`.string` (documented in CLAUDE.md as lossy). How often CloudKit actually omits `type` on +reads is **not established**. If an untagged `BYTES` response occurs, `dataValue` is `nil` +and the base64 text remains on `stringValue`. + +Related: [[project_release_process]] diff --git a/.claude/memory/project_mistkitconfiguration_subrepo_overlay.md b/.claude/memory/project_mistkitconfiguration_subrepo_overlay.md new file mode 100644 index 000000000..9bdaf3fb0 --- /dev/null +++ b/.claude/memory/project_mistkitconfiguration_subrepo_overlay.md @@ -0,0 +1,27 @@ +# MistKitConfiguration subrepo carries a never-merged Package.swift overlay + +`Packages/MistKitConfiguration` is a `git subrepo` of +`git@github.com:brightdigit/MistKitConfiguration.git` (branch `initial-extraction`). +Its `Package.swift` **deliberately differs between the two repos on one line**: + +| Where | MistKit dependency | +|---|---| +| Monorepo (`Packages/MistKitConfiguration`) | `.package(name: "MistKit", path: "../..")` | +| Standalone repo | `.package(url: "…/MistKit.git", from: "1.0.0-beta.4")` | + +Both forms are required. The `path:` form is forced by +[[project_path_package_identity_collision]] — a `path:` package's identity is the +directory name, so mixing it with a transitive `url:` MistKit fails the monorepo +build. The `url:` form is what makes a *tag* of MistKitConfiguration usable +downstream; a tag carrying `path: "../.."` resolves nowhere. + +**`git subrepo push` does not know this.** It copies the subdir verbatim, so a push +from the monorepo overwrites the standalone `url:` line with `path:` and breaks the +published package. Re-apply the swap after every push. Same discipline +`Examples/BushelCloud/Package.swift:94-98` documents for its own MistKit line. + +The seeding was done by hand rather than by `git subrepo push`, because the repo was +empty and GitHub cannot open a PR between unrelated histories: `main` was seeded with +a LICENSE-only initial commit first so `initial-extraction` had a merge base. As a +result `.gitrepo` records an empty `commit =`, so the *first* `git subrepo push` will +believe nothing has been pushed — check the remote before running it. diff --git a/.claude/memory/project_mkc_integration_branch.md b/.claude/memory/project_mkc_integration_branch.md new file mode 100644 index 000000000..fbd20c204 --- /dev/null +++ b/.claude/memory/project_mkc_integration_branch.md @@ -0,0 +1,47 @@ +--- +name: project-mkc-integration-branch +description: "MistKitConfiguration's mistkit-beta.5 branch pins the unreleased MistKit release branch; the subrepo tracks it and it must never be merged to main or tagged." +metadata: + type: project +--- + +`brightdigit/MistKitConfiguration` carries two long-lived refs with different +dependency policies: + +| Ref | MistKit dependency | Purpose | +|---|---|---| +| `main` | `from: "1.0.0-beta.4"` | tag-only; what `1.0.0-beta.1` was cut from | +| `mistkit-beta.5` | `branch: "v1.0.0-beta.5"` | integration; tracks unreleased MistKit | + +`Packages/MistKitConfiguration/.gitrepo` tracks **`mistkit-beta.5`** (created +2026-08-31 at `a70afee`), not `main` and no longer `initial-extraction` — that +branch was squash-merged as `6df3ad4`, so its commits are not on `main` and a +subrepo pull against it would diff against history that no longer exists. + +**`mistkit-beta.5` must never be merged to `main` or tagged.** A `branch:` +requirement in a published tag is unresolvable for downstream consumers; +`dependency-policy.yml` rejects branch/revision/path requirements on non-draft PRs +to `main`, which is the guard. The branch exists so MistKitConfiguration can be +exercised against MistKit features that have not shipped (#444's `ZoneType` / +`ZoneInfo.deleted`). + +**`Packages/MistKitConfiguration` is scaffolding for the beta.5 line and must be +removed before `main`.** It rides the `v1.0.0-beta.5` release branch so MistKit and +MistKitConfiguration can be developed together, but it is deliberately **not** part of +the shipped MistKit repo: the release PR `v1.0.0-beta.5` → `main` deletes the subrepo, +and anything still needing the package consumes it as a normal tagged dependency. + +Leaving it in would be circular — a MistKit release carrying a package whose `.gitrepo` +tracks a branch pinning that same unreleased release. + +**Retirement order:** merge feature PRs into `v1.0.0-beta.5` with the subrepo intact → +before the `→ main` release PR, delete `Packages/MistKitConfiguration` and drop its +`examples.yml` lane → tag MistKit `1.0.0-beta.5` → bump MistKitConfiguration `main` to +`from: "1.0.0-beta.5"`, cut `1.0.0-beta.2`, delete the `mistkit-beta.5` branch. + +Note this is the *standalone* repo's wiring. It does not change how the monorepo +builds: `Examples/` and `Packages/` still reach MistKit by `path: "../.."` — see +[[project_dogfood_pins_are_branch_pins_not_tags]]. + +Related: [[project_mistkitconfiguration_subrepo_overlay]], +[[project_path_package_identity_collision]] diff --git a/.claude/memory/project_path_package_identity_collision.md b/.claude/memory/project_path_package_identity_collision.md new file mode 100644 index 000000000..9dd9c8c28 --- /dev/null +++ b/.claude/memory/project_path_package_identity_collision.md @@ -0,0 +1,29 @@ +# Path-package identity is the directory name, not `name:` + +A local `.package(name: "MistKit", path: "../..")` gets its **package identity from the +resolved directory's basename** — in a worktree that is the worktree folder name (e.g. +`407-mistkitconfiguration`), **not** `mistkit` and not the `name:` argument. + +Consequence: if any package in the graph also depends on MistKit by **URL** +(`https://github.com/brightdigit/MistKit.git`, identity `mistkit`), SPM sees two distinct +packages, resolves **both**, and the build fails: + +``` +error: multiple similar targets 'MistKit', 'MistKitOpenAPI' appear in package +'mistkit' and '407-mistkitconfiguration', this may indicate that the two packages +are the same and can be de-duplicated by using mirrors. +``` + +`swift package resolve` **succeeds** — the failure only appears at `swift build`, so a +green resolve is not evidence the graph is sound. + +**Apply:** every package inside this monorepo that needs MistKit must use the *same* +`path:` dependency as its siblings. Never mix a `path:` MistKit with a transitive `url:` +MistKit. This is why `Packages/MistKitConfiguration` uses `.package(name: "MistKit", +path: "../..")` rather than a tagged URL, and why its `path:` line is a monorepo-local +overlay that must be swapped for a `url:` before the standalone repo is tagged — the same +never-merged-overlay discipline documented at `Examples/BushelCloud/Package.swift:94-98`. + +Verified empirically 2026-08-31 with a two-package scratch fixture: URL-form fails at +build; both-path form builds clean. See [[project_examples_dir_is_for_mistkit_dev]] and +[[project_mistkit_branch_pin_resolves_tags]]. diff --git a/.claude/memory/project_release_process.md b/.claude/memory/project_release_process.md new file mode 100644 index 000000000..01f5106e4 --- /dev/null +++ b/.claude/memory/project_release_process.md @@ -0,0 +1,40 @@ +--- +name: project_release_process +description: MistKit release mechanics — v-prefixed branch vs bare tag, pins invert at release, notes must exist in the tagged tree, notes are a flat bullet list +metadata: + type: project +--- + +The MistKit release process is encoded in `.claude/skills/release/SKILL.md` (invoke as +`/release`) with mechanical checks in `Scripts/release.sh`. The facts that are **not** +derivable from the code: + +**Naming is asymmetric on purpose.** Release branch `v1.0.0-beta.5`; release tag +`1.0.0-beta.5` (lightweight, no `v`). All 26 release tags follow this. + +**Pin semantics invert at release.** `setup-mistkit` resolves `MISTKIT_BRANCH` with +`git ls-remote`, which matches tags *and* branches, so the wrong kind of ref pins +silently and greens example CI without compiling the code under release. Before the +release merge the pin must be the **branch**; after publishing, the **tag**. Only +`./Scripts/release.sh pins --expect-branch|--expect-tag` asserts the ref *kind* — see +[[project_mistkit_branch_pin_resolves_tags]]. + +**A tag must contain its own notes.** Both `1.0.0-beta.3` and `1.0.0-beta.4` were tagged +with no `ReleaseNotes.md` section of their own (beta.3's tagged tree heads at +`## 1.0.0-beta.2`; beta.4's notes landed a day later in `687b532`). `verify-tag` reads +the *tagged tree* to catch this, and `.github/workflows/release.yml` re-asserts it after +any tag push. + +**Release notes are a flat bullet list** — `* (#refs) by @user in ` — with +no `###` category subsections. Sections for beta.1–beta.4 predate this decision (made +2026-09-01) and were deliberately left un-flattened. + +**The release PR is the merge-commit case**, unlike feature PRs — but the shape is +confirmed with the human each time rather than assumed. See +[[feedback_feature_pr_merge_squash_or_rebase]] and +[[feedback_check_merge_strategy_before_release_deletions]] (the archive tag is +load-bearing under squash). + +**Release branches are retained after publication.** Do not delete `v*` release +branches or remove their worktrees; open the next beta from `main` with +`git trees add vX.Y.Z main`. diff --git a/.claude/memory/reference_cloudkit_zone_dictionary.md b/.claude/memory/reference_cloudkit_zone_dictionary.md index aeed8b097..c1338f758 100644 --- a/.claude/memory/reference_cloudkit_zone_dictionary.md +++ b/.claude/memory/reference_cloudkit_zone_dictionary.md @@ -1,14 +1,14 @@ --- name: reference_cloudkit_zone_dictionary -description: "CloudKit's Zone Dictionary has exactly three keys (zoneID, syncToken, atomic) — isEager and zone create options do not exist" +description: "CloudKit Zone Dictionary keys — archived docs plus live-confirmed extensions (issue #444)" metadata: node_type: memory type: reference --- -Verified against Apple's archived CloudKit Web Services Reference during issue #386 / PR #427. +Verified against Apple's archived CloudKit Web Services Reference during issue #386 / PR #427, with live-response confirmation for additional keys in issue #444. -**Zone Dictionary documents exactly three keys** ([Types.html](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html)): +**Zone Dictionary — archived docs** ([Types.html](https://developer.apple.com/library/archive/documentation/DataManagement/Conceptual/CloudKitWebServicesReference/Types.html)): | Key | Apple's wording | |-----|-----------------| @@ -16,7 +16,24 @@ Verified against Apple's archived CloudKit Web Services Reference during issue # | `syncToken` | "The current point in the zone's change history." | | `atomic` | "A Boolean value indicating whether this zone supports atomic operations." | -All four zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`) route their **success** payload through this dictionary, and their **failure** payload through the "Zone Fetch Error Dictionary" (`zoneID`, `reason`, `serverErrorCode`, `retryAfter`, `redirectURL`). +**Live-confirmed additions (issue #444)** — present on change feeds (`zones/changes`, `changes/database`) but absent from the archived Zone Dictionary page: + +| Key | Location | Notes | +|-----|----------|-------| +| `deleted` | Zone object (not inside `zoneID`) | `true` = tombstone; sync clients must observe this | +| `zoneType` | Inside `zoneID` | ``ZoneType`` — `DEFAULT_ZONE` or `REGULAR_CUSTOM_ZONE`; key optional | + +**Zone ID Dictionary** — wire key is `ownerRecordName`, **not** `ownerName`: + +| Key | Description | +|-----|-------------| +| `zoneName` | Required. Default `_defaultZone`. | +| `ownerRecordName` | Zone owner's user record name (shared zones). | +| `zoneType` | Optional. ``ZoneType`` (`DEFAULT_ZONE` / `REGULAR_CUSTOM_ZONE`); unrecognized values throw at conversion. | + +MistKit's domain `ZoneID` keeps the Swift property name `ownerName`; only the wire key is `ownerRecordName`. + +All four zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`) route their **success** payload through the Zone dictionary, and their **failure** payload through the "Zone Fetch Error Dictionary" (`zoneID`, `reason`, `serverErrorCode`, `retryAfter`, `redirectURL`). **Things that do NOT exist — do not add them speculatively:** @@ -24,9 +41,9 @@ All four zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/ch - **`atomic` on the `zones/modify` request** — the request body is `operations` only. `records/modify` *does* document `atomic`; the asymmetry is deliberate. - **Zone create options on `ZoneOperation`** — the operation's `zone` is documented as having "a single `zoneID` key". -**Open discrepancies (unresolved, see issue #386 comment):** +**Resolved discrepancies:** -- `zones/changes` documents its token key as **`metaSyncToken`** in both request and response; MistKit sends/reads `syncToken`. Apple's page contradicts itself (the `moreComing` description refers back to "the included `syncToken` key"), so this needs a live-response check before changing. -- `zones/changes` is documented as **deprecated** in favor of `changes/database`. +- `zones/changes` token key is `metaSyncToken` on the wire (issue #430); Swift-facing names unchanged. +- `ownerRecordName` is the wire key for the zone owner (issue #444); the mistaken `ownerName` key never decoded live responses. -Note `ZoneID`'s owner key: Apple documents `ownerRecordName`, while MistKit's `ZoneID` domain type calls it `ownerName` and the wire schema uses `ownerName`. Related: [[reference_cloudkit_archived_endpoints]]. +Note `zones/changes` is documented as **deprecated** in favor of `changes/database`. Related: [[reference_cloudkit_archived_endpoints]]. diff --git a/.claude/memory/reference_configkey_cli_flag_dash_case.md b/.claude/memory/reference_configkey_cli_flag_dash_case.md new file mode 100644 index 000000000..78749b6e9 --- /dev/null +++ b/.claude/memory/reference_configkey_cli_flag_dash_case.md @@ -0,0 +1,38 @@ +--- +name: reference-configkey-cli-flag-dash-case +description: "ConfigKeyKit ConfigKey bases must use dash-case, not snake_case: CLIKeyEncoder joins components verbatim, so an underscore in a base silently produces an unusable CLI flag (ENV is unaffected)." +metadata: + type: reference +--- + +A ConfigKeyKit `ConfigKey`/`OptionalConfigKey` base string must separate words +with **dashes**, not underscores: `"cloudkit.key-id"`, never `"cloudkit.key_id"`. + +**Why:** the two sources normalize differently, and only one is forgiving. + +| base | ENV name | CLI flag | +|---|---|---| +| `cloudkit.key-id` | `CLOUDKIT_KEY-ID` → resolves from `CLOUDKIT_KEY_ID` | `--cloudkit-key-id` ✅ | +| `cloudkit.key_id` | `CLOUDKIT_KEY_ID` ✅ | `--cloudkit-key_id` ❌ | + +- swift-configuration's `EnvironmentVariablesProvider` normalizes **both** `-` + and `.` to `_` when encoding, so either base spelling resolves the same + `CLOUDKIT_*` variable. ENV is not a signal that the base is correct. +- `CLIKeyEncoder.encode` joins the key's components with `-` **verbatim**, so an + underscore inside a component survives into the flag. ConfigKeyKit's + `StandardNamingStyle.dotSeparated` returns the base unchanged, so it does not + intervene. + +**How to apply:** when adding a config key, spell the base in dash-case. Two +symptoms of getting it wrong, both silent — nothing fails at build time and ENV +keeps working: +1. The documented `--flag-name` never resolves; only the undocumented + `--flag_name` does. +2. `CommandLineArgumentsProvider(secretsSpecifier: .specific([...]))` entries are + matched against the *generated* flag, so a mismatch means the value is never + marked secret — a private key passed by flag is logged unredacted. + +Found in BushelCloud (fixed in #407 PR 1); CelestraCloud was already correct. +Verified empirically against swift-configuration 1.2.0, not inferred from source. + +Related: [[reference-configkeykit-configvaluereading]] diff --git a/.claude/memory/reference_configkeykit_configvaluereading.md b/.claude/memory/reference_configkeykit_configvaluereading.md new file mode 100644 index 000000000..35b8c9dc0 --- /dev/null +++ b/.claude/memory/reference_configkeykit_configvaluereading.md @@ -0,0 +1,34 @@ +--- +name: reference-configkeykit-configvaluereading +description: "ConfigKeyKit#1 shipped the swift-configuration bridge as the in-core ConfigValueReading protocol (1.0.0-beta.2), NOT as a separate ConfigKeyKitConfiguration package." +metadata: + type: reference +--- + +ConfigKeyKit issue #1 ("Remove Need for Extension") was resolved by putting the +`read(_:)` resolution on a protocol in the **dependency-free core**, not by +shipping the separate `ConfigKeyKitConfiguration` product its comments proposed. + +- `Sources/ConfigKeyKit/ConfigValueReading.swift`, tagged `1.0.0-beta.2`. +- The core stays Foundation-only; consumers supply a ~3-line conformance: + ```swift + extension ConfigReader: @retroactive ConfigValueReading { + public func makeConfigKey(_ s: String) -> Configuration.ConfigKey { .init(s) } + } + ``` + `Configuration` must be imported **publicly** for that conformance to compile. +- It supplies `read()` for `ConfigKey`, + `OptionalConfigKey`, plus `read(_:parsing:)`. +- Resolution order is `sourcePriority` (default `[.commandLine, .environment]`), + not `ConfigKeySource.allCases`. + +**There is no `ConfigKeyKitConfiguration` package** — do not look for one, and +treat any issue text that assumes it (e.g. MistKit #407's dependency diagram and +its "blocked by ConfigKeyKit#1" sequencing) as stale. The prefix-factory idea +(`ConfigKeySet(envPrefix:)`) from that thread also did not ship; `envPrefix` +remains a per-key initializer parameter. + +Adopted in BushelCloud + CelestraCloud in #407 PR 1, deleting ~180 lines of +hand-rolled overloads that were character-for-character identical between them. + +Related: [[reference-configkey-cli-flag-dash-case]] diff --git a/.claude/memory/reference_draft_gated_ci_needs_ready_for_review.md b/.claude/memory/reference_draft_gated_ci_needs_ready_for_review.md new file mode 100644 index 000000000..fba644e3e --- /dev/null +++ b/.claude/memory/reference_draft_gated_ci_needs_ready_for_review.md @@ -0,0 +1,34 @@ +--- +name: reference-draft-gated-ci-needs-ready-for-review +description: "A workflow job gated on `draft == false` needs `types: [..., ready_for_review]`, or it stays skipped forever and never guards the transition it exists for." +metadata: + type: reference +--- + +A job guarded by `if: github.event.pull_request.draft == false` evaluates that +condition against **the payload of the event that queued the run**, not against +the PR's state at read time. + +`on: pull_request` without an explicit `types:` defaults to +`[opened, synchronize, reopened]` — **none of which fire when a draft is marked +ready for review**. So a PR opened as a draft keeps its draft-time evaluation +and reports `skipped` indefinitely. + +Two things that do *not* fix it: + +- **Re-running the workflow** — a re-run replays the original event payload, so + the condition re-evaluates to the same stale `draft: true`. +- **Toggling draft → ready → draft** — that emits `ready_for_review` / + `converted_to_draft`, which the default `types:` ignores. + +The failure mode is silent and inverted: the gate shows `skipped`, never +`failure`, so a PR can go draft → merged with exactly the dependencies the gate +exists to reject. Observed on `brightdigit/MistKitConfiguration` +`dependency-policy.yml` (2026-08-31, PR #1); fixed by adding +`types: [opened, synchronize, reopened, ready_for_review]`. + +**Apply:** any `draft == false` gate must list `ready_for_review` in `types:`. +When a required check reads `skipping` on a PR that is *not* a draft, treat it +as a broken gate, not a pass — verify the check's logic locally before merging. + +Related: [[project_mistkitconfiguration_subrepo_overlay]] diff --git a/.claude/memory/reference_git_trees_add_bases_on_main.md b/.claude/memory/reference_git_trees_add_bases_on_main.md new file mode 100644 index 000000000..c7062e833 --- /dev/null +++ b/.claude/memory/reference_git_trees_add_bases_on_main.md @@ -0,0 +1,24 @@ +--- +name: reference_git_trees_add_bases_on_main +description: "git trees add branches from main regardless of which worktree you run it in; reset onto the intended base immediately after" +metadata: + node_type: memory + type: reference +--- + +`git trees add ` creates the new branch from **`main`**, not from the +branch of the worktree you invoke it in. It also pushes that `main`-based commit +to `origin/` right away (unless `--no-push`). + +**Why:** Running `git trees add claude-docs-consolidation` from the +`v1.0.0-beta.5` worktree produced a tree at `main` (687b532) — 15 commits behind, +with 23 files of `.claude/` drift. The intended base was 3708e09. Nothing warns +about this; the output just says "Preparing worktree (new branch …)" and the +drift is silent until you diff. + +**How to apply:** After `git trees add` from a non-`main` base, immediately +`git reset --hard origin/` and confirm with +`git log --oneline HEAD..origin/ | wc -l` (want 0). Because the +stale commit is already on the remote, the first real push needs +`--force-with-lease`. Related: [[feedback_use_git_trees_not_git_worktree]], +[[project_beta4_worktree_layout]]. diff --git a/.claude/memory/reference_windows_62_mistkittests_emit_abort.md b/.claude/memory/reference_windows_62_mistkittests_emit_abort.md new file mode 100644 index 000000000..513796739 --- /dev/null +++ b/.claude/memory/reference_windows_62_mistkittests_emit_abort.md @@ -0,0 +1,11 @@ +--- +name: windows-6.2-mistkittests-emit-abort +description: Swift 6.2 Windows silently aborts emitting MistKitTests; gate tip-over test bodies with #if + Issue.record +metadata: + node_type: memory + type: reference +--- + +On Windows + Swift **6.2 only**, `swift build --build-tests` can die with exit 1 and **no** `error:`/stack dump after compiling `MistKitTests` — and **never** print `Emitting module MistKitTests`. Same commit is green on Windows 6.1/6.3; main’s Windows 6.2 emits successfully. Reproducible. + +Tip-over is MistKitTests size/complexity — converting `WebAuthTokenManager` actor→class did **not** fix it. Mitigation: omit tip-over **test bodies** at compile time with `#if !(os(Windows) && compiler(>=6.2) && compiler(<6.3))` / `#else Issue.record`, keep `@Test`/`@Suite`/mocks compiled, and `.disabled(if: Platform.isWindowsSwift62)` for runtime. Do not use `.disabled(if:)` alone — that still compiles. See `.claude/docs/research/windows-6.2-ci-failure-462.md`. diff --git a/.claude/skills/fix-lint/SKILL.md b/.claude/skills/fix-lint/SKILL.md new file mode 100644 index 000000000..5a0a4b8aa --- /dev/null +++ b/.claude/skills/fix-lint/SKILL.md @@ -0,0 +1,58 @@ +--- +name: fix-lint +description: >- + Triage and fix MistKit lint findings from ./Scripts/lint.sh (swift-format, + SwiftLint, swift build, Periphery). Use when the user reports lint errors or + warnings, asks to fix lint, or before commit/push when lint must be clean. +--- + +# Fix Lint + +## Success criteria + +`./Scripts/lint.sh` exits **0** and `summary.totalFindings == 0`. Any warning or error from any lint tool is blocking — all four tools run with `--strict`. + +## Discover findings + +```bash +LINT_REPORT=1 ./Scripts/lint.sh +``` + +`lint.sh` invokes [scripts/compile-lint-report.py](scripts/compile-lint-report.py) in report mode to produce: + +- Human summary on **stderr** +- JSON between `### MISTKIT_LINT_REPORT_BEGIN ###` / `### MISTKIT_LINT_REPORT_END ###` on stdout + +Read `summary.totalFindings`, `summary.failedSteps`, and each tool's `findings[]` (`file`, `line`, `rule`, `message`, `severity`). + +Report mode is **read-only** (no auto-format or `swiftlint --fix`). + +## Fix loop + +1. Group findings by tool, then file. +2. Apply fixes (see table below). +3. Re-run `./Scripts/lint.sh` or `LINT_REPORT=1 ./Scripts/lint.sh` until clean. +4. Run targeted `swift test --filter …` for touched areas. + +## Tool-specific guidance + +| Tool | Typical fix | +|------|-------------| +| **swift-format** | Edit source to match rule (e.g. `for-in` over `.forEach`). Outside report mode, `lint.sh` auto-formats first. | +| **SwiftLint** | Fix the code; use `// swiftlint:disable:next ` only for documented intentional exceptions. Constant URLs can use `guard let` + `preconditionFailure` instead of `!`. | +| **Periphery** | Remove dead code — do not add no-op usages to silence. | +| **swift build** | Fix compiler errors from the build step log. | + +## Constraints + +- Run swift-format, SwiftLint, periphery, and swift-openapi-generator through **mise** (`mise exec -- …`) when invoking outside `lint.sh`. +- Do not manually edit `Sources/MistKitOpenAPI/` — regenerate from `openapi.yaml`. +- Periphery needs a prior `swift build --build-tests` index store under `.build/`; `lint.sh` handles this ordering. + +## Verify + +```bash +./Scripts/lint.sh +LINT_REPORT=1 ./Scripts/lint.sh # confirm totalFindings: 0 +swift test --filter +``` diff --git a/.claude/skills/fix-lint/scripts/compile-lint-report.py b/.claude/skills/fix-lint/scripts/compile-lint-report.py new file mode 100755 index 000000000..f486ccb40 --- /dev/null +++ b/.claude/skills/fix-lint/scripts/compile-lint-report.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Compile lint tool outputs into a unified MistKit lint report.""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path +from typing import Any + +SWIFT_FORMAT_RE = re.compile( + r"^(?P[^:]+):(?P\d+):(?P\d+): " + r"(?Pwarning|error): \[(?P[^\]]+)\] (?P.*)$" +) + +SWIFT_BUILD_RE = re.compile( + r"^(?P[^:]+):(?P\d+):(?P\d+): " + r"(?Pwarning|error): (?P.*)$" +) + + +def read_text(path: Path | None) -> str: + if path is None or not path.is_file(): + return "" + return path.read_text(encoding="utf-8", errors="replace") + + +def read_json(path: Path | None) -> Any: + if path is None or not path.is_file(): + return None + text = path.read_text(encoding="utf-8", errors="replace").strip() + if not text: + return None + return json.loads(text) + + +def parse_swift_format(text: str) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + for line in text.splitlines(): + match = SWIFT_FORMAT_RE.match(line.strip()) + if not match: + continue + findings.append( + { + "file": match.group("file"), + "line": int(match.group("line")), + "column": int(match.group("column")), + "severity": match.group("severity"), + "rule": match.group("rule"), + "message": match.group("message"), + } + ) + return findings + + +def parse_swift_build(text: str) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + for line in text.splitlines(): + stripped = line.strip() + if stripped.startswith("[") or ": warning:" not in stripped and ": error:" not in stripped: + continue + match = SWIFT_BUILD_RE.match(stripped) + if not match: + continue + findings.append( + { + "file": match.group("file"), + "line": int(match.group("line")), + "column": int(match.group("column")), + "severity": match.group("severity"), + "rule": "compiler", + "message": match.group("message"), + } + ) + return findings + + +def normalize_swiftlint(raw: list[dict[str, Any]] | None) -> tuple[list[dict[str, Any]], int]: + if not raw: + return [], 0 + findings: list[dict[str, Any]] = [] + for violation in raw: + severity = str(violation.get("severity", "")).lower() + findings.append( + { + "file": violation.get("file", ""), + "line": violation.get("line"), + "column": violation.get("character"), + "severity": severity, + "rule": violation.get("rule_id", ""), + "message": violation.get("reason", ""), + "type": violation.get("type"), + } + ) + serious_count = sum(1 for finding in findings if finding["severity"] == "error") + return findings, serious_count + + +def normalize_periphery(raw: list[dict[str, Any]] | None) -> list[dict[str, Any]]: + if not raw: + return [] + findings: list[dict[str, Any]] = [] + for item in raw: + location = item.get("location", "") + file_path = location + line: int | None = None + column: int | None = None + if location.count(":") >= 2: + file_path, line_text, column_text = location.rsplit(":", 2) + line = int(line_text) + column = int(column_text) + hints = item.get("hints", []) + rule = hints[0] if hints else "unused" + kind = item.get("kind", "symbol") + name = item.get("name", "") + findings.append( + { + "file": file_path, + "line": line, + "column": column, + "severity": "warning", + "rule": rule, + "message": f"Unused {kind} '{name}'", + } + ) + return findings + + +def tool_section( + *, + skipped: bool, + skip_reason: str | None, + exit_code: int | None, + findings: list[dict[str, Any]], + extra: dict[str, Any] | None = None, +) -> dict[str, Any]: + section: dict[str, Any] = { + "skipped": skipped, + "exitCode": exit_code, + "findingCount": len(findings), + "findings": findings, + } + if skip_reason: + section["skipReason"] = skip_reason + if extra: + section.update(extra) + return section + + +def human_summary(report: dict[str, Any]) -> str: + lines = ["=== MistKit lint report ==="] + for tool_name, tool in report["tools"].items(): + label = tool_name.replace("-", " ") + if tool["skipped"]: + reason = tool.get("skipReason", "skipped") + lines.append(f"{label}: skipped ({reason})") + continue + count = tool["findingCount"] + suffix = "" + if tool_name == "swiftlint" and "seriousCount" in tool: + suffix = f" ({tool['seriousCount']} serious)" + elif tool_name == "swift-build": + errors = sum(1 for f in tool["findings"] if f["severity"] == "error") + warnings = sum(1 for f in tool["findings"] if f["severity"] == "warning") + if errors or warnings: + suffix = f" ({errors} errors, {warnings} warnings)" + noun = "finding" if count == 1 else "findings" + lines.append(f"{label}: {count} {noun}{suffix}") + lines.append("---") + lines.append(f"total: {report['summary']['totalFindings']} findings") + failed_steps = report["summary"]["failedSteps"] + if failed_steps: + lines.append(f"failed steps: {', '.join(failed_steps)}") + else: + lines.append("failed steps: none") + return "\n".join(lines) + + +def main() -> int: + if len(sys.argv) != 2: + print("usage: compile-lint-report.py ", file=sys.stderr) + return 2 + + manifest = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) + report_dir = Path(manifest["reportDir"]) + + swift_format_findings = parse_swift_format( + read_text(report_dir / "swift-format.log") + ) + swiftlint_raw = read_json(report_dir / "swiftlint.json") + swiftlint_findings, serious_count = normalize_swiftlint(swiftlint_raw) + swift_build_findings = parse_swift_build(read_text(report_dir / "swift-build.log")) + periphery_findings = normalize_periphery(read_json(report_dir / "periphery.json")) + + tools: dict[str, Any] = { + "swift-format": tool_section( + skipped=False, + skip_reason=None, + exit_code=manifest["steps"].get("swift-format", {}).get("exitCode"), + findings=swift_format_findings, + ), + "swiftlint": tool_section( + skipped=manifest["steps"].get("swiftlint", {}).get("skipped", False), + skip_reason=manifest["steps"].get("swiftlint", {}).get("skipReason"), + exit_code=manifest["steps"].get("swiftlint", {}).get("exitCode"), + findings=swiftlint_findings, + extra={"seriousCount": serious_count}, + ), + "swift-build": tool_section( + skipped=manifest["steps"].get("swift-build", {}).get("skipped", False), + skip_reason=manifest["steps"].get("swift-build", {}).get("skipReason"), + exit_code=manifest["steps"].get("swift-build", {}).get("exitCode"), + findings=swift_build_findings, + ), + "periphery": tool_section( + skipped=manifest["steps"].get("periphery", {}).get("skipped", False), + skip_reason=manifest["steps"].get("periphery", {}).get("skipReason"), + exit_code=manifest["steps"].get("periphery", {}).get("exitCode"), + findings=periphery_findings, + ), + } + + total_findings = sum(tool["findingCount"] for tool in tools.values()) + failed_steps = manifest.get("failedSteps", []) + + report = { + "summary": { + "totalFindings": total_findings, + "failedSteps": failed_steps, + "failedStepCount": len(failed_steps), + }, + "tools": tools, + } + + output_format = manifest.get("outputFormat", "json") + json_text = json.dumps(report, indent=2) + + if output_format in {"both", "summary"}: + print(human_summary(report), file=sys.stderr) + + print("### MISTKIT_LINT_REPORT_BEGIN ###") + print(json_text) + print("### MISTKIT_LINT_REPORT_END ###") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/.claude/skills/release b/.claude/skills/release new file mode 120000 index 000000000..14f8a38a8 --- /dev/null +++ b/.claude/skills/release @@ -0,0 +1 @@ +../../.agents/skills/release \ No newline at end of file diff --git a/.github/workflows/MistDemo-Integration.yml b/.github/workflows/MistDemo-Integration.yml index 23c507dbf..a153196fc 100644 --- a/.github/workflows/MistDemo-Integration.yml +++ b/.github/workflows/MistDemo-Integration.yml @@ -19,9 +19,12 @@ # after rotating, then re-run via workflow_dispatch. # # Two-job design: -# 1) `build` runs in `swift:6.3-noble`, installs the Swift Static -# Linux SDK (musl), and produces a self-contained statically -# linked mistdemo binary. +# 1) `build` runs in `swiftlang/swift:nightly-6.4.x-noble` (MistDemo +# declares swift-tools-version: 6.4; no 6.4 RELEASE image yet), +# installs the matching 6.4.x-branch Static Linux SDK (musl), and +# produces a self-contained statically linked mistdemo binary. +# Bump container + SDK URL/checksum together; switch to a 6.4 +# RELEASE pin when Apple ships one. # 2) `integration` runs on plain `ubuntu-24.04` (no Swift toolchain, # no LD_LIBRARY_PATH needed) and executes the live CloudKit # phases against the static binary. @@ -46,9 +49,9 @@ jobs: build: name: Build static mistdemo runs-on: ubuntu-24.04 - # Pin to an exact Swift patch — the Static Linux SDK URL + checksum - # below are tied to this same version. Bump together. - container: swift:6.3.2-noble + # Pin nightly container + 6.4.x-branch Static Linux SDK together. + # Snapshot URL/checksum match SyndiKit-style 6.4.x-branch SDK pins. + container: swiftlang/swift:nightly-6.4.x-noble if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} timeout-minutes: 45 defaults: @@ -61,8 +64,8 @@ jobs: run: | set -euo pipefail swift sdk install \ - https://download.swift.org/swift-6.3.2-release/static-sdk/swift-6.3.2-RELEASE/swift-6.3.2-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz \ - --checksum 3fd798bef6f4408f1ea5a6f94ce4d4052830c4326ab85ebc04f983f01b3da407 + https://download.swift.org/swift-6.4.x-branch/static-sdk/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-09-01-a/swift-6.4.x-DEVELOPMENT-SNAPSHOT-2026-09-01-a_static-linux-0.1.0.artifactbundle.tar.gz \ + --checksum 68a0c0345f715c4272eda1ef79d53e86c0b19a5600bc678ef48a641b22538f85 swift sdk list - name: swift build --swift-sdk x86_64-swift-linux-musl -c release @@ -73,7 +76,19 @@ jobs: working-directory: Examples/MistDemo run: | set -euo pipefail - BIN=.build/x86_64-swift-linux-musl/release/mistdemo + # Ask SwiftPM where it put the binary rather than hardcoding the + # layout - the cross-compiled output directory is toolchain + # dependent and has moved between releases. + BIN_DIR=$(swift build -c release --swift-sdk x86_64-swift-linux-musl --show-bin-path) + BIN="$BIN_DIR/mistdemo" + if [ ! -f "$BIN" ]; then + echo "::error::mistdemo not found at $BIN" + echo "Contents of $BIN_DIR:" + ls -la "$BIN_DIR" || true + echo "Executables under .build:" + find .build -type f -name mistdemo || true + exit 1 + fi ls -lh "$BIN" # A statically linked binary either reports "not a dynamic # executable" (musl) or "statically linked" (glibc). Anything @@ -84,11 +99,15 @@ jobs: echo "::error::Binary has dynamic dependencies; static link failed" exit 1 fi + # Stage at a fixed path so the upload step does not have to know + # the toolchain-specific build layout. + mkdir -p "$RUNNER_TEMP/artifact" + cp "$BIN" "$RUNNER_TEMP/artifact/mistdemo" - uses: actions/upload-artifact@v4 with: name: mistdemo - path: Examples/MistDemo/.build/x86_64-swift-linux-musl/release/mistdemo + path: ${{ runner.temp }}/artifact/mistdemo retention-days: 1 # Single executable; default zip compression is fine. diff --git a/.github/workflows/MistDemo.yml b/.github/workflows/MistDemo.yml index 932a59bf6..be8e74f61 100644 --- a/.github/workflows/MistDemo.yml +++ b/.github/workflows/MistDemo.yml @@ -60,17 +60,20 @@ jobs: - id: matrix name: Build matrix values run: | - # MistDemo's Package.swift declares swift-tools-version: 6.2, - # so Swift 6.1 is not supported. + # MistDemo declares swift-tools-version: 6.4 (MistKitConfiguration). + # Swift 6.4 has no release toolchain yet, so only the nightly image can + # parse the manifest — no 6.2/6.3 lanes (same constraint as + # Packages/MistKitConfiguration and examples.yml). + SWIFT='[{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" - echo 'ubuntu-swift=[{"version":"6.2"},{"version":"6.3"},{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' >> "$GITHUB_OUTPUT" - echo 'ubuntu-type=["","wasm","wasm-embedded"]' >> "$GITHUB_OUTPUT" + # Wasm SDK snapshots are not published for the 6.4 nightly — SPM only. + echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" else echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" - echo 'ubuntu-swift=[{"version":"6.3"}]' >> "$GITHUB_OUTPUT" echo 'ubuntu-type=[""]' >> "$GITHUB_OUTPUT" fi + echo "ubuntu-swift=$SWIFT" >> "$GITHUB_OUTPUT" build-ubuntu: name: Build on Ubuntu @@ -130,24 +133,21 @@ jobs: token: ${{ secrets.CODECOV_TOKEN }} files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + # Windows: no Swift 6.4 release toolchain yet. MistDemo → MistKitConfiguration + # needs 6.4; re-enable when a windows-*-release 6.4 build exists (see MKC CI). build-windows: name: Build on Windows needs: configure runs-on: ${{ matrix.runs-on }} - if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + if: ${{ false && needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: runs-on: [windows-2022, windows-2025] - # MistDemo's Package.swift declares swift-tools-version: 6.2, - # so Swift 6.1 is not supported. - # TODO: re-add swift-6.2-release once the swift-testing 6.2 + - # Windows parallel-runner crash is resolved (test process exits - # 1 with no diagnostic output after fanning out parallel tests; - # 6.3 is unaffected). + # Re-enable when a windows-*-release 6.4 toolchain exists. swift: - - version: swift-6.3-release - build: 6.3-RELEASE + - version: swift-6.4-release + build: 6.4-RELEASE steps: - uses: actions/checkout@v6 - uses: brightdigit/swift-build@v1 @@ -167,19 +167,18 @@ jobs: os: windows swift_project: MistDemo + # Android: same 6.4 gate as Windows — SDK lanes are 6.2/6.3 only today. build-android: name: Build on Android needs: configure runs-on: ubuntu-latest - if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + if: ${{ false && needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} strategy: fail-fast: false matrix: - # MistDemo's Package.swift declares swift-tools-version: 6.2, - # so Swift 6.1 is not supported. + # Re-enable when an Android Swift 6.4 SDK lane exists. swift: - - version: "6.2" - - version: "6.3" + - version: "6.4" android-api-level: [33, 34] steps: - uses: actions/checkout@v6 @@ -206,7 +205,7 @@ jobs: # Minimal macOS builds — always runs (SPM + iOS) build-macos: name: Build on macOS - runs-on: macos-26 + runs-on: ${{ matrix.runs-on }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} # Forward CI=true into the iOS simulator's test-runner process so # `TestPlatform.isFlakyTimeoutSimulator` tolerates the cooperative-executor @@ -217,14 +216,16 @@ jobs: fail-fast: false matrix: include: - # SPM build - - xcode: "/Applications/Xcode_26.6.app" + # MistKitConfiguration is swift-tools-version: 6.4 — Xcode 26.6 cannot + # parse it. Same xcode-27-only gate as Packages/MistKitConfiguration CI. + - runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" - # iOS build - type: ios - xcode: "/Applications/Xcode_26.6.app" + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" deviceName: "iPhone 17 Pro" - osVersion: "26.5" + osVersion: "27.0" download-platform: true steps: - uses: actions/checkout@v6 @@ -274,47 +275,21 @@ jobs: fail-fast: false matrix: include: - # macOS - - type: macos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.6.app" - - # watchOS - - type: watchos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.6.app" - deviceName: "Apple Watch Ultra 3 (49mm)" - osVersion: "26.5" - download-platform: true - - # tvOS - - type: tvos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.6.app" - deviceName: "Apple TV" - osVersion: "26.5" - download-platform: true - - # visionOS - - type: visionos - runs-on: macos-26 - xcode: "/Applications/Xcode_26.6.app" - deviceName: "Apple Vision Pro" - osVersion: "26.5" - download-platform: true - - # ── Xcode 27 (preview image, beta toolchain) ───────────────────── + # ── Xcode 27 only (MistKitConfiguration requires Swift 6.4) ────── + # macos-26 / Xcode 26.6 cannot parse mistkitconfiguration's + # swift-tools-version: 6.4. Same gate as MistKitConfiguration CI. + # # `runs-on: xcode-27` is its own image label, not a macos-NN one, and # is marked Preview in actions/runner-images. It ships exactly one # Xcode — 27.0 beta — so there is no 26.x fallback on this runner. # # Pin the /Applications/Xcode_27.0.app symlink, NOT the real - # Xcode_27_beta_4.app path: the beta number changes on every image + # Xcode_27_beta_N.app path: the beta number changes on every image # refresh and would silently break these lanes. # # tvOS uses "Apple TV 4K (3rd generation)" — this image has no plain - # "Apple TV" device, unlike macos-26 above. A wrong device name fails - # the simulator boot outright. + # "Apple TV" device. A wrong device name fails the simulator boot + # outright. - type: macos runs-on: xcode-27 xcode: "/Applications/Xcode_27.0.app" diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 0bf756018..e6ab02c08 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -2,7 +2,10 @@ name: Examples on: pull_request: - branches: [main] + # Match MistKit.yml full-matrix bases: main and semver release branches (e.g. v1.0.0-beta.5). + branches: + - main + - 'v*.*.*' concurrency: group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} @@ -15,8 +18,9 @@ jobs: test-examples: name: Test ${{ matrix.example }} on Ubuntu runs-on: ubuntu-latest - # BushelCloud / CelestraCloud declare swift-tools-version: 6.4; MistDemo is 6.2. - # Swift 6.4 has no release image yet — use the nightly (same pin as the subrepos). + # MistDemo / BushelCloud / CelestraCloud / MistKitConfiguration all declare + # swift-tools-version: 6.4. Swift 6.4 has no release image yet — use the + # nightly (same pin as the subrepos). container: ${{ matrix.container }} if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} strategy: @@ -24,10 +28,16 @@ jobs: matrix: include: - example: MistDemo - container: swift:6.3 + path: Examples/MistDemo + container: swiftlang/swift:nightly-6.4.x-noble - example: BushelCloud + path: Examples/BushelCloud container: swiftlang/swift:nightly-6.4.x-noble - example: CelestraCloud + path: Examples/CelestraCloud + container: swiftlang/swift:nightly-6.4.x-noble + - example: MistKitConfiguration + path: Packages/MistKitConfiguration container: swiftlang/swift:nightly-6.4.x-noble steps: @@ -37,4 +47,7 @@ jobs: - name: Build and Test ${{ matrix.example }} uses: brightdigit/swift-build@v1 with: - working-directory: Examples/${{ matrix.example }} + working-directory: ${{ matrix.path }} + # MistKitConfiguration gitignores Package.resolved (standalone CI uses the + # same flag). Examples that commit a lockfile keep the default. + skip-package-resolved: ${{ matrix.example == 'MistKitConfiguration' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 000000000..31d7dd04f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: Release Check + +# Verifies a release tag; deliberately does NOT create the GitHub release. +# Publishing stays a human step (Scripts/release.sh publish) so that +# `git push --tags` is not the point of no return. +on: + push: + tags: + - '[0-9]+.[0-9]+.[0-9]+*' + - 'v[0-9]*' + +concurrency: + group: release-check-${{ github.ref }} + cancel-in-progress: true + +jobs: + # MistKit branches are v-prefixed; MistKit tags are not. A v-prefixed tag is + # always a mistake, and catching it here makes the convention self-enforcing. + reject-v-prefix: + name: Reject v-prefixed tag + if: startsWith(github.ref_name, 'v') + runs-on: ubuntu-latest + steps: + - name: Fail + run: | + echo "::error::MistKit tags do not use a 'v' prefix." + echo "Branches are v-prefixed (v${GITHUB_REF_NAME#v}); tags are not (${GITHUB_REF_NAME#v})." + echo "Delete this tag: git push origin --delete ${GITHUB_REF_NAME}" + exit 1 + + verify: + name: Verify release tag + if: ${{ !startsWith(github.ref_name, 'v') }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + # Full history so the tag/main ancestry check can resolve. + fetch-depth: 0 + + - name: Verify tag + run: ./Scripts/release.sh verify-tag "${GITHUB_REF_NAME}" diff --git a/AGENTS.md b/AGENTS.md index 9255c7294..1b0ccd948 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,12 +135,12 @@ MistKit uses separate types for requests and responses at the OpenAPI schema lev **Request type tagging (issue #375):** Most request values omit `type` and let CloudKit infer it from the value structure. Three scalar types are ambiguous on the wire and **must** carry an explicit `type`, otherwise CloudKit infers the wrong type and rejects the write with `BAD_REQUEST`: - `TIMESTAMP` (`.date`) — a millisecond number, otherwise read as `INT64`/`DOUBLE` -- `BYTES` (`.bytes`) — a base64 string, otherwise read as `STRING` +- `BYTES` (`.bytes`) — domain `Data`, encoded as a base64 string on the wire, otherwise read as `STRING` - `DOUBLE` (`.double`) — a whole-valued double serializes without a fraction, otherwise read as `INT64` Object/array-shaped values (`REFERENCE`, `ASSET`, `LOCATION`, `LIST`) and `STRING`/`INT64` are unambiguous and stay untagged. Tagging happens in the exhaustive `init(from:)` switch (`Components.Schemas.FieldValueRequest.swift`). `type` is *not* required globally because CloudKit documents it as optional. -**Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case. `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`). +**Response type recovery (issue #375):** The generated `value` `oneOf` is *undiscriminated* — the decoder tries cases first-match-wins (`String → Int64 → Double → Bytes → Date`), so a whole-millisecond `TIMESTAMP` decodes as `Int64Value` and a base64 `BYTES` string decodes as `StringValue`. The response conversion therefore honors an explicit `type` *over* the decoded case (`makeTypedScalar` in `FieldValue+Components+Scalar.swift`). For the genuinely-ambiguous scalars whose correct interpretation differs from inference it produces the typed value directly: `TIMESTAMP`/`DOUBLE` from any numeric case, `BYTES` from any string case (decoded with `Data(base64Encoded:)`; malformed tagged base64 throws `ConversionError.typeValueMismatch` with the unwrapped string). `INT64`/`STRING` validate the category then defer to inference (which already yields them, and for `INT64` avoids truncating a fractional number). When `type` is absent it falls back to first-match-wins inference (`makeInferredScalar`), which is lossy for the ambiguous scalars (BYTES→`.string`, whole-number TIMESTAMP→`.int64`). Do **not** infer `.bytes` from untagged base64 — ordinary strings such as `"Chen"` decode as valid base64. Domain `.bytes` is `Data`; `bytesValue` re-encodes to base64 and `dataValue` matches `.bytes` only (no `.string` fallback). The generated `BytesValue` in `Sources/MistKitOpenAPI/` stays `String`. When a scalar `type` *contradicts* the value's category — a numeric type (`TIMESTAMP`/`DOUBLE`/`INT64`) over a non-number, or a string type (`STRING`/`BYTES`) over a non-string — the response is internally inconsistent and the conversion **throws** `ConversionError.typeValueMismatch` (via `requireNumeric`/`requireString`) rather than coercing to the value's shape. This matches the codebase's existing fail-loud `unmappableFieldValue` philosophy. @@ -358,6 +358,10 @@ Asset uploads use `URLSession.shared` directly rather than the injected `ClientT - `requestAssetUploadURL()` - Step 1: Get CDN upload URL → `Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift` - `uploadAssetData()` - Step 2: Upload binary data to CDN → `Sources/MistKit/CloudKitService/CloudKitService+AssetUpload.swift` +**Asset download — `fileChecksum` is NOT verifiable client-side (issues #466, #473):** `Asset.download(using:)` (`#if !os(WASI)`, macOS 12 / iOS 15+) GETs `downloadURL` via `URLSession` (default `.shared`, same CDN-vs-API pool split as uploads) and returns the bytes unverified: missing/invalid URL → `CloudKitError.missingAssetDownloadURL`, non-2xx → `httpError`. Check `Asset.size` if you need a guard against a truncated download. + +#466 originally asked for the bytes to be verified against `fileChecksum`, and #473 implemented that as SHA-256-of-plaintext compared as base64 then hex. **That is not what `fileChecksum` is, and the goal is not achievable.** Verified against a live container (`iCloud.com.brightdigit.MistDemo`/`development`): the value decodes to 21 bytes — a `0x01` version prefix plus a 20-byte digest — so it can never equal a 32-byte SHA-256, and `Asset.download` therefore threw on *every* genuine CloudKit asset (the MistDemo Integration job caught this; unit tests did not, because they built fixtures with the same formula they asserted against). It is minted server-side — MistKit reads it verbatim out of the CDN upload receipt (`CloudKitService+AssetUpload.swift`), the receipt embeds a fragment of it, and Apple's archived reference labels the field only `[SIGNATURE]` with no algorithm. It is deterministic and content-addressed (identical bytes → identical checksum; it doubles as the content address in `downloadURL` and is what `rereferenceAssets` echoes back), so treat it as an identity/caching token. ~1,500 candidate constructions over three byte-exact samples produced zero matches — full write-up in `.claude/docs/research/asset-filechecksum.md`. `Asset.matches(data:)`, `CloudKitError.assetChecksumMismatch`, `missingAssetChecksum` and `AssetChecksumTests` were **removed** rather than deprecated; do not reintroduce plaintext-digest verification. `referenceChecksum`/`wrappingKey` never appear in live responses (encrypted assets are unexercised) and are equally unusable for this. Tests: `AssetDownloadTests`. + **Future Consideration:** A `ClientTransport` extension could provide a generic upload method, but would need to: - Handle connection pooling separately for different hosts @@ -467,36 +471,22 @@ Key endpoints documented in the OpenAPI spec: ## Reference Documentation -Apple's official CloudKit documentation is available in `.claude/docs/` for offline reference during development: - -### When to Consult Each Document - -**webservices.md** (289 KB) - CloudKit Web Services REST API -- **Primary use**: Implementing REST API endpoints -- **Contains**: Authentication, request formats, all endpoints, data types, error codes -- **Consult when**: Writing API client code, handling authentication, debugging responses - -**cloudkitjs.md** (188 KB) - CloudKit JS Framework -- **Primary use**: Understanding CloudKit concepts and operation flows -- **Contains**: Container/database patterns, operations, response objects, error handling -- **Consult when**: Designing Swift types, implementing queries, working with subscriptions - -**testing-enablinganddisabling.md** (126 KB) - Swift Testing Framework -- **Primary use**: Writing modern Swift tests -- **Contains**: `@Test` macros, async testing, parameterization, migration from XCTest -- **Consult when**: Writing or organizing tests, testing async code +Offline copies of external documentation live in `.claude/docs/`. **See +[`.claude/docs/README.md`](.claude/docs/README.md) — it is the single router for +that directory**, listing every doc with its size and when to consult it. -**swift-openapi-generator.md** (235 KB) - Swift OpenAPI Generator Documentation -- **Primary use**: Understanding code generation configuration and features -- **Contains**: Generator configuration, type overrides, middleware system, transport protocols, API stability -- **Consult when**: Configuring openapi-generator-config.yaml, implementing middleware, troubleshooting generated code +Highlights: `webservices.md` is the authoritative CloudKit REST reference; +`swift-openapi-generator.md` and `swift-openapi-runtime.md` cover the generated +client in `Sources/MistKitOpenAPI/`; `QUICK_REFERENCE.md` is the fast lookup for +endpoint shapes, field types, and error codes. -See `.claude/docs/README.md` for detailed topic breakdowns and integration guidance. +Do not duplicate the per-document breakdown here — it drifts. Add new docs to +the router instead. ### MistDemo Documentation - **Swift Configuration Reference** (`.claude/docs/mistdemo/swift-configuration-reference.md`) - Guide for using Swift Configuration in MistDemo -- **Official Swift Configuration Docs** (`.claude/docs/https_-swiftpackageindex.com-apple-swift-configuration-1.0.0-documentation-configuration.md`) - Full API reference +- **Official Swift Configuration Docs** (`.claude/docs/swift-configuration.md`) - Full API reference ### CloudKit Schema Language @@ -554,6 +544,45 @@ The convention is not lint-enforced (SwiftLint has no rule for import visibility - type order is based on the default in swiftlint: https://realm.github.io/SwiftLint/type_contents_order.html - Anything inside [CONTENT] [/CONTENT] is written by me +## Release Process + +Full runbook: `.claude/skills/release/SKILL.md` (invoke as `/release`). Mechanical +checks live in `Scripts/release.sh`; `make release-preflight` / `release-check` wrap +the common ones. + +**Naming — the one rule to internalize:** + +```text +release branch v1.0.0-beta.5 ← with v +release tag 1.0.0-beta.5 ← without v +``` + +Tags are lightweight and unprefixed; branches are prefixed. `setup-mistkit` resolves +`MISTKIT_BRANCH` via `git ls-remote`, which matches **tags as well as branches**, so +pinning the wrong kind of ref succeeds silently and greens example CI without ever +compiling the code under release. The requirement inverts at release time: before the +merge the pin must be the **branch**, after publishing it must be the **tag**. Assert +with `./Scripts/release.sh pins --expect-branch v1.0.0-beta.5` before the merge and +`./Scripts/release.sh pins --expect-tag 1.0.0-beta.5` after publishing. + +**Three standing guardrails:** + +1. **Never tag without notes.** `./Scripts/release.sh verify-tag --at HEAD` must + pass before `git tag`. Both 1.0.0-beta.3 and 1.0.0-beta.4 were tagged with no + `ReleaseNotes.md` section of their own; the `Release Check` workflow re-asserts this + after any tag push. +2. **Archive before merging under squash.** Under squash-merge, squashed commits can + become unreachable from `main` — the pre-merge archive tag is the preservation + mechanism. Release branches themselves are retained after publication. +3. **Release notes are a flat bullet list for new entries.** No `###` category + subsections; sections from beta.1–beta.4 predate this and are left as they are. + +The release PR is the documented merge-commit case (feature PRs are always rebase or +squash), but confirm the shape with the human before merging. + +Worktrees are managed with `git trees` (`add` / `rm` / `list` / `clean`), never raw +`git worktree`. + ## Memory & Corrections Convention Versioned, in-repo agent memory is the source of truth for how to work in this repo. Read both stores at the start of every session before doing work: diff --git a/.claude/docs/data-sources-api-research.md b/Examples/BushelCloud/.claude/data-sources-api-research.md similarity index 100% rename from .claude/docs/data-sources-api-research.md rename to Examples/BushelCloud/.claude/data-sources-api-research.md diff --git a/.claude/docs/firmware-wiki.md b/Examples/BushelCloud/.claude/firmware-wiki.md similarity index 100% rename from .claude/docs/firmware-wiki.md rename to Examples/BushelCloud/.claude/firmware-wiki.md diff --git a/.claude/docs/mobileasset-wiki.md b/Examples/BushelCloud/.claude/mobileasset-wiki.md similarity index 100% rename from .claude/docs/mobileasset-wiki.md rename to Examples/BushelCloud/.claude/mobileasset-wiki.md diff --git a/Examples/BushelCloud/.env.example b/Examples/BushelCloud/.env.example index b89a06449..4d71a40a6 100644 --- a/Examples/BushelCloud/.env.example +++ b/Examples/BushelCloud/.env.example @@ -27,7 +27,7 @@ CLOUDKIT_DATABASE=public # Server-to-Server Key ID # Get this from: CloudKit Dashboard → API Access → Server-to-Server Keys -# Format: 32-character hexadecimal string +# Format: 64-character hexadecimal string CLOUDKIT_KEY_ID=your-key-id-here # Path to Private Key (.pem file) diff --git a/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md b/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md index b21183a17..4d4c5e28e 100644 --- a/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md +++ b/Examples/BushelCloud/.github/CLOUDKIT_SYNC_SETUP.md @@ -18,7 +18,7 @@ This document explains how to configure the scheduled CloudKit sync workflow. 4. Click **+** to create a new key 5. Download the `.pem` file immediately (you can only download it once) 6. Save the file securely (e.g., `~/Downloads/AuthKey_XXXXXXXXXX.pem`) -7. Note the **Key ID** (32-character hex string) +7. Note the **Key ID** (64-character hex string) ### 2. Add GitHub Secrets @@ -31,7 +31,7 @@ This document explains how to configure the scheduled CloudKit sync workflow. #### Add CLOUDKIT_KEY_ID - **Name:** `CLOUDKIT_KEY_ID` -- **Value:** Your 32-character key ID from step 1.7 +- **Value:** Your 64-character key ID from step 1.7 - Click **Add secret** #### Add CLOUDKIT_PRIVATE_KEY diff --git a/Examples/BushelCloud/.github/SECRETS_SETUP.md b/Examples/BushelCloud/.github/SECRETS_SETUP.md index 4eeb847c8..bcfac175b 100644 --- a/Examples/BushelCloud/.github/SECRETS_SETUP.md +++ b/Examples/BushelCloud/.github/SECRETS_SETUP.md @@ -11,7 +11,7 @@ Before adding secrets, you need a CloudKit Server-to-Server key: 3. Navigate to: **API Access → Server-to-Server Keys** 4. Click **+** to create a new key 5. Download the `.pem` file (you can only download once!) -6. Copy the Key ID (32-character hex string) +6. Copy the Key ID (64-character hex string) ## Required Secrets @@ -24,7 +24,7 @@ You need to add **2 secrets** to your GitHub repository: **Secret configuration:** - **Name:** `CLOUDKIT_KEY_ID` -- **Value:** Your 32-character key ID from CloudKit Dashboard +- **Value:** Your 64-character key ID from CloudKit Dashboard - **Example:** `a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6` ### 2. CLOUDKIT_PRIVATE_KEY @@ -90,7 +90,7 @@ If you see authentication errors, double-check: | Secret Name | Where to Get It | Format | |-------------|-----------------|--------| -| `CLOUDKIT_KEY_ID` | CloudKit Dashboard → API Access → Server-to-Server Keys | 32-character hex (e.g., `a1b2c3...`) | +| `CLOUDKIT_KEY_ID` | CloudKit Dashboard → API Access → Server-to-Server Keys | 64-character hex (e.g., `a1b2c3...`) | | `CLOUDKIT_PRIVATE_KEY` | Downloaded `.pem` file | Multi-line PEM format with headers | ## Next Steps diff --git a/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml b/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml index 83cbef3ba..63614d9ae 100644 --- a/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml +++ b/Examples/BushelCloud/.github/actions/cloudkit-sync/action.yml @@ -130,6 +130,10 @@ inputs: description: 'Run export after sync and generate reports' required: false default: 'true' + mistkit-branch: + description: 'MistKit ref to check out when falling back to a fresh build' + required: false + default: 'v1.0.0-beta.4' runs: using: "composite" @@ -145,6 +149,12 @@ runs: path: ./binary branch: ${{ github.ref_name }} + - name: Setup MistKit + if: steps.download-binary.outcome != 'success' + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ inputs.mistkit-branch }} + - name: Build binary (fallback if artifact unavailable) if: steps.download-binary.outcome != 'success' shell: bash diff --git a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml index cdd3307b4..266f077de 100644 --- a/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml +++ b/Examples/BushelCloud/.github/workflows/bushel-cloud-build.yml @@ -37,6 +37,11 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: v1.0.0-beta.4 + - name: Verify Swift version run: | swift --version diff --git a/Examples/BushelCloud/.gitrepo b/Examples/BushelCloud/.gitrepo index 1d334ddaf..5ca2cb191 100644 --- a/Examples/BushelCloud/.gitrepo +++ b/Examples/BushelCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/BushelCloud.git branch = mistkit - commit = 72a1a6825d9188c4a9e062c33bf15ff369e07a8c - parent = 9c5b292931b81e61ca219ef4f04cbdd0afd35013 + commit = cf22ab754e1294b2f4cb065c79008c29e000166d + parent = 690a370bb4489d3b8ca0f52b93b5b599a323a14b method = merge cmdver = 0.4.9 diff --git a/Examples/BushelCloud/CLAUDE.md b/Examples/BushelCloud/CLAUDE.md index 159677262..497b347a2 100644 --- a/Examples/BushelCloud/CLAUDE.md +++ b/Examples/BushelCloud/CLAUDE.md @@ -442,7 +442,7 @@ CloudKit enforces a **200 operations per request** limit. Operations are automat let batchSize = 200 for start in stride(from: 0, to: operations.count, by: batchSize) { let batch = Array(operations[start ..< min(start + batchSize, operations.count)]) - // MistKit (beta.3+) partitions results using a pre-computed classification and + // MistKit (beta.4+) partitions results using a pre-computed classification and // returns a structured batch result — no manual .success/.failure switching. let batchResult = try await service.modifyRecords( batch, @@ -620,13 +620,13 @@ Note: The `.xcodeproj` is in `.gitignore` - always regenerate rather than commit ## Dependencies -- **MistKit** (local path: `../MistKit`) - CloudKit Web Services client with S2S auth +- **MistKit** (1.0.0-beta.4+) - CloudKit Web Services client with S2S auth - **IPSWDownloads** - ipsw.me API wrapper for restore images - **SwiftSoup** - HTML parsing for web scraping - **ArgumentParser** - CLI framework - **swift-log** - Logging infrastructure -MistKit is the parent package; BushelCloud is an example in `Examples/Bushel/`. +BushelCloud consumes MistKit as a tagged Swift Package Manager dependency from GitHub. ## CI/CD and Code Quality @@ -707,3 +707,15 @@ For detailed guides on advanced topics, see: - Critical issues solved and lessons learned - Common pitfalls to avoid - Lessons for building future CloudKit demos + +- **[.claude/data-sources-api-research.md](.claude/data-sources-api-research.md)** - External data source research + - xcodereleases.com API structure + - swiftversion.net scraping approach + - MistKit API patterns used by the sync tool + +- **[.claude/firmware-wiki.md](.claude/firmware-wiki.md)** - IPSW/OTA firmware reference (TheAppleWiki) + - Firmware file formats and contents + - Background for IPSWFetcher and MESUFetcher + +- **[.claude/mobileasset-wiki.md](.claude/mobileasset-wiki.md)** - MobileAsset framework reference (TheAppleWiki) + - Asset catalog structure behind MESU endpoints diff --git a/Examples/BushelCloud/Package.resolved b/Examples/BushelCloud/Package.resolved index 265324b9d..42551b5f4 100644 --- a/Examples/BushelCloud/Package.resolved +++ b/Examples/BushelCloud/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "a54730827c3e3ca47018c9b427835bd627e21338b440ba0d3f3defc4f44ff94f", + "originHash" : "74ffa954d191ccc211069191b2e6aad419590228dbea160058cae815c9d9236d", "pins" : [ { "identity" : "bushelkit", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/ConfigKeyKit.git", "state" : { - "revision" : "6949abb4b7f3e50f7f81d668e6c11aa4721e97fd", - "version" : "1.0.0-beta.2" + "revision" : "3c8ae3825b4cdcbf60fb1adeaaaf324557e7fd41", + "version" : "1.0.0-beta.3" } }, { diff --git a/Examples/BushelCloud/Package.swift b/Examples/BushelCloud/Package.swift index 7e3553881..e3650ec36 100644 --- a/Examples/BushelCloud/Package.swift +++ b/Examples/BushelCloud/Package.swift @@ -5,76 +5,8 @@ import PackageDescription -// MARK: - Swift Settings Configuration - let swiftSettings: [SwiftSetting] = [ - // Swift 6.4 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking - // .unsafeFlags([ - // // Enable concurrency warnings - // "-warn-concurrency", - // // Enable actor data race checks - // "-enable-actor-data-race-checks", - // // Complete strict concurrency checking - // "-strict-concurrency=complete", - // // Enable testing support - // "-enable-testing", - // // Warn about functions with >100 lines - // "-Xfrontend", "-warn-long-function-bodies=100", - // // Warn about slow type checking expressions - // "-Xfrontend", "-warn-long-expression-type-checking=100" - // ]) ] let package = Package( @@ -96,7 +28,10 @@ let package = Package( // this is a tagged remote release; this one-line overlay is reapplied when // the branch is recreated from main (never merged, so it never conflicts). .package(name: "MistKit", path: "../.."), - .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), + // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once + // MistKitConfiguration is released. + .package(path: "../../Packages/MistKitConfiguration"), + .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.3"), .package(url: "https://github.com/brightdigit/BushelKit.git", from: "3.0.0-alpha.4"), .package(url: "https://github.com/brightdigit/IPSWDownloads.git", from: "1.0.0"), .package(url: "https://github.com/scinfu/SwiftSoup.git", from: "2.6.0"), @@ -112,6 +47,7 @@ let package = Package( dependencies: [ .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), .product(name: "BushelLogging", package: "BushelKit"), .product(name: "BushelFoundation", package: "BushelKit"), .product(name: "BushelUtilities", package: "BushelKit"), @@ -125,14 +61,16 @@ let package = Package( .executableTarget( name: "BushelCloudCLI", dependencies: [ - .target(name: "BushelCloudKit") + .target(name: "BushelCloudKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), ], swiftSettings: swiftSettings ), .testTarget( name: "BushelCloudKitTests", dependencies: [ - .target(name: "BushelCloudKit") + .target(name: "BushelCloudKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), ], swiftSettings: swiftSettings ) diff --git a/Examples/BushelCloud/README.md b/Examples/BushelCloud/README.md index 406c22b48..77d16f5ac 100644 --- a/Examples/BushelCloud/README.md +++ b/Examples/BushelCloud/README.md @@ -535,7 +535,7 @@ bushel-cloud sync ## Dependencies -- **MistKit** - CloudKit Web Services client (local path dependency) +- **MistKit** (1.0.0-beta.4+) - CloudKit Web Services client - **IPSWDownloads** - ipsw.me API wrapper - **SwiftSoup** - HTML parsing for web scraping - **ArgumentParser** - CLI argument parsing diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift index 8141509c4..cd57d9613 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ClearCommand.swift @@ -31,6 +31,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import BushelUtilities internal import Foundation +internal import MistKitConfiguration internal enum ClearCommand { internal static func run(_ args: [String]) async throws { @@ -59,21 +60,8 @@ internal enum ClearCommand { } } - // Determine authentication method - let authMethod: CloudKitAuthMethod - if let pemString = config.cloudKit.privateKey { - authMethod = .pemString(pemString) - } else { - authMethod = .pemFile(path: config.cloudKit.privateKeyPath) - } - // Create sync engine - let syncEngine = try SyncEngine( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - authMethod: authMethod, - environment: config.cloudKit.environment - ) + let syncEngine = try SyncEngine(cloudKit: config.cloudKit) // Execute clear do { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift index 8dc228755..985f2a985 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ExportCommand.swift @@ -30,6 +30,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import Foundation +internal import MistKitConfiguration internal import MistKit internal enum ExportCommand { @@ -70,21 +71,8 @@ internal enum ExportCommand { // Enable verbose console output if requested ConsoleOutput.isVerbose = config.export?.verbose ?? false - // Determine authentication method - let authMethod: CloudKitAuthMethod - if let pemString = config.cloudKit.privateKey { - authMethod = .pemString(pemString) - } else { - authMethod = .pemFile(path: config.cloudKit.privateKeyPath) - } - // Create sync engine - let syncEngine = try SyncEngine( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - authMethod: authMethod, - environment: config.cloudKit.environment - ) + let syncEngine = try SyncEngine(cloudKit: config.cloudKit) // Execute export do { diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift index 51b2aad8d..c6cf20069 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/ListCommand.swift @@ -30,6 +30,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import Foundation +internal import MistKitConfiguration internal import MistKit internal enum ListCommand { @@ -40,22 +41,7 @@ internal enum ListCommand { let config = try rawConfig.validated() // Create CloudKit service - let cloudKitService: BushelCloudKitService - if let pemString = config.cloudKit.privateKey { - cloudKitService = try BushelCloudKitService( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - pemString: pemString, - environment: config.cloudKit.environment - ) - } else { - cloudKitService = try BushelCloudKitService( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - privateKeyPath: config.cloudKit.privateKeyPath, - environment: config.cloudKit.environment - ) - } + let cloudKitService = try BushelCloudKitService(config.cloudKit) // Determine what to list based on flags let listConfig = config.list diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift index 1ae93ae35..60820cfff 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/StatusCommand.swift @@ -30,6 +30,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import Foundation +internal import MistKitConfiguration internal import MistKit internal enum StatusCommand { @@ -40,22 +41,7 @@ internal enum StatusCommand { let config = try rawConfig.validated() // Create CloudKit service - let cloudKitService: BushelCloudKitService - if let pemString = config.cloudKit.privateKey { - cloudKitService = try BushelCloudKitService( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - pemString: pemString, - environment: config.cloudKit.environment - ) - } else { - cloudKitService = try BushelCloudKitService( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - privateKeyPath: config.cloudKit.privateKeyPath, - environment: config.cloudKit.environment - ) - } + let cloudKitService = try BushelCloudKitService(config.cloudKit) // Load configuration to show intervals let configuration = config.fetch ?? FetchConfiguration.loadFromEnvironment() diff --git a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift index 776658906..84816d1dc 100644 --- a/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift +++ b/Examples/BushelCloud/Sources/BushelCloudCLI/Commands/SyncCommand.swift @@ -31,6 +31,7 @@ internal import BushelCloudKit internal import BushelFoundation internal import BushelUtilities internal import Foundation +internal import MistKitConfiguration internal enum SyncCommand { internal static func run(_ args: [String]) async throws { @@ -48,20 +49,9 @@ internal enum SyncCommand { // Get fetch configuration (already loaded by ConfigurationLoader) let fetchConfiguration = config.fetch ?? FetchConfiguration.loadFromEnvironment() - // Determine authentication method - let authMethod: CloudKitAuthMethod - if let pemString = config.cloudKit.privateKey { - authMethod = .pemString(pemString) - } else { - authMethod = .pemFile(path: config.cloudKit.privateKeyPath) - } - // Create sync engine let syncEngine = try SyncEngine( - containerIdentifier: config.cloudKit.containerID, - keyID: config.cloudKit.keyID, - authMethod: authMethod, - environment: config.cloudKit.environment, + cloudKit: config.cloudKit, configuration: fetchConfiguration ) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift index 7012ce080..ca5874900 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/BushelCloudKitService.swift @@ -32,6 +32,7 @@ public import BushelLogging public import Foundation internal import Logging public import MistKit +public import MistKitConfiguration #if canImport(FelinePineSwift) internal import FelinePineSwift @@ -63,91 +64,15 @@ public struct BushelCloudKitService: Sendable, RecordManaging, CloudKitRecordCol // MARK: - Initialization - /// Initialize CloudKit service with Server-to-Server authentication + /// Initialize from already-validated CloudKit credentials. /// - /// **MistKit Pattern**: Server-to-Server authentication requires: - /// 1. Key ID from CloudKit Dashboard → API Access → Server-to-Server Keys - /// 2. Private key .pem file downloaded when creating the key - /// 3. Container identifier (begins with "iCloud.") + /// Validation (presence + key ID / PEM format) happens in + /// ``ValidatedCloudKitConfiguration``; this wrapper only builds the MistKit service. /// - /// - Parameters: - /// - containerIdentifier: CloudKit container ID (e.g., "iCloud.com.company.App") - /// - keyID: Server-to-Server Key ID from CloudKit Dashboard - /// - privateKeyPath: Path to the private key .pem file - /// - environment: CloudKit environment (.development or .production, defaults to .development) - /// - Throws: Error if the private key file cannot be read or is invalid - public init( - containerIdentifier: String, - keyID: String, - privateKeyPath: String, - environment: Environment = .development - ) throws { - // Validate Key ID format before any file IO - try KeyIDValidator.validate(keyID) - - // Read PEM file from disk - guard FileManager.default.fileExists(atPath: privateKeyPath) else { - throw BushelCloudKitError.privateKeyFileNotFound(path: privateKeyPath) - } - - let pemString: String - do { - pemString = try String(contentsOfFile: privateKeyPath, encoding: .utf8) - } catch { - throw BushelCloudKitError.privateKeyFileReadFailed(path: privateKeyPath, error: error) - } - - // Validate PEM format before using it - try PEMValidator.validate(pemString) - - // Create Server-to-Server authentication manager - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: pemString - ) - - self.service = CloudKitService( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: environment - ) - } - - /// Initialize CloudKit service with Server-to-Server authentication using PEM string - /// - /// **CI/CD Pattern**: This initializer accepts PEM content directly from environment variables, - /// eliminating the need for temporary file creation in GitHub Actions or other CI/CD environments. - /// - /// - Parameters: - /// - containerIdentifier: CloudKit container ID (e.g., "iCloud.com.company.App") - /// - keyID: Server-to-Server Key ID from CloudKit Dashboard - /// - pemString: PEM file content as string (including headers/footers) - /// - environment: CloudKit environment (.development or .production, defaults to .development) - /// - Throws: Error if PEM string is invalid or authentication fails - public init( - containerIdentifier: String, - keyID: String, - pemString: String, - environment: Environment = .development - ) throws { - // Validate Key ID format before any cryptographic work - try KeyIDValidator.validate(keyID) - - // Validate PEM format BEFORE passing to MistKit - // This provides better error messages than MistKit's internal validation - try PEMValidator.validate(pemString) - - // Create Server-to-Server authentication manager directly from PEM string - let tokenManager = try ServerToServerAuthManager( - keyID: keyID, - pemString: pemString - ) - - self.service = CloudKitService( - containerIdentifier: containerIdentifier, - tokenManager: tokenManager, - environment: environment - ) + /// - Parameter cloudKit: Validated server-to-server credentials. + /// - Throws: `CredentialsValidationError` if MistKit rejects the credentials. + public init(_ cloudKit: ValidatedCloudKitConfiguration) throws { + self.service = try cloudKit.makeCloudKitService() } // MARK: - RecordManaging Protocol Requirements diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift deleted file mode 100644 index 63de7bb80..000000000 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/KeyIDValidator.swift +++ /dev/null @@ -1,89 +0,0 @@ -// -// KeyIDValidator.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Validates CloudKit Server-to-Server Key ID format -internal enum KeyIDValidator { - private static let allowedCharacters = CharacterSet( - charactersIn: "0123456789abcdefABCDEF" - ) - - /// Validates that a key ID has the expected CloudKit S2S format. - /// - /// CloudKit Server-to-Server keys are SHA-256 fingerprints of the public key: - /// 64 lowercase hex characters. We accept upper- or lower-case to be lenient - /// about copy/paste from the dashboard. - /// - /// - Parameter keyID: The key ID to validate. - /// - Throws: `BushelCloudKitError.invalidKeyID` with a specific reason and suggestion. - internal static func validate(_ keyID: String) throws { - let trimmed = keyID.trimmingCharacters(in: .whitespacesAndNewlines) - - guard !trimmed.isEmpty else { - throw BushelCloudKitError.invalidKeyID( - reason: "Key ID is empty", - suggestion: """ - Set CLOUDKIT_KEY_ID to the Server-to-Server key ID from the CloudKit \ - Dashboard (a 64-character hex string). - """ - ) - } - - guard trimmed == keyID else { - throw BushelCloudKitError.invalidKeyID( - reason: "Key ID has surrounding whitespace", - suggestion: """ - Trim leading/trailing whitespace from CLOUDKIT_KEY_ID. Common cause: \ - accidental newline or space when copying from the dashboard. - """ - ) - } - - guard trimmed.count == 64 else { - throw BushelCloudKitError.invalidKeyID( - reason: "Key ID must be 64 characters (got \(trimmed.count))", - suggestion: """ - CloudKit Server-to-Server keys are 64-character hex strings. \ - Re-copy the full key ID from the CloudKit Dashboard. - """ - ) - } - - guard trimmed.unicodeScalars.allSatisfy(allowedCharacters.contains) else { - throw BushelCloudKitError.invalidKeyID( - reason: "Key ID contains non-hex characters", - suggestion: """ - The key ID should be hex (0-9, a-f). Verify you copied the Key ID — \ - not the key name or container ID — from the CloudKit Dashboard. - """ - ) - } - } -} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift deleted file mode 100644 index 6512f5810..000000000 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/PEMValidator.swift +++ /dev/null @@ -1,99 +0,0 @@ -// -// PEMValidator.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation - -/// Validates PEM format for CloudKit Server-to-Server private keys -internal enum PEMValidator { - /// Validates a PEM string has proper structure and encoding - /// - /// **Checks performed:** - /// 1. Contains BEGIN PRIVATE KEY header - /// 2. Contains END PRIVATE KEY footer - /// 3. Has content between headers - /// 4. Content is valid base64 - /// - /// **Why validate?** - /// - Provides clear error messages before attempting CloudKit operations - /// - Catches common copy/paste errors (truncation, missing markers) - /// - Prevents cryptic errors from MistKit's ServerToServerAuthManager - /// - /// - Parameter pemString: The PEM-formatted private key string - /// - Throws: BushelCloudKitError.invalidPEMFormat with specific reason and recovery suggestion - internal static func validate(_ pemString: String) throws { - let trimmed = pemString.trimmingCharacters(in: .whitespacesAndNewlines) - - // Check for BEGIN header - guard trimmed.contains("-----BEGIN") && trimmed.contains("PRIVATE KEY-----") else { - throw BushelCloudKitError.invalidPEMFormat( - reason: "Missing '-----BEGIN PRIVATE KEY-----' header", - suggestion: """ - Ensure you copied the entire PEM file including the header line. \ - Re-download from CloudKit Dashboard if needed. - """ - ) - } - - // Check for END footer - guard trimmed.contains("-----END") && trimmed.contains("PRIVATE KEY-----") else { - throw BushelCloudKitError.invalidPEMFormat( - reason: "Missing '-----END PRIVATE KEY-----' footer", - suggestion: """ - The PEM file may have been truncated during copy/paste. \ - Ensure you copied the entire file including the footer line. - """ - ) - } - - // Extract content between headers - let lines = trimmed.components(separatedBy: .newlines) - let contentLines = lines.filter { line in - !line.contains("BEGIN") && !line.contains("END") && !line.isEmpty - } - - guard !contentLines.isEmpty else { - throw BushelCloudKitError.invalidPEMFormat( - reason: "PEM file contains no key data between headers", - suggestion: "The key file may be corrupted or empty. Re-download from CloudKit Dashboard." - ) - } - - // Validate base64 encoding - let base64Content = contentLines.joined() - guard Data(base64Encoded: base64Content) != nil else { - throw BushelCloudKitError.invalidPEMFormat( - reason: "PEM content is not valid base64 encoding", - suggestion: """ - The key file may be corrupted. \ - Ensure you used a text editor (not binary editor) and the file is UTF-8 encoded. - """ - ) - } - } -} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift index 76ea2efbc..03eca0348 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/SyncEngine.swift @@ -33,6 +33,7 @@ public import BushelUtilities public import Foundation internal import Logging public import MistKit +public import MistKitConfiguration #if canImport(FelinePineSwift) internal import FelinePineSwift @@ -128,50 +129,17 @@ public struct SyncEngine: Sendable { // MARK: - Initialization - /// Initialize sync engine with CloudKit credentials - /// - /// **Flexible Authentication**: Supports both file-based and string-based PEM content: - /// - `.pemString`: For CI/CD environments (GitHub Actions secrets) - /// - `.pemFile`: For local development (file on disk) - /// - /// **Environment Separation**: Use separate keys for development and production: - /// - Development: Safe for testing, free API calls, can clear data freely - /// - Production: Real user data, requires careful key management + /// Initialize sync engine with validated CloudKit credentials. /// /// - Parameters: - /// - containerIdentifier: CloudKit container ID - /// - keyID: Server-to-Server Key ID - /// - authMethod: Authentication method (`.pemString` or `.pemFile`) - /// - environment: CloudKit environment (.development or .production, defaults to .development) - /// - configuration: Fetch configuration for data sources - /// - Throws: Error if authentication credentials are invalid or missing + /// - cloudKit: Validated server-to-server credentials. + /// - configuration: Fetch configuration for data sources. + /// - Throws: Error if MistKit rejects the credentials. public init( - containerIdentifier: String, - keyID: String, - authMethod: CloudKitAuthMethod, - environment: Environment = .development, + cloudKit: ValidatedCloudKitConfiguration, configuration: FetchConfiguration = FetchConfiguration.loadFromEnvironment() ) throws { - // Initialize CloudKit service based on auth method - let service: BushelCloudKitService - switch authMethod { - case .pemString(let pem): - service = try BushelCloudKitService( - containerIdentifier: containerIdentifier, - keyID: keyID, - pemString: pem, - environment: environment - ) - case .pemFile(let path): - service = try BushelCloudKitService( - containerIdentifier: containerIdentifier, - keyID: keyID, - privateKeyPath: path, - environment: environment - ) - } - - self.cloudKitService = service + self.cloudKitService = try BushelCloudKitService(cloudKit) self.pipeline = DataSourcePipeline( configuration: configuration ) diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift index 6d3978dec..8fcf1e193 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/BushelConfiguration.swift @@ -28,21 +28,8 @@ // public import BushelFoundation -internal import Foundation -internal import MistKit - -// MARK: - Configuration Error - -/// Errors that can occur during configuration validation -public struct ConfigurationError: Error, Sendable { - public let message: String - public let key: String? - - public init(_ message: String, key: String? = nil) { - self.message = message - self.key = key - } -} +public import Foundation +public import MistKitConfiguration // MARK: - Root Configuration @@ -79,11 +66,17 @@ public struct BushelConfiguration: Sendable { /// Validate that all required fields are present public func validated() throws -> ValidatedBushelConfiguration { - guard let cloudKit = cloudKit else { + guard let cloudKit else { throw ConfigurationError("CloudKit configuration required", key: "cloudkit") } + let validatedCloudKit: ValidatedCloudKitConfiguration + do { + validatedCloudKit = try cloudKit.validated() + } catch let error as CloudKitConfigurationError { + throw error.map(keys: ConfigurationKeys.cloudKit) + } return ValidatedBushelConfiguration( - cloudKit: try cloudKit.validated(), + cloudKit: validatedCloudKit, virtualBuddy: virtualBuddy, fetch: fetch, sync: sync, diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift deleted file mode 100644 index 077cdb4f5..000000000 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfiguration.swift +++ /dev/null @@ -1,150 +0,0 @@ -// -// CloudKitConfiguration.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Foundation -public import MistKit - -// MARK: - CloudKit Configuration - -/// CloudKit Server-to-Server authentication configuration -public struct CloudKitConfiguration: Sendable { - public var containerID: String? - public var keyID: String? - public var privateKeyPath: String? - public var privateKey: String? // Raw PEM string for CI/CD - public var environment: String? // "development" or "production" - - public init( - containerID: String? = nil, - keyID: String? = nil, - privateKeyPath: String? = nil, - privateKey: String? = nil, - environment: String? = nil - ) { - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = privateKeyPath - self.privateKey = privateKey - self.environment = environment - } - - /// Validate that all required CloudKit fields are present - public func validated() throws -> ValidatedCloudKitConfiguration { - try ValidatedCloudKitConfiguration(from: self) - } -} - -/// Validated CloudKit configuration with non-optional fields -public struct ValidatedCloudKitConfiguration: Sendable { - public let containerID: String - public let keyID: String - public let privateKeyPath: String // Can be empty if privateKey is used - public let privateKey: String? // Optional (only one method required) - public let environment: MistKit.Environment - - public init(from config: CloudKitConfiguration) throws { - // Validate container ID - guard let containerID = config.containerID, !containerID.isEmpty else { - throw ConfigurationError( - "CloudKit container ID required. Set CLOUDKIT_CONTAINER_ID or use --cloudkit-container-id", - key: "cloudkit.container_id" - ) - } - - // Validate key ID - guard let keyID = config.keyID, !keyID.isEmpty else { - throw ConfigurationError( - "CloudKit key ID required. Set CLOUDKIT_KEY_ID or use --cloudkit-key-id", - key: "cloudkit.key_id" - ) - } - - // Validate at least ONE credential method is provided (NOT both required) - let trimmedPrivateKey = config.privateKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let trimmedPrivateKeyPath = - config.privateKeyPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - - let hasPrivateKey = !trimmedPrivateKey.isEmpty - let hasPrivateKeyPath = !trimmedPrivateKeyPath.isEmpty - - guard hasPrivateKey || hasPrivateKeyPath else { - throw ConfigurationError( - "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided", - key: "cloudkit.private_key" - ) - } - - // Parse environment string to enum (case-insensitive for user convenience) - let environmentString = (config.environment ?? "development") - .lowercased() - .trimmingCharacters(in: .whitespacesAndNewlines) - - guard let parsedEnvironment = MistKit.Environment(rawValue: environmentString) else { - throw ConfigurationError( - """ - Invalid CLOUDKIT_ENVIRONMENT: '\(config.environment ?? "")'. \ - Must be 'development' or 'production' - """, - key: "cloudkit.environment" - ) - } - - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = hasPrivateKeyPath ? trimmedPrivateKeyPath : "" - self.privateKey = hasPrivateKey ? trimmedPrivateKey : nil - self.environment = parsedEnvironment - } - - // Legacy initializer for backward compatibility (if needed by tests) - public init( - containerID: String, - keyID: String, - privateKeyPath: String, - privateKey: String? = nil, - environment: MistKit.Environment - ) { - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = privateKeyPath - self.privateKey = privateKey - self.environment = environment - } -} - -// MARK: - VirtualBuddy Configuration - -/// VirtualBuddy TSS API configuration -public struct VirtualBuddyConfiguration: Sendable { - public var apiKey: String? - - public init(apiKey: String? = nil) { - self.apiKey = apiKey - } -} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift new file mode 100644 index 000000000..bd18c58ec --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift @@ -0,0 +1,77 @@ +// +// CloudKitConfigurationError+Mapping.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +public import MistKitConfiguration + +extension CloudKitConfigurationError { + /// Maps a package error onto Bushel's presentation wording and key names. + public func map(keys: CloudKitConfigurationKeys) -> ConfigurationError { + switch self { + case .missing(.containerID): + ConfigurationError( + "CloudKit container ID required. Set CLOUDKIT_CONTAINER_ID or use --cloudkit-container-id", + key: keys.containerID.base + ) + case .missing(.keyID): + ConfigurationError( + "CloudKit key ID required. Set CLOUDKIT_KEY_ID or use --cloudkit-key-id", + key: keys.keyID.base + ) + case .missing(.privateKey), .missing(.privateKeyPath): + ConfigurationError( + "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided", + key: keys.privateKey.base + ) + case .missing(.environment): + ConfigurationError( + "CloudKit environment must be 'development' or 'production'", + key: keys.environment.base + ) + case .invalidKeyID(let failure): + ConfigurationError( + "Invalid CloudKit Server-to-Server Key ID: \(String(describing: failure))", + key: keys.keyID.base + ) + case .invalidPrivateKey(let failure): + ConfigurationError( + "Invalid PEM format: \(String(describing: failure))", + key: keys.privateKey.base + ) + case .unrecognizedEnvironment(let raw): + ConfigurationError( + """ + Invalid CLOUDKIT_ENVIRONMENT: '\(raw)'. \ + Must be 'development' or 'production' + """, + key: keys.environment.base + ) + } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift index ae75d0f74..7ce98b1dc 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationKeys.swift @@ -29,37 +29,17 @@ internal import ConfigKeyKit internal import Foundation +internal import MistKitConfiguration /// Configuration keys for reading from providers internal enum ConfigurationKeys { - // MARK: - CloudKit Configuration + /// Default CloudKit container for Bushel. + internal static let defaultContainerID = "iCloud.com.brightdigit.Bushel" - /// CloudKit configuration keys - /// - /// Auto-generates environment variable names from the key path. - /// Example: "cloudkit.container_id" → ENV: CLOUDKIT_CONTAINER_ID - internal enum CloudKit { - internal static let containerID = ConfigKey( - "cloudkit.container_id", - default: "iCloud.com.brightdigit.Bushel" - ) - - internal static let keyID = OptionalConfigKey( - "cloudkit.key_id" - ) - - internal static let privateKeyPath = OptionalConfigKey( - "cloudkit.private_key_path" - ) - - internal static let privateKey = OptionalConfigKey( - "cloudkit.private_key" - ) - - internal static let environment = OptionalConfigKey( - "cloudkit.environment" - ) - } + /// CloudKit credential keys with Bushel's container default. + internal static let cloudKit = CloudKitConfigurationKeys( + defaultContainerID: defaultContainerID + ) // MARK: - VirtualBuddy Configuration @@ -68,7 +48,7 @@ internal enum ConfigurationKeys { /// Auto-generates ENV names (VIRTUALBUDDY_API_KEY). internal enum VirtualBuddy { internal static let apiKey = OptionalConfigKey( - "virtualbuddy.api_key" + "virtualbuddy.api-key" ) } @@ -77,7 +57,7 @@ internal enum ConfigurationKeys { /// Fetch throttling configuration keys /// /// Uses `bushelPrefixed:` to add BUSHEL_ prefix to all environment variables. - /// Example: "fetch.interval_global" → ENV: BUSHEL_FETCH_INTERVAL_GLOBAL + /// Example: "fetch.interval-global" → ENV: BUSHEL_FETCH_INTERVAL_GLOBAL internal enum Fetch { /// Generate per-source interval key dynamically /// - Parameter source: Data source identifier (e.g., "appledb.dev") @@ -96,20 +76,20 @@ internal enum ConfigurationKeys { /// /// Uses `bushelPrefixed:` for BUSHEL_SYNC_* environment variables. internal enum Sync { - internal static let dryRun = ConfigKey(bushelPrefixed: "sync.dry_run") + internal static let dryRun = ConfigKey(bushelPrefixed: "sync.dry-run") internal static let restoreImagesOnly = ConfigKey( - bushelPrefixed: "sync.restore_images_only" + bushelPrefixed: "sync.restore-images-only" ) - internal static let xcodeOnly = ConfigKey(bushelPrefixed: "sync.xcode_only") - internal static let swiftOnly = ConfigKey(bushelPrefixed: "sync.swift_only") - internal static let noBetas = ConfigKey(bushelPrefixed: "sync.no_betas") - internal static let noAppleWiki = ConfigKey(bushelPrefixed: "sync.no_apple_wiki") + internal static let xcodeOnly = ConfigKey(bushelPrefixed: "sync.xcode-only") + internal static let swiftOnly = ConfigKey(bushelPrefixed: "sync.swift-only") + internal static let noBetas = ConfigKey(bushelPrefixed: "sync.no-betas") + internal static let noAppleWiki = ConfigKey(bushelPrefixed: "sync.no-apple-wiki") internal static let verbose = ConfigKey(bushelPrefixed: "sync.verbose") internal static let force = ConfigKey(bushelPrefixed: "sync.force") - internal static let minInterval = OptionalConfigKey(bushelPrefixed: "sync.min_interval") + internal static let minInterval = OptionalConfigKey(bushelPrefixed: "sync.min-interval") internal static let source = OptionalConfigKey(bushelPrefixed: "sync.source") internal static let jsonOutputFile = OptionalConfigKey( - bushelPrefixed: "sync.json_output_file") + bushelPrefixed: "sync.json-output-file") } // MARK: - Export Command Configuration @@ -120,8 +100,8 @@ internal enum ConfigurationKeys { internal enum Export { internal static let output = OptionalConfigKey(bushelPrefixed: "export.output") internal static let pretty = ConfigKey(bushelPrefixed: "export.pretty") - internal static let signedOnly = ConfigKey(bushelPrefixed: "export.signed_only") - internal static let noBetas = ConfigKey(bushelPrefixed: "export.no_betas") + internal static let signedOnly = ConfigKey(bushelPrefixed: "export.signed-only") + internal static let noBetas = ConfigKey(bushelPrefixed: "export.no-betas") internal static let verbose = ConfigKey(bushelPrefixed: "export.verbose") } @@ -131,7 +111,7 @@ internal enum ConfigurationKeys { /// /// Uses `bushelPrefixed:` for BUSHEL_STATUS_* environment variables. internal enum Status { - internal static let errorsOnly = ConfigKey(bushelPrefixed: "status.errors_only") + internal static let errorsOnly = ConfigKey(bushelPrefixed: "status.errors-only") internal static let detailed = ConfigKey(bushelPrefixed: "status.detailed") } @@ -141,9 +121,9 @@ internal enum ConfigurationKeys { /// /// Uses `bushelPrefixed:` for BUSHEL_LIST_* environment variables. internal enum List { - internal static let restoreImages = ConfigKey(bushelPrefixed: "list.restore_images") - internal static let xcodeVersions = ConfigKey(bushelPrefixed: "list.xcode_versions") - internal static let swiftVersions = ConfigKey(bushelPrefixed: "list.swift_versions") + internal static let restoreImages = ConfigKey(bushelPrefixed: "list.restore-images") + internal static let xcodeVersions = ConfigKey(bushelPrefixed: "list.xcode-versions") + internal static let swiftVersions = ConfigKey(bushelPrefixed: "list.swift-versions") } // MARK: - Clear Command Configuration diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift index 72fc729f2..3214d1b48 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader+Loading.swift @@ -28,33 +28,27 @@ // internal import BushelFoundation +internal import ConfigKeyKit internal import Foundation +internal import MistKitConfiguration // MARK: - Configuration Loading extension ConfigurationLoader { /// Load the complete configuration from all providers public func loadConfiguration() async throws -> BushelConfiguration { - // CloudKit configuration (automatic CLI → ENV → default fallback) - let cloudKit = CloudKitConfiguration( - containerID: read(ConfigurationKeys.CloudKit.containerID), - keyID: read(ConfigurationKeys.CloudKit.keyID), - privateKeyPath: read(ConfigurationKeys.CloudKit.privateKeyPath), - privateKey: read(ConfigurationKeys.CloudKit.privateKey), - // Default to development - environment: read(ConfigurationKeys.CloudKit.environment) ?? "development" - ) + let cloudKit = configReader.readCloudKitConfiguration(keys: ConfigurationKeys.cloudKit) // VirtualBuddy configuration let virtualBuddy = VirtualBuddyConfiguration( - apiKey: read(ConfigurationKeys.VirtualBuddy.apiKey) + apiKey: configReader.read(ConfigurationKeys.VirtualBuddy.apiKey) ) // Fetch configuration: Start with BushelKit's environment loading, then override with CLI var fetch = FetchConfiguration.loadFromEnvironment() // Override global interval if --min-interval provided - if let minInterval = read(ConfigurationKeys.Sync.minInterval) { + if let minInterval = configReader.read(ConfigurationKeys.Sync.minInterval) { fetch = FetchConfiguration( globalMinimumFetchInterval: TimeInterval(minInterval), perSourceIntervals: fetch.perSourceIntervals, @@ -69,7 +63,7 @@ extension ConfigurationLoader { // Try CLI arg first (e.g., "fetch.interval.appledb_dev") // Then try ENV var (e.g., "BUSHEL_FETCH_INTERVAL_APPLEDB_DEV") let intervalKey = ConfigurationKeys.Fetch.intervalKey(for: source.rawValue) - if let interval = read(intervalKey) { + if let interval = configReader.read(intervalKey) { perSourceIntervals[source.rawValue] = interval } } @@ -85,45 +79,45 @@ extension ConfigurationLoader { // Sync command configuration let sync = SyncConfiguration( - dryRun: read(ConfigurationKeys.Sync.dryRun), - restoreImagesOnly: read(ConfigurationKeys.Sync.restoreImagesOnly), - xcodeOnly: read(ConfigurationKeys.Sync.xcodeOnly), - swiftOnly: read(ConfigurationKeys.Sync.swiftOnly), - noBetas: read(ConfigurationKeys.Sync.noBetas), - noAppleWiki: read(ConfigurationKeys.Sync.noAppleWiki), - verbose: read(ConfigurationKeys.Sync.verbose), - force: read(ConfigurationKeys.Sync.force), - minInterval: read(ConfigurationKeys.Sync.minInterval), - source: read(ConfigurationKeys.Sync.source), - jsonOutputFile: read(ConfigurationKeys.Sync.jsonOutputFile) + dryRun: configReader.read(ConfigurationKeys.Sync.dryRun), + restoreImagesOnly: configReader.read(ConfigurationKeys.Sync.restoreImagesOnly), + xcodeOnly: configReader.read(ConfigurationKeys.Sync.xcodeOnly), + swiftOnly: configReader.read(ConfigurationKeys.Sync.swiftOnly), + noBetas: configReader.read(ConfigurationKeys.Sync.noBetas), + noAppleWiki: configReader.read(ConfigurationKeys.Sync.noAppleWiki), + verbose: configReader.read(ConfigurationKeys.Sync.verbose), + force: configReader.read(ConfigurationKeys.Sync.force), + minInterval: configReader.read(ConfigurationKeys.Sync.minInterval), + source: configReader.read(ConfigurationKeys.Sync.source), + jsonOutputFile: configReader.read(ConfigurationKeys.Sync.jsonOutputFile) ) // Export command configuration let export = ExportConfiguration( - output: read(ConfigurationKeys.Export.output), - pretty: read(ConfigurationKeys.Export.pretty), - signedOnly: read(ConfigurationKeys.Export.signedOnly), - noBetas: read(ConfigurationKeys.Export.noBetas), - verbose: read(ConfigurationKeys.Export.verbose) + output: configReader.read(ConfigurationKeys.Export.output), + pretty: configReader.read(ConfigurationKeys.Export.pretty), + signedOnly: configReader.read(ConfigurationKeys.Export.signedOnly), + noBetas: configReader.read(ConfigurationKeys.Export.noBetas), + verbose: configReader.read(ConfigurationKeys.Export.verbose) ) // Status command configuration let status = StatusConfiguration( - errorsOnly: read(ConfigurationKeys.Status.errorsOnly), - detailed: read(ConfigurationKeys.Status.detailed) + errorsOnly: configReader.read(ConfigurationKeys.Status.errorsOnly), + detailed: configReader.read(ConfigurationKeys.Status.detailed) ) // List command configuration let list = ListConfiguration( - restoreImages: read(ConfigurationKeys.List.restoreImages), - xcodeVersions: read(ConfigurationKeys.List.xcodeVersions), - swiftVersions: read(ConfigurationKeys.List.swiftVersions) + restoreImages: configReader.read(ConfigurationKeys.List.restoreImages), + xcodeVersions: configReader.read(ConfigurationKeys.List.xcodeVersions), + swiftVersions: configReader.read(ConfigurationKeys.List.swiftVersions) ) // Clear command configuration let clear = ClearConfiguration( - yes: read(ConfigurationKeys.Clear.yes), - verbose: read(ConfigurationKeys.Clear.verbose) + yes: configReader.read(ConfigurationKeys.Clear.yes), + verbose: configReader.read(ConfigurationKeys.Clear.verbose) ) return BushelConfiguration( diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift index 1ff767548..72f8ada4a 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/ConfigurationLoader.swift @@ -30,148 +30,27 @@ internal import ConfigKeyKit internal import Configuration internal import Foundation +internal import MistKitConfiguration /// Actor responsible for loading configuration from CLI arguments and environment variables public actor ConfigurationLoader { - private let configReader: ConfigReader + internal let configReader: ConfigReader /// Initialize the configuration loader with command-line and environment providers public init() { - var providers: [any ConfigProvider] = [] - - // Priority 1: Command-line arguments (automatically parses all --key value and --flag arguments) - providers.append( - CommandLineArgumentsProvider( - secretsSpecifier: .specific([ - "--cloudkit-key-id", - "--cloudkit-private-key-path", - "--cloudkit-private-key", - "--virtualbuddy-api-key", - ]) - ) + self.configReader = ConfigurationSources.makeConfigReader( + secretCommandLineFlags: ConfigurationKeys.cloudKit.secretCommandLineFlags.union([ + "--virtualbuddy-api-key" + ]) ) - - // Priority 2: Environment variables - providers.append(EnvironmentVariablesProvider()) - - self.configReader = ConfigReader(providers: providers) - } - - #if DEBUG - /// Test-only initializer that accepts a pre-configured ConfigReader - /// - /// This allows tests to inject controlled configuration sources without - /// modifying process-global state (environment variables). - /// - /// - Parameter configReader: Pre-configured ConfigReader for testing - internal init(configReader: ConfigReader) { - self.configReader = configReader - } - #endif - - // MARK: - Helper Methods - - /// Read a string value from configuration - internal func readString(forKey key: String) -> String? { - configReader.string(forKey: ConfigKey(key)) } - /// Read an integer value from configuration - internal func readInt(forKey key: String) -> Int? { - guard let stringValue = configReader.string(forKey: ConfigKey(key)) else { - return nil - } - return Int(stringValue) - } - - /// Read a double value from configuration - internal func readDouble(forKey key: String) -> Double? { - guard let stringValue = configReader.string(forKey: ConfigKey(key)) else { - return nil - } - return Double(stringValue) - } - - // MARK: - Generic Helper Methods for ConfigKey (with defaults) - - /// Read a string value with automatic CLI → ENV → default fallback - /// Returns non-optional since ConfigKey has a required default - internal func read(_ key: ConfigKeyKit.ConfigKey) -> String { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readString(forKey: keyString) { - return value - } - } - return key.defaultValue // Non-optional! - } - - /// Read a boolean value with enhanced ENV variable parsing - /// - /// Returns non-optional since ConfigKey has a required default. - /// - /// Boolean parsing rules: - /// - CLI: Flag presence indicates true (e.g., --verbose) - /// - ENV: Accepts "true", "1", "yes" (case-insensitive) - /// - Empty string in ENV is treated as absent (falls back to default) + /// Creates a loader over a pre-configured reader. /// - /// - Parameter key: Configuration key with boolean type - /// - Returns: Boolean value from CLI/ENV or the key's default - internal func read(_ key: ConfigKeyKit.ConfigKey) -> Bool { - // Try CLI first (presence-based for flags) - if let cliKey = key.key(for: .commandLine), - configReader.string(forKey: ConfigKey(cliKey)) != nil - { - return true - } - - // Try ENV (may have string value like VERBOSE=true) - if let envKey = key.key(for: .environment), - let envValue = configReader.string(forKey: ConfigKey(envKey)) - { - let lowercased = envValue.lowercased().trimmingCharacters(in: .whitespaces) - return lowercased == "true" || lowercased == "1" || lowercased == "yes" - } - - // Use default value (non-optional) - return key.defaultValue - } - - // MARK: - Generic Helper Methods for OptionalConfigKey (without defaults) - - /// Read a string value with automatic CLI → ENV fallback - /// Returns optional since OptionalConfigKey has no default - internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> String? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readString(forKey: keyString) { - return value - } - } - return nil // No default available - } - - /// Read an integer value with automatic CLI → ENV fallback - /// Returns optional since OptionalConfigKey has no default - internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Int? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readInt(forKey: keyString) { - return value - } - } - return nil // No default available - } - - /// Read a double value with automatic CLI → ENV fallback - /// Returns optional since OptionalConfigKey has no default - internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Double? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readDouble(forKey: keyString) { - return value - } - } - return nil // No default available + /// Lets tests inject controlled configuration sources instead of mutating + /// process-global state (environment variables). + /// - Parameter configReader: Pre-configured reader to read from. + internal init(configReader: ConfigReader) { + self.configReader = configReader } } diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/VirtualBuddyConfiguration.swift b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/VirtualBuddyConfiguration.swift new file mode 100644 index 000000000..ef2a2a102 --- /dev/null +++ b/Examples/BushelCloud/Sources/BushelCloudKit/Configuration/VirtualBuddyConfiguration.swift @@ -0,0 +1,37 @@ +// +// VirtualBuddyConfiguration.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// VirtualBuddy TSS API configuration +public struct VirtualBuddyConfiguration: Sendable { + public var apiKey: String? + + public init(apiKey: String? = nil) { + self.apiKey = apiKey + } +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/KeyIDValidatorTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/KeyIDValidatorTests.swift deleted file mode 100644 index 97106794d..000000000 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/KeyIDValidatorTests.swift +++ /dev/null @@ -1,122 +0,0 @@ -// -// KeyIDValidatorTests.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Testing - -@testable import BushelCloudKit - -@Suite("Key ID Validation Tests") -internal struct KeyIDValidatorTests { - // A representative 64-character hex Key ID (SHA-256 fingerprint length). - private static let validLowercase = - "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - - @Test("Valid 64-char lowercase hex passes validation") - internal func testValidLowercase() { - #expect(throws: Never.self) { - try KeyIDValidator.validate(Self.validLowercase) - } - } - - @Test("Uppercase and mixed-case hex passes validation") - internal func testCaseInsensitiveHex() { - let uppercase = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789" - let mixedCase = "AbCdEf0123456789abcdef0123456789ABCDEF0123456789aBcDeF0123456789" - - #expect(throws: Never.self) { - try KeyIDValidator.validate(uppercase) - } - #expect(throws: Never.self) { - try KeyIDValidator.validate(mixedCase) - } - } - - @Test("Empty string throws error") - internal func testEmpty() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate("") - } - } - - @Test("Whitespace-only string throws error") - internal func testWhitespaceOnly() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(" \n ") - } - } - - @Test("Surrounding whitespace on an otherwise-valid key throws error") - internal func testSurroundingWhitespace() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(" \(Self.validLowercase)\n") - } - } - - @Test("Too-short key throws error") - internal func testTooShort() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate("0123456789abcdef") - } - } - - @Test("Too-long key throws error") - internal func testTooLong() { - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(Self.validLowercase + "00") - } - } - - @Test("Non-hex characters throw error") - internal func testNonHexCharacters() { - // 64 characters, but contains a non-hex letter ('g') and a dash. - let withLetter = "g123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - let withDash = "-123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" - - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(withLetter) - } - #expect(throws: BushelCloudKitError.self) { - try KeyIDValidator.validate(withDash) - } - } - - @Test("Error messages are helpful") - internal func testErrorMessages() { - do { - try KeyIDValidator.validate("invalid") - Issue.record("Should have thrown error") - } catch let error as BushelCloudKitError { - let description = error.errorDescription ?? "" - #expect(description.contains("Invalid CloudKit Server-to-Server Key ID")) - #expect(error.recoverySuggestion != nil) - } catch { - Issue.record("Wrong error type: \(error)") - } - } -} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift deleted file mode 100644 index 146282d6f..000000000 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/CloudKit/PEMValidatorTests.swift +++ /dev/null @@ -1,113 +0,0 @@ -// -// PEMValidatorTests.swift -// BushelCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -internal import Testing - -@testable import BushelCloudKit - -@Suite("PEM Validation Tests") -internal struct PEMValidatorTests { - @Test("Valid PEM passes validation") - internal func testValidPEM() throws { - let validPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg - -----END PRIVATE KEY----- - """ - - #expect(throws: Never.self) { - try PEMValidator.validate(validPEM) - } - } - - @Test("Missing header throws error") - internal func testMissingHeader() { - let invalidPEM = """ - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg - -----END PRIVATE KEY----- - """ - - #expect(throws: BushelCloudKitError.self) { - try PEMValidator.validate(invalidPEM) - } - } - - @Test("Missing footer throws error") - internal func testMissingFooter() { - let invalidPEM = """ - -----BEGIN PRIVATE KEY----- - MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg - """ - - #expect(throws: BushelCloudKitError.self) { - try PEMValidator.validate(invalidPEM) - } - } - - @Test("Empty content throws error") - internal func testEmptyContent() { - let invalidPEM = """ - -----BEGIN PRIVATE KEY----- - -----END PRIVATE KEY----- - """ - - #expect(throws: BushelCloudKitError.self) { - try PEMValidator.validate(invalidPEM) - } - } - - @Test("Invalid base64 throws error") - internal func testInvalidBase64() { - let invalidPEM = """ - -----BEGIN PRIVATE KEY----- - not-valid-base64-content!!! - -----END PRIVATE KEY----- - """ - - #expect(throws: BushelCloudKitError.self) { - try PEMValidator.validate(invalidPEM) - } - } - - @Test("Error messages are helpful") - internal func testErrorMessages() { - let invalidPEM = "invalid" - - do { - try PEMValidator.validate(invalidPEM) - Issue.record("Should have thrown error") - } catch let error as BushelCloudKitError { - let description = error.errorDescription ?? "" - #expect(description.contains("BEGIN PRIVATE KEY")) - #expect(error.recoverySuggestion != nil) - } catch { - Issue.record("Wrong error type: \(error)") - } - } -} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests+Fixtures.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests+Fixtures.swift new file mode 100644 index 000000000..640ba4891 --- /dev/null +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests+Fixtures.swift @@ -0,0 +1,48 @@ +// +// ConfigurationLoaderTests+Fixtures.swift +// BushelCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. + +@testable import BushelCloudKit + +extension ConfigurationLoaderTests { + /// A syntactically valid Server-to-Server key ID: exactly 64 hex characters. + /// + /// ``KeyIDValidator`` checks shape, so fixtures must be well-formed even + /// though no CloudKit request is made. + internal static let validKeyID = + "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + + /// A structurally valid PEM: correct header/footer and base64-decodable body. + /// + /// Not a usable key — ``PEMValidator`` checks structure, not cryptographic + /// content. + internal static let validPEM = """ + -----BEGIN PRIVATE KEY----- + bm90IGEgcmVhbCBrZXksIGJ1dCB2YWxpZCBiYXNlNjQgc28gUEVNVmFsaWRhdG9yIGFjY2VwdHMgaXQ= + -----END PRIVATE KEY----- + """ +} diff --git a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift index 9645ede74..1fffe6a67 100644 --- a/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift +++ b/Examples/BushelCloud/Tests/BushelCloudKitTests/Configuration/ConfigurationLoaderTests.swift @@ -30,6 +30,7 @@ internal import Configuration internal import Foundation internal import MistKit +internal import MistKitConfiguration internal import Testing @testable import BushelCloudKit @@ -148,15 +149,17 @@ internal struct ConfigurationLoaderTests { #expect(config.sync?.verbose == false) // Default } - @Test("ENV var with whitespace is trimmed and parsed") + @Test("ENV var with surrounding whitespace is ignored (falls to default)") internal func testEnvWhitespace() async throws { + // ConfigKeyKit#8: only exact "true"/"1"/"yes" (case-insensitive) are truthy; + // padded values are unrecognized and fall through to the key's default. let loader = ConfigurationLoaderTests.createLoader( cliArgs: [], env: ["BUSHEL_SYNC_VERBOSE": " true "] ) let config = try await loader.loadConfiguration() - #expect(config.sync?.verbose == true) + #expect(config.sync?.verbose == false) } } @@ -194,7 +197,7 @@ internal struct ConfigurationLoaderTests { @Test("String value from CLI arguments") internal func testStringFromCLI() async throws { let loader = ConfigurationLoaderTests.createLoader( - cliArgs: ["cloudkit.container_id=iCloud.com.test.App"], + cliArgs: ["cloudkit.container-id=iCloud.com.test.App"], env: [:] ) @@ -216,7 +219,7 @@ internal struct ConfigurationLoaderTests { @Test("CLI string overrides ENV string") internal func testStringCLIPrecedence() async throws { let loader = ConfigurationLoaderTests.createLoader( - cliArgs: ["cloudkit.container_id=iCloud.com.cli.App"], + cliArgs: ["cloudkit.container-id=iCloud.com.cli.App"], env: ["CLOUDKIT_CONTAINER_ID": "iCloud.com.env.App"] ) @@ -243,7 +246,7 @@ internal struct ConfigurationLoaderTests { @Test("Valid integer from CLI") internal func testValidIntFromCLI() async throws { let loader = ConfigurationLoaderTests.createLoader( - cliArgs: ["sync.min_interval=3600"], + cliArgs: ["sync.min-interval=3600"], env: [:] ) @@ -343,7 +346,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, // Missing CLOUDKIT_PRIVATE_KEY_PATH ] ) @@ -361,7 +364,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", ] ) @@ -370,8 +373,8 @@ internal struct ConfigurationLoaderTests { let validated = try config.validated() #expect(validated.cloudKit.containerID == "iCloud.com.test.App") - #expect(validated.cloudKit.keyID == "test-key-id") - #expect(validated.cloudKit.privateKeyPath == "/path/to/key.pem") + #expect(validated.cloudKit.keyID == ConfigurationLoaderTests.validKeyID) + #expect(validated.cloudKit.privateKey.filePath == "/path/to/key.pem") } @Test("CloudKit privateKey from environment variable") @@ -380,17 +383,20 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY": - "-----BEGIN PRIVATE KEY-----\nMIGH...\n-----END PRIVATE KEY-----", + ConfigurationLoaderTests.validPEM, ] ) let config = try await loader.loadConfiguration() let validated = try config.validated() - #expect(validated.cloudKit.privateKey != nil) - #expect(validated.cloudKit.privateKey?.contains("BEGIN PRIVATE KEY") == true) + guard case .raw(let pem) = validated.cloudKit.privateKey else { + Issue.record("expected inline PEM material") + return + } + #expect(pem.contains("BEGIN PRIVATE KEY")) } @Test( @@ -402,7 +408,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", "CLOUDKIT_ENVIRONMENT": environment, ] @@ -420,7 +426,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", "CLOUDKIT_ENVIRONMENT": "staging", // Invalid ] @@ -439,7 +445,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, // Missing both CLOUDKIT_PRIVATE_KEY and CLOUDKIT_PRIVATE_KEY_PATH ] ) @@ -457,9 +463,8 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", - "CLOUDKIT_PRIVATE_KEY": - "-----BEGIN PRIVATE KEY-----\nfrom-env\n-----END PRIVATE KEY-----", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, + "CLOUDKIT_PRIVATE_KEY": ConfigurationLoaderTests.validPEM, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", ] ) @@ -468,9 +473,8 @@ internal struct ConfigurationLoaderTests { let validated = try config.validated() // Both should be set in validated config - #expect(validated.cloudKit.privateKey != nil) - #expect(!validated.cloudKit.privateKeyPath.isEmpty) - // SyncEngine will prefer privateKey when initializing + // Inline PEM wins over a path when both are supplied. + #expect(validated.cloudKit.privateKey.filePath == nil) } @Test("Empty CLOUDKIT_PRIVATE_KEY is treated as absent") @@ -479,7 +483,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY": " ", // Whitespace only "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", ] @@ -488,9 +492,8 @@ internal struct ConfigurationLoaderTests { let config = try await loader.loadConfiguration() let validated = try config.validated() - // Should use privateKeyPath since privateKey is effectively empty - #expect(validated.cloudKit.privateKey == nil) - #expect(!validated.cloudKit.privateKeyPath.isEmpty) + // Falls back to the path, since the inline key is effectively empty. + #expect(validated.cloudKit.privateKey.filePath?.isEmpty == false) } @Test("Environment parsing is case-insensitive") @@ -499,7 +502,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY_PATH": "/path/to/key.pem", "CLOUDKIT_ENVIRONMENT": "Production", // Mixed case ] @@ -517,9 +520,9 @@ internal struct ConfigurationLoaderTests { cliArgs: [], env: [ "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.App", - "CLOUDKIT_KEY_ID": "test-key-id", + "CLOUDKIT_KEY_ID": ConfigurationLoaderTests.validKeyID, "CLOUDKIT_PRIVATE_KEY": - "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----", + ConfigurationLoaderTests.validPEM, "CLOUDKIT_ENVIRONMENT": "production", ] ) @@ -528,8 +531,8 @@ internal struct ConfigurationLoaderTests { let validated = try config.validated() #expect(validated.cloudKit.containerID == "iCloud.com.test.App") - #expect(validated.cloudKit.keyID == "test-key-id") - #expect(validated.cloudKit.privateKey != nil) + #expect(validated.cloudKit.keyID == ConfigurationLoaderTests.validKeyID) + #expect(validated.cloudKit.privateKey.filePath == nil) #expect(validated.cloudKit.environment == .production) } } @@ -556,7 +559,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [ "export.output=/tmp/export.json", "export.pretty", - "export.signed_only", + "export.signed-only", ], env: [:] ) @@ -574,7 +577,7 @@ internal struct ConfigurationLoaderTests { cliArgs: [ "sync.verbose", "export.pretty", - "list.restore_images", + "list.restore-images", ], env: [:] ) @@ -596,8 +599,8 @@ internal struct ConfigurationLoaderTests { let loader = ConfigurationLoaderTests.createLoader( cliArgs: [ "sync.verbose", - "sync.dry_run", - "sync.min_interval=3600", + "sync.dry-run", + "sync.min-interval=3600", ], env: [ "BUSHEL_SYNC_NO_BETAS": "true", @@ -642,42 +645,48 @@ internal struct ConfigurationLoaderTests { // MARK: - Test Utilities - /// Create a ConfigurationLoader with simulated CLI args and environment variables + /// Creates a loader backed by the same providers production uses, with + /// their inputs injected instead of read from the process. + /// + /// Using `CommandLineArgumentsProvider` and `EnvironmentVariablesProvider` + /// rather than `InMemoryProvider` keeps the double faithful on two behaviors + /// the tests depend on: the environment provider normalizes `-` and `.` to + /// `_` when encoding a key (so `CLOUDKIT_KEY-ID`, generated from the + /// dash-case base `cloudkit.key-id`, resolves from a `CLOUDKIT_KEY_ID` + /// variable), and both providers coerce their string-shaped input on demand + /// (so one variable answers `string`, `int` and `double` reads). + /// `InMemoryProvider` does neither — it matches keys literally and serves + /// only the stored case. /// /// - Parameters: - /// - cliArgs: Simulated CLI arguments (format: "key=value" or "key" for flags) - /// - env: Simulated environment variables - /// - Returns: ConfigurationLoader with controlled inputs + /// - cliArgs: Simulated CLI arguments (format: "key=value", or "key" for flags). + /// - env: Simulated environment variables. + /// - Returns: A loader reading from those inputs only. private static func createLoader( cliArgs: [String], env: [String: String] ) -> ConfigurationLoader { - // Parse CLI args: "key=value" or "key" for flags - var cliValues: [AbsoluteConfigKey: ConfigValue] = [:] + // Rebuild an argv from "key=value" / "key" (flag presence) entries. + // + // A bare `--flag` is spelled `--flag true` here. `CommandLineArgumentsProvider` + // reports a valueless flag through `bool(forKey:)` but not `string(forKey:)`, + // and ConfigKeyKit's boolean resolution detects flag presence via the string + // read — so a truly bare flag resolves to its default. That gap predates the + // `ConfigValueReading` migration (the hand-rolled `read(ConfigKey)` + // these tests previously exercised used the same string-based check); it was + // masked because the former `InMemoryProvider` double stored flags as + // `.string("true")`. Passing the value explicitly keeps these tests on the + // path that works. See the follow-up issue on valueless-flag support. + var arguments: [String] = ["bushel-cloud"] for arg in cliArgs { - if arg.contains("=") { - let parts = arg.split(separator: "=", maxSplits: 1) - if parts.count == 2 { - let key = AbsoluteConfigKey(stringLiteral: String(parts[0])) - cliValues[key] = .init(.string(String(parts[1])), isSecret: false) - } - } else { - // Flag presence (boolean) - let key = AbsoluteConfigKey(stringLiteral: arg) - cliValues[key] = .init(.string("true"), isSecret: false) - } - } - - // ENV vars as-is - var envValues: [AbsoluteConfigKey: ConfigValue] = [:] - for (key, value) in env { - let configKey = AbsoluteConfigKey(stringLiteral: key) - envValues[configKey] = .init(.string(value), isSecret: false) + let parts = arg.split(separator: "=", maxSplits: 1) + arguments.append("--" + parts[0].replacingOccurrences(of: ".", with: "-")) + arguments.append(parts.count == 2 ? String(parts[1]) : "true") } let providers: [any ConfigProvider] = [ - InMemoryProvider(values: cliValues), // Priority 1: CLI - InMemoryProvider(values: envValues), // Priority 2: ENV + CommandLineArgumentsProvider(arguments: arguments), // Priority 1: CLI + EnvironmentVariablesProvider(environmentVariables: env), // Priority 2: ENV ] let configReader = ConfigReader(providers: providers) diff --git a/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/SKILL.md b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/SKILL.md new file mode 100644 index 000000000..9adc21e28 --- /dev/null +++ b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/SKILL.md @@ -0,0 +1,252 @@ +--- +name: rebase-integration-branches +description: >- + Rebase mistkit and subrepo integration branches onto a release branch in + this git-trees repo. Squashes each branch to one commit ahead of the release + branch with CI-only integration changes. Use when rebasing integration + branches, squashing mistkit/subrepo onto v1.0.0-dev.1, or syncing + path-dependency CI for MistKit/CelestraKit. +disable-model-invocation: true +--- + +# Rebase Integration Branches (git-trees) + +Squash `mistkit` and `subrepo` so each is **exactly one commit ahead** of the +release branch. Integration commits change **CI/workflow files only** unless +the user explicitly asks to change `Package.swift` / `Package.resolved`. + +## Prerequisites + +- Read `AGENTS.md` at the container root (sibling of worktree directories). +- Run git commands from the **container root** (parent of this worktree), + not from inside a worktree, unless editing files. +- Confirm `RELEASE_BRANCH` with the user if unsure. + +## Project config + +Read [projects.md](projects.md). Defaults for this repo: + +| Key | Value | +|-----|-------| +| `RELEASE_BRANCH` | `v1.0.0-dev.1` | +| `MAIN_BRANCH` | `main` | +| `MISTKIT_REF` | `1.0.0-beta.4` | +| `KIT_PACKAGE` | `CelestraKit` | +| `KIT_PATH` | `../CelestraKit` | +| Primary workflow | `.github/workflows/CelestraCloud.yml` | +| Feed update workflow | `.github/workflows/update-feeds.yml` | +| MistKit path (if used) | `../..` | + +## Target end state + +``` +main + └── RELEASE_BRANCH (+N commits ahead of main, often 1) + └── mistkit (+1 integration commit, CI only by default) + └── subrepo (+1 integration commit, CI only by default) +``` + +Verify after push: + +```bash +git rev-list --count origin/$MAIN_BRANCH..origin/mistkit # expect: N+1 +git rev-list --count $RELEASE_BRANCH..origin/mistkit # expect: 1 +git rev-list --count origin/mistkit..origin/$MAIN_BRANCH # expect: 0 +``` + +## Workflow checklist + +``` +- [ ] Step 0: Pre-flight — inspect branch divergence +- [ ] Step 1: Backup current branch tips +- [ ] Step 2: Rebase mistkit (squash onto RELEASE_BRANCH) +- [ ] Step 3: Push mistkit +- [ ] Step 4: Rebase subrepo (squash onto RELEASE_BRANCH) +- [ ] Step 5: Push subrepo +- [ ] Step 6: Cleanup worktrees, restore local branch refs +- [ ] Step 7: Report commit counts and diff stats +``` + +--- + +## Step 0: Pre-flight + +```bash +cd # e.g. .../CelestraCloud +git fetch origin + +git rev-list --count $RELEASE_BRANCH..mistkit +git rev-list --count mistkit..$RELEASE_BRANCH +git rev-list --count origin/$MAIN_BRANCH..mistkit +git diff --stat $RELEASE_BRANCH mistkit +git diff --stat $RELEASE_BRANCH subrepo +``` + +**Stop and ask** if force-push was not requested, backups don't exist, or the +user wants to preserve full branch history. + +--- + +## Step 1: Backup branches + +```bash +git branch -f backup/mistkit-pre-squash mistkit +git branch -f backup/subrepo-pre-squash subrepo +git push -u origin backup/mistkit-pre-squash backup/subrepo-pre-squash +``` + +Existing backups `mistkit-backup` / `subrepo-backup` may also be present — update +or create `backup/*-pre-squash` for consistency with BushelCloud. + +--- + +## Step 2: Squash mistkit onto release branch + +```bash +git trees add mistkit --no-push +cd mistkit +git reset --hard $RELEASE_BRANCH +``` + +### mistkit — CI changes + +1. **Delete** `.github/workflows/dependency-policy.yml`. + +2. **Primary workflow** (`.github/workflows/CelestraCloud.yml`) — release branch + may already have `setup-mistkit`. Add only where missing: + - `env`: `MISTKIT_BRANCH: 1.0.0-beta.4` (match existing tag format in repo) + - After checkout in each build job (`build-ubuntu`, `build-macos`, + `build-macos-platforms`): + + ```yaml + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + ``` + +3. **Feed update workflow** (`.github/workflows/update-feeds.yml`) — add + setup-mistkit after checkout in the `build` job if missing (needed when + Package.swift uses a local MistKit path). + +4. **Do not carry over** stale drift: source changes, Swift version + downgrades, etc. + +### mistkit — Package changes (only if user requests) + +```swift +.package(name: "MistKit", path: "../.."), +``` + +Remove the `mistkit` pin from `Package.resolved`. + +### Commit and push mistkit + +```bash +git add -A && git diff --stat $RELEASE_BRANCH +git commit -m "$(cat <<'EOF' +chore: wire setup-mistkit CI for mistkit integration branch + +Add setup-mistkit to build workflows (pinned to 1.0.0-beta.4) and remove +dependency-policy workflow since integration branches use local path deps. +EOF +)" +git log --oneline $RELEASE_BRANCH..HEAD # exactly 1 commit +git push --force-with-lease -u origin HEAD +cd .. && git trees rm mistkit --apply +``` + +--- + +## Step 4: Squash subrepo onto release branch + +```bash +git trees add subrepo --no-push +cd subrepo +git reset --hard $RELEASE_BRANCH +``` + +### subrepo — CI changes + +1. **Delete** `.github/workflows/dependency-policy.yml`. + +2. After checkout in each build job of `CelestraCloud.yml`, add sed override + before setup-mistkit / swift-build: + + Ubuntu: + ```yaml + - name: Update Package.swift to use remote CelestraKit branch + run: | + sed -i 's|\.package(path: "\.\./CelestraKit")|.package(url: "https://github.com/brightdigit/CelestraKit.git", branch: "subrepo")|g' Package.swift + rm -f Package.resolved + ``` + + macOS: + ```yaml + - name: Update Package.swift to use remote CelestraKit branch + run: | + sed -i '' 's|\.package(path: "\.\./CelestraKit")|.package(url: "https://github.com/brightdigit/CelestraKit.git", branch: "subrepo")|g' Package.swift + rm -f Package.resolved + ``` + +### subrepo — Package changes (only if user requests) + +```swift +.package(path: "../CelestraKit"), +``` + +Remove the `celestrakit` pin from `Package.resolved`. + +### Commit and push subrepo + +```bash +git add -A && git diff --stat $RELEASE_BRANCH +git commit -m "$(cat <<'EOF' +chore: wire subrepo CI branch override for CelestraKit integration branch + +Add CI sed steps to substitute the subrepo branch during builds. Remove +dependency-policy workflow since integration branches use local path deps. +EOF +)" +git push --force-with-lease -u origin HEAD +cd .. && git trees rm subrepo --apply +``` + +--- + +## Step 6: Cleanup + +```bash +git branch -f mistkit origin/mistkit +git branch -f subrepo origin/subrepo +``` + +--- + +## Optional: revert Package files only + +```bash +git trees add --no-push +cd +git checkout $RELEASE_BRANCH -- Package.swift Package.resolved +git commit -m "revert: restore Package.swift and Package.resolved from $RELEASE_BRANCH" +git push origin +cd .. && git trees rm --apply && git branch -f origin/ +``` + +Warn: subrepo sed steps require `.package(path: "../CelestraKit")` in Package.swift. + +--- + +## Rules + +1. Never modify `CelestraCloud.git/` directly. +2. Never touch the release-branch worktree while rebasing integration branches. +3. Use `git trees add --no-push`. +4. Always `--force-with-lease`. +5. Ask before force-push if not confirmed in this session. + +## Related + +BushelCloud carries the same skill pattern with `BushelKit` and `BushelCloud.yml`. +See `brightdigit/BushelCloud` `.agents/skills/rebase-integration-branches/`. diff --git a/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/agents/openai.yaml b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/agents/openai.yaml new file mode 100644 index 000000000..706ce4ca7 --- /dev/null +++ b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/agents/openai.yaml @@ -0,0 +1,5 @@ +interface: + display_name: "Rebase Integration Branches" + short_description: "Squash mistkit/subrepo onto a release branch" +policy: + allow_implicit_invocation: false diff --git a/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/projects.md b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/projects.md new file mode 100644 index 000000000..86600137b --- /dev/null +++ b/Examples/CelestraCloud/.agents/skills/rebase-integration-branches/projects.md @@ -0,0 +1,35 @@ +# CelestraCloud config + +| Key | Value | +|-----|-------| +| Container root | Parent of worktree dirs (contains `.git`, `CelestraCloud.git/`) | +| Bare store | `CelestraCloud.git` | +| `RELEASE_BRANCH` | `v1.0.0-dev.1` | +| `MAIN_BRANCH` | `main` | +| `MISTKIT_REF` | `1.0.0-beta.4` | +| `KIT_PACKAGE` | `CelestraKit` | +| `KIT_PATH` | `../CelestraKit` | +| Primary workflow | `.github/workflows/CelestraCloud.yml` | +| Feed update workflow | `.github/workflows/update-feeds.yml` | +| MistKit path (if used) | `../..` (confirm monorepo layout) | + +## Expected commit counts + +- `v1.0.0-dev.1` is typically 1 commit ahead of `main` +- `mistkit` / `subrepo` should be 1 commit ahead of release, 2 ahead of `main` + +## Backup branches + +Prefer `backup/mistkit-pre-squash` and `backup/subrepo-pre-squash`. +Legacy backups `mistkit-backup` / `subrepo-backup` may also exist. + +## Differences from BushelCloud + +| BushelCloud | CelestraCloud | +|-------------|---------------| +| `v1.0.0-alpha.3` | `v1.0.0-dev.1` | +| `BushelKit` | `CelestraKit` | +| `BushelCloud.yml` | `CelestraCloud.yml` | +| `bushel-cloud-build.yml` | `update-feeds.yml` | +| `cloudkit-sync/action.yml` | _(not present)_ | +| `MISTKIT_BRANCH: v1.0.0-beta.4` | `MISTKIT_BRANCH: 1.0.0-beta.4` | diff --git a/.claude/docs/cloudkit-public-database-architecture.md b/Examples/CelestraCloud/.claude/cloudkit-public-database-architecture.md similarity index 100% rename from .claude/docs/cloudkit-public-database-architecture.md rename to Examples/CelestraCloud/.claude/cloudkit-public-database-architecture.md diff --git a/Examples/CelestraCloud/.claude/skills/rebase-integration-branches b/Examples/CelestraCloud/.claude/skills/rebase-integration-branches new file mode 120000 index 000000000..24589df6a --- /dev/null +++ b/Examples/CelestraCloud/.claude/skills/rebase-integration-branches @@ -0,0 +1 @@ +../../.agents/skills/rebase-integration-branches \ No newline at end of file diff --git a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml index 30cb55943..86c646e1a 100644 --- a/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml +++ b/Examples/CelestraCloud/.github/workflows/CelestraCloud.yml @@ -21,7 +21,7 @@ concurrency: env: PACKAGE_NAME: CelestraCloud - MISTKIT_BRANCH: v1.0.0-beta.4 + MISTKIT_BRANCH: 1.0.0-beta.4 jobs: configure: diff --git a/Examples/CelestraCloud/.github/workflows/dependency-policy.yml b/Examples/CelestraCloud/.github/workflows/dependency-policy.yml deleted file mode 100644 index 17c38b636..000000000 --- a/Examples/CelestraCloud/.github/workflows/dependency-policy.yml +++ /dev/null @@ -1,47 +0,0 @@ -name: Dependency Policy - -# Gate for PRs targeting `main`: Package.swift may only use tagged (version) -# dependencies. Branch, revision, or local-path dependencies are integration-only -# and must be bumped to a released tag before merging into `main`. - -on: - pull_request: - branches: - - main - -concurrency: - group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} - cancel-in-progress: true - -jobs: - tagged-dependencies: - name: Verify tagged dependencies - runs-on: ubuntu-latest - container: swiftlang/swift:nightly-6.4.x-noble - steps: - - uses: actions/checkout@v4 - - - name: Install jq - run: | - apt-get update - apt-get install -y --no-install-recommends jq - - - name: Verify Package.swift uses only tagged dependencies - run: | - swift package dump-package > package.json - - bad=$(jq -r ' - ([ .dependencies[] | (.sourceControl // [])[] - | select((.requirement | keys[0]) as $k | $k == "branch" or $k == "revision") - | "\(.identity): \(.requirement | keys[0]) \(.requirement | to_entries[0].value)" ] - + [ .dependencies[] | (.fileSystem // [])[] - | "\(.identity): local path (\(.path))" ])[]' package.json) - - if [ -n "$bad" ]; then - echo "::error::Package.swift must use only tagged (version) dependencies on PRs targeting main." - echo "Non-tagged dependencies found:" - echo "$bad" - exit 1 - fi - - echo "✅ All dependencies use tagged (version) requirements." diff --git a/Examples/CelestraCloud/.gitrepo b/Examples/CelestraCloud/.gitrepo index bb6e5cb4a..f2d497255 100644 --- a/Examples/CelestraCloud/.gitrepo +++ b/Examples/CelestraCloud/.gitrepo @@ -6,7 +6,7 @@ [subrepo] remote = git@github.com:brightdigit/CelestraCloud.git branch = mistkit - commit = 3a6a0afdf26669c2e6107bd880c55dad9fc736a5 - parent = 9c5b292931b81e61ca219ef4f04cbdd0afd35013 + commit = 11609b9ca672826b184462f983d69044b2b9cb07 + parent = eba45563f4cd45b480c3bf85546040f591462f12 method = merge cmdver = 0.4.9 diff --git a/Examples/CelestraCloud/AGENTS.md b/Examples/CelestraCloud/AGENTS.md index 334d32c3c..d5876cc1d 100644 --- a/Examples/CelestraCloud/AGENTS.md +++ b/Examples/CelestraCloud/AGENTS.md @@ -390,6 +390,7 @@ Code must be concurrency-safe with proper actor isolation. - `.claude/IMPLEMENTATION_NOTES.md` - Design decisions, patterns, and technical context - `.claude/AI_SCHEMA_WORKFLOW.md` - CloudKit schema design guide for AI agents - `.claude/CLOUDKIT_SCHEMA_SETUP.md` - Schema deployment instructions +- `.claude/cloudkit-public-database-architecture.md` - Public database architecture and schema reference for the RSS reader ## Pull Request Testing diff --git a/Examples/CelestraCloud/Package.resolved b/Examples/CelestraCloud/Package.resolved index 0b5243d2a..c0c21f629 100644 --- a/Examples/CelestraCloud/Package.resolved +++ b/Examples/CelestraCloud/Package.resolved @@ -1,141 +1,141 @@ { - "originHash": "db7ccb8af3f28002014d2d7bcc61ef143ec2f25a0cd3e9873cb86b35a5353570", - "pins": [ + "originHash" : "87364e7b045e473ed8ad111d20d315a2cbd9c05d42a520e101eddd6505e58ff7", + "pins" : [ { - "identity": "celestrakit", - "kind": "remoteSourceControl", - "location": "https://github.com/brightdigit/CelestraKit.git", - "state": { - "revision": "ca9dae2b20c12a4e73b48b2c245ed0cd1dbebcc4", - "version": "0.0.3" + "identity" : "celestrakit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/CelestraKit.git", + "state" : { + "revision" : "ca9dae2b20c12a4e73b48b2c245ed0cd1dbebcc4", + "version" : "0.0.3" } }, { - "identity": "configkeykit", - "kind": "remoteSourceControl", - "location": "https://github.com/brightdigit/ConfigKeyKit.git", - "state": { - "revision": "6949abb4b7f3e50f7f81d668e6c11aa4721e97fd", - "version": "1.0.0-beta.2" + "identity" : "configkeykit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/ConfigKeyKit.git", + "state" : { + "revision" : "3c8ae3825b4cdcbf60fb1adeaaaf324557e7fd41", + "version" : "1.0.0-beta.3" } }, { - "identity": "swift-asn1", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-asn1.git", - "state": { - "revision": "a9a5efd40eaf558a2bcd48d64b1d1646be686008", - "version": "1.7.1" + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { - "identity": "swift-async-algorithms", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-async-algorithms.git", - "state": { - "revision": "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", - "version": "1.1.5" + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" } }, { - "identity": "swift-collections", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-collections", - "state": { - "revision": "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", - "version": "1.6.0" + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" } }, { - "identity": "swift-configuration", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-configuration.git", - "state": { - "revision": "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", - "version": "1.2.0" + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" } }, { - "identity": "swift-crypto", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-crypto.git", - "state": { - "revision": "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", - "version": "4.5.1" + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "47d3869a7291f085c1fb9fb1e6d3b97a793f45c6", + "version" : "4.5.1" } }, { - "identity": "swift-http-types", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-http-types", - "state": { - "revision": "db774a277f60063a32d854f2980299caf06da041", - "version": "1.6.0" + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types", + "state" : { + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" } }, { - "identity": "swift-log", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-log.git", - "state": { - "revision": "3ffafb9722d5d918c614feb496c8789a3b59d222", - "version": "1.15.0" + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3ffafb9722d5d918c614feb496c8789a3b59d222", + "version" : "1.15.0" } }, { - "identity": "swift-openapi-runtime", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-openapi-runtime", - "state": { - "revision": "3d3a8457661daf7fb260ceeb9f0e24e5204ba5fb", - "version": "1.12.0" + "identity" : "swift-openapi-runtime", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-runtime", + "state" : { + "revision" : "3d3a8457661daf7fb260ceeb9f0e24e5204ba5fb", + "version" : "1.12.0" } }, { - "identity": "swift-openapi-urlsession", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-openapi-urlsession", - "state": { - "revision": "08796d36c99ad2318929bfa1d1e40f82194b65cc", - "version": "1.3.1" + "identity" : "swift-openapi-urlsession", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-openapi-urlsession", + "state" : { + "revision" : "08796d36c99ad2318929bfa1d1e40f82194b65cc", + "version" : "1.3.1" } }, { - "identity": "swift-service-lifecycle", - "kind": "remoteSourceControl", - "location": "https://github.com/swift-server/swift-service-lifecycle", - "state": { - "revision": "7f9326b0326ff86e3646295ea6e891f68c471c5e", - "version": "2.12.0" + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle", + "state" : { + "revision" : "7f9326b0326ff86e3646295ea6e891f68c471c5e", + "version" : "2.12.0" } }, { - "identity": "swift-system", - "kind": "remoteSourceControl", - "location": "https://github.com/apple/swift-system", - "state": { - "revision": "869129b7bf4ecc57b97d0193ad29690ca2134750", - "version": "1.8.1" + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system", + "state" : { + "revision" : "869129b7bf4ecc57b97d0193ad29690ca2134750", + "version" : "1.8.1" } }, { - "identity": "syndikit", - "kind": "remoteSourceControl", - "location": "https://github.com/brightdigit/SyndiKit.git", - "state": { - "revision": "bf0315dc6f9a3d72bdf66bb726b86e3ebab6e9ea", - "version": "0.8.1" + "identity" : "syndikit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/brightdigit/SyndiKit.git", + "state" : { + "revision" : "bf0315dc6f9a3d72bdf66bb726b86e3ebab6e9ea", + "version" : "0.8.1" } }, { - "identity": "xmlcoder", - "kind": "remoteSourceControl", - "location": "https://github.com/CoreOffice/XMLCoder", - "state": { - "revision": "42f62383dbcd074440cb1f6a750b9c02df9e7325", - "version": "0.18.2" + "identity" : "xmlcoder", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CoreOffice/XMLCoder", + "state" : { + "revision" : "42f62383dbcd074440cb1f6a750b9c02df9e7325", + "version" : "0.18.2" } } ], - "version": 3 + "version" : 3 } diff --git a/Examples/CelestraCloud/Package.swift b/Examples/CelestraCloud/Package.swift index 4632e72de..1739a8277 100644 --- a/Examples/CelestraCloud/Package.swift +++ b/Examples/CelestraCloud/Package.swift @@ -4,76 +4,8 @@ import PackageDescription -// MARK: - Swift Settings Configuration - let swiftSettings: [SwiftSetting] = [ - // Swift 6.4 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking - // .unsafeFlags([ - // // Enable concurrency warnings - // "-warn-concurrency", - // // Enable actor data race checks - // "-enable-actor-data-race-checks", - // // Complete strict concurrency checking - // "-strict-concurrency=complete", - // // Enable testing support - // "-enable-testing", - // // Warn about functions with >100 lines - // "-Xfrontend", "-warn-long-function-bodies=100", - // // Warn about slow type checking expressions - // "-Xfrontend", "-warn-long-expression-type-checking=100" - // ]) ] let package = Package( @@ -91,7 +23,10 @@ let package = Package( ], dependencies: [ .package(name: "MistKit", path: "../.."), - .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), + // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once + // MistKitConfiguration is released. Same never-merged discipline as the MistKit line. + .package(path: "../../Packages/MistKitConfiguration"), + .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.3"), .package(url: "https://github.com/brightdigit/CelestraKit.git", from: "0.0.3"), .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), .package( @@ -105,6 +40,7 @@ let package = Package( name: "CelestraCloudKit", dependencies: [ .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "CelestraKit", package: "CelestraKit"), .product(name: "Logging", package: "swift-log"), @@ -115,7 +51,8 @@ let package = Package( .executableTarget( name: "CelestraCloud", dependencies: [ - .target(name: "CelestraCloudKit") + .target(name: "CelestraCloudKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), ], swiftSettings: swiftSettings ), diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift index b3cb333cd..7e82209ee 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/AddFeedCommand.swift @@ -31,6 +31,7 @@ internal import CelestraCloudKit internal import CelestraKit internal import Foundation internal import MistKit +internal import MistKitConfiguration // MARK: - Main Type @@ -65,8 +66,8 @@ internal enum AddFeedCommand { // 3. Load configuration and create CloudKit service let loader = ConfigurationLoader() let config = try await loader.loadConfiguration() - let validatedCloudKit = try config.cloudkit.validated() - let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + let validatedCloudKit = try config.cloudkit.validatedForCelestra() + let service = try validatedCloudKit.makeCloudKitService() // 4. Create Feed record with initial metadata let feed = Feed( diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift index 9af2eba44..01a864220 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/ClearCommand.swift @@ -31,6 +31,7 @@ internal import CelestraCloudKit internal import CelestraKit internal import Foundation internal import MistKit +internal import MistKitConfiguration internal enum ClearCommand { internal static func run(args: [String]) async throws { @@ -50,8 +51,8 @@ internal enum ClearCommand { // Load configuration and create CloudKit service let loader = ConfigurationLoader() let config = try await loader.loadConfiguration() - let validatedCloudKit = try config.cloudkit.validated() - let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + let validatedCloudKit = try config.cloudkit.validatedForCelestra() + let service = try validatedCloudKit.makeCloudKitService() // Delete articles first (to avoid orphans) print("📋 Deleting articles...") diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift index 64ee2d46f..59624fe5f 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand+Reporting.swift @@ -31,6 +31,7 @@ internal import CelestraCloudKit internal import CelestraKit internal import Foundation internal import MistKit +internal import MistKitConfiguration extension UpdateCommand { internal static func createFeedResult( @@ -99,7 +100,8 @@ extension UpdateCommand { maxFailures: config.update.maxFailures, minPopularity: config.update.minPopularity, limit: config.update.limit, - environment: config.cloudkit.environment == .production ? "production" : "development" + environment: (config.cloudkit.environment ?? "development") + .lowercased() == "production" ? "production" : "development" ), summary: UpdateReport.Summary( totalFeeds: summary.successCount + summary.errorCount diff --git a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift index 1336e98e6..fe646f20c 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloud/Commands/UpdateCommand.swift @@ -31,6 +31,7 @@ internal import CelestraCloudKit internal import CelestraKit internal import Foundation internal import MistKit +internal import MistKitConfiguration internal enum UpdateCommand { internal static func run() async throws { @@ -93,8 +94,8 @@ internal enum UpdateCommand { private static func createProcessor( config: CelestraConfiguration ) throws -> FeedUpdateProcessor { - let validatedCloudKit = try config.cloudkit.validated() - let service = try CelestraConfig.createCloudKitService(from: validatedCloudKit) + let validatedCloudKit = try config.cloudkit.validatedForCelestra() + let service = try validatedCloudKit.makeCloudKitService() let fetcher = RSSFetcherService(userAgent: .cloud(build: 1)) let robotsService = RobotsTxtService(userAgent: .cloud(build: 1)) let rateLimiter = RateLimiter(defaultDelay: config.update.delay) diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift index 4fc694659..1b95613c7 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CelestraConfiguration.swift @@ -28,6 +28,7 @@ // public import Foundation +public import MistKitConfiguration /// Root configuration for Celestra application public struct CelestraConfiguration: Sendable { diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift deleted file mode 100644 index e386e8384..000000000 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfiguration.swift +++ /dev/null @@ -1,95 +0,0 @@ -// -// CloudKitConfiguration.swift -// CelestraCloud -// -// Created by Leo Dion. -// Copyright © 2026 BrightDigit. -// -// Permission is hereby granted, free of charge, to any person -// obtaining a copy of this software and associated documentation -// files (the "Software"), to deal in the Software without -// restriction, including without limitation the rights to use, -// copy, modify, merge, publish, distribute, sublicense, and/or -// sell copies of the Software, and to permit persons to whom the -// Software is furnished to do so, subject to the following -// conditions: -// -// The above copyright notice and this permission notice shall be -// included in all copies or substantial portions of the Software. -// -// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES -// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT -// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -// OTHER DEALINGS IN THE SOFTWARE. -// - -public import Foundation -public import MistKit - -/// CloudKit credentials and environment settings -public struct CloudKitConfiguration: Sendable { - /// Default CloudKit container identifier for Celestra - public static let defaultContainerID = "iCloud.com.brightdigit.Celestra" - - /// CloudKit container identifier (e.g., iCloud.com.example.App) - public var containerID: String? - - /// Server-to-Server authentication key ID from Apple Developer Console - public var keyID: String? - - /// Absolute path to PEM-encoded private key file - public var privateKeyPath: String? - - /// CloudKit environment (development or production, default: development) - public var environment: MistKit.Environment - - /// Initialize CloudKit configuration - /// - Parameters: - /// - containerID: CloudKit container identifier - /// - keyID: Server-to-Server authentication key ID - /// - privateKeyPath: Absolute path to PEM-encoded private key file - /// - environment: CloudKit environment - public init( - containerID: String? = nil, - keyID: String? = nil, - privateKeyPath: String? = nil, - environment: MistKit.Environment = .development - ) { - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = privateKeyPath - self.environment = environment - } - - /// Validate that all required fields are present - public func validated() throws -> ValidatedCloudKitConfiguration { - guard let containerID = containerID, !containerID.isEmpty else { - throw EnhancedConfigurationError( - "CloudKit container ID must be non-empty", - key: "cloudkit.container_id" - ) - } - guard let keyID = keyID, !keyID.isEmpty else { - throw EnhancedConfigurationError( - "CloudKit key ID must be non-empty", - key: "cloudkit.key_id" - ) - } - guard let privateKeyPath = privateKeyPath, !privateKeyPath.isEmpty else { - throw EnhancedConfigurationError( - "CloudKit private key path must be non-empty", - key: "cloudkit.private_key_path" - ) - } - return ValidatedCloudKitConfiguration( - containerID: containerID, - keyID: keyID, - privateKeyPath: privateKeyPath, - environment: environment - ) - } -} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift new file mode 100644 index 000000000..d564d9109 --- /dev/null +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/CloudKitConfigurationError+Mapping.swift @@ -0,0 +1,88 @@ +// +// CloudKitConfigurationError+Mapping.swift +// CelestraCloud +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +public import MistKitConfiguration + +extension CloudKitConfigurationError { + /// Maps a package error onto Celestra's presentation wording and key names. + /// + /// - Parameter keys: Key group used to name the flag or environment variable at fault. + /// - Returns: A ``ConfigurationError`` ready to surface to the user. + public func map(keys: CloudKitConfigurationKeys) -> ConfigurationError { + switch self { + case .missing(.containerID): + ConfigurationError( + "CloudKit container ID must be non-empty", + key: keys.containerID.base + ) + case .missing(.keyID): + ConfigurationError( + "CloudKit key ID must be non-empty", + key: keys.keyID.base + ) + case .missing(.privateKey), .missing(.privateKeyPath): + ConfigurationError( + "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided", + key: keys.privateKey.base + ) + case .missing(.environment): + ConfigurationError( + "CloudKit environment must be 'development' or 'production'", + key: keys.environment.base + ) + case .invalidKeyID(let failure): + ConfigurationError( + "Invalid CloudKit Server-to-Server Key ID: \(String(describing: failure))", + key: keys.keyID.base + ) + case .invalidPrivateKey(let failure): + ConfigurationError( + "Invalid PEM format: \(String(describing: failure))", + key: keys.privateKey.base + ) + case .unrecognizedEnvironment(let raw): + ConfigurationError( + "Invalid CLOUDKIT_ENVIRONMENT: '\(raw)'. Must be 'development' or 'production'", + key: keys.environment.base + ) + } + } +} + +extension CloudKitConfiguration { + /// Validates credentials, mapping package errors into Celestra ``ConfigurationError``. + public func validatedForCelestra() throws -> ValidatedCloudKitConfiguration { + do { + return try validated() + } catch { + throw error.map(keys: ConfigurationKeys.cloudKit) + } + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift index 9585c2ba2..4abae3776 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationKeys.swift @@ -29,24 +29,20 @@ internal import ConfigKeyKit internal import Foundation +internal import MistKitConfiguration /// Configuration keys for reading from providers. /// -/// Each option is a single typed `ConfigKey`/`OptionalConfigKey`. ConfigKeyKit's -/// `StandardNamingStyle` derives both spellings from one dash-separated base: -/// the CLI flag (dash-case, e.g. `--cloudkit-container-id`) and the environment -/// variable (`SCREAMING_SNAKE_CASE`, e.g. `CLOUDKIT_CONTAINER_ID`). Bases use -/// dashes throughout for consistency and conventional kebab-case CLI flags. +/// CloudKit credentials come from ``CloudKitConfigurationKeys``; Celestra-only update +/// options stay here as typed `ConfigKey`/`OptionalConfigKey` values. internal enum ConfigurationKeys { - internal enum CloudKit { - internal static let containerID = ConfigKey( - "cloudkit.container-id", - default: CloudKitConfiguration.defaultContainerID - ) - internal static let keyID = OptionalConfigKey("cloudkit.key-id") - internal static let privateKeyPath = OptionalConfigKey("cloudkit.private-key-path") - internal static let environment = OptionalConfigKey("cloudkit.environment") - } + /// Default CloudKit container for Celestra. + internal static let defaultContainerID = "iCloud.com.brightdigit.Celestra" + + /// CloudKit credential keys with Celestra's container default. + internal static let cloudKit = CloudKitConfigurationKeys( + defaultContainerID: defaultContainerID + ) internal enum Update { internal static let delay = ConfigKey("update.delay", default: 2.0) diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift index 699146844..8fa889ec9 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift +++ b/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ConfigurationLoader.swift @@ -30,7 +30,7 @@ internal import ConfigKeyKit internal import Configuration internal import Foundation -internal import MistKit +internal import MistKitConfiguration /// Loads and merges configuration from multiple sources public actor ConfigurationLoader { @@ -38,45 +38,32 @@ public actor ConfigurationLoader { /// Creates a new configuration loader with default providers. public init() { - var providers: [any ConfigProvider] = [] - - // Priority 1: Command-line arguments (highest) - providers.append( - CommandLineArgumentsProvider( - secretsSpecifier: .specific( - [ - "--cloudkit-key-id", - "--cloudkit-private-key-path", - ] - ) - ) + self.configReader = ConfigurationSources.makeConfigReader( + secretCommandLineFlags: ConfigurationKeys.cloudKit.secretCommandLineFlags ) + } - // Priority 2: Environment variables - providers.append(EnvironmentVariablesProvider()) - - self.configReader = ConfigReader(providers: providers) + /// Creates a loader over a pre-configured reader. + /// + /// Lets tests inject controlled configuration sources instead of mutating + /// process-global environment variables. + /// - Parameter configReader: Pre-configured reader to read from. + internal init(configReader: ConfigReader) { + self.configReader = configReader } /// Load complete configuration with all defaults applied public func loadConfiguration() async throws -> CelestraConfiguration { - // CloudKit configuration (automatic CLI → ENV → default fallback) - let cloudkit = CloudKitConfiguration( - containerID: read(ConfigurationKeys.CloudKit.containerID), - keyID: read(ConfigurationKeys.CloudKit.keyID), - privateKeyPath: read(ConfigurationKeys.CloudKit.privateKeyPath), - environment: parseEnvironment(read(ConfigurationKeys.CloudKit.environment)) - ) + let cloudkit = configReader.readCloudKitConfiguration(keys: ConfigurationKeys.cloudKit) - // Update command configuration let update = UpdateCommandConfiguration( - delay: read(ConfigurationKeys.Update.delay), - skipRobotsCheck: read(ConfigurationKeys.Update.skipRobotsCheck), - maxFailures: read(ConfigurationKeys.Update.maxFailures), - minPopularity: read(ConfigurationKeys.Update.minPopularity), - lastAttemptedBefore: read(ConfigurationKeys.Update.lastAttemptedBefore), - limit: read(ConfigurationKeys.Update.limit), - jsonOutputPath: read(ConfigurationKeys.Update.jsonOutputPath) + delay: configReader.read(ConfigurationKeys.Update.delay), + skipRobotsCheck: configReader.read(ConfigurationKeys.Update.skipRobotsCheck), + maxFailures: configReader.read(ConfigurationKeys.Update.maxFailures), + minPopularity: configReader.read(ConfigurationKeys.Update.minPopularity), + lastAttemptedBefore: configReader.read(ConfigurationKeys.Update.lastAttemptedBefore), + limit: configReader.read(ConfigurationKeys.Update.limit), + jsonOutputPath: configReader.read(ConfigurationKeys.Update.jsonOutputPath) ) return CelestraConfiguration( @@ -84,107 +71,4 @@ public actor ConfigurationLoader { update: update ) } - - // MARK: - Per-key-string Primitives - - private func readString(forKey key: String) -> String? { - configReader.string(forKey: ConfigKey(key)) - } - - private func readInt(forKey key: String) -> Int? { - configReader.int(forKey: ConfigKey(key)) - } - - private func readDate(forKey key: String) -> Date? { - // Swift Configuration automatically converts ISO8601 strings to Date - configReader.string(forKey: ConfigKey(key), as: Date.self) - } - - private func parseEnvironment(_ value: String?) -> MistKit.Environment { - guard let value = value?.lowercased() else { - return .development - } - return value == "production" ? .production : .development - } - - // MARK: - Generic ConfigKey Helpers (required default → non-optional) - - /// Read a string value with automatic CLI → ENV → default fallback. - private func read(_ key: ConfigKeyKit.ConfigKey) -> String { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readString(forKey: keyString) { - return value - } - } - return key.defaultValue - } - - /// Read a double value with automatic CLI → ENV → default fallback. - private func read(_ key: ConfigKeyKit.ConfigKey) -> Double { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let stringValue = readString(forKey: keyString), let value = Double(stringValue) { - return value - } - } - return key.defaultValue - } - - /// Read a boolean value with CLI flag-presence and ENV string parsing. - /// - /// - CLI: flag presence indicates `true` (e.g. `--update-skip-robots-check`). - /// - ENV: accepts `true`/`1`/`yes` (case-insensitive); anything else is `false`. - /// - Otherwise: the key's default. - private func read(_ key: ConfigKeyKit.ConfigKey) -> Bool { - if let cliKey = key.key(for: .commandLine), - configReader.string(forKey: ConfigKey(cliKey)) != nil - { - return true - } - - if let envKey = key.key(for: .environment), - let envValue = configReader.string(forKey: ConfigKey(envKey)) - { - let lowercased = envValue.lowercased().trimmingCharacters(in: .whitespaces) - return lowercased == "true" || lowercased == "1" || lowercased == "yes" - } - - return key.defaultValue - } - - // MARK: - Generic OptionalConfigKey Helpers (no default → optional) - - /// Read a string value with automatic CLI → ENV fallback. - private func read(_ key: ConfigKeyKit.OptionalConfigKey) -> String? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readString(forKey: keyString) { - return value - } - } - return nil - } - - /// Read an integer value with automatic CLI → ENV fallback. - private func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Int? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readInt(forKey: keyString) { - return value - } - } - return nil - } - - /// Read a date value (ISO8601) with automatic CLI → ENV fallback. - private func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Date? { - for source in ConfigKeySource.allCases { - guard let keyString = key.key(for: source) else { continue } - if let value = readDate(forKey: keyString) { - return value - } - } - return nil - } } diff --git a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift index 80d180023..85673e6e9 100644 --- a/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift +++ b/Examples/CelestraCloud/Tests/CelestraCloudTests/Configuration/CloudKitConfigurationTests.swift @@ -29,26 +29,30 @@ internal import Foundation internal import MistKit +internal import MistKitConfiguration internal import Testing @testable import CelestraCloudKit @Suite("CloudKitConfiguration Tests") internal struct CloudKitConfigurationTests { + /// A syntactically valid Server-to-Server key ID: exactly 64 hex characters. + private static let validKeyID = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2" + @Test("Valid configuration with all fields") internal func testValidConfigurationWithAllFields() throws { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem", - environment: .production + environment: "production" ) - let validated = try config.validated() + let validated = try config.validatedForCelestra() #expect(validated.containerID == "iCloud.com.example.Test") - #expect(validated.keyID == "TEST_KEY_ID") - #expect(validated.privateKeyPath == "/path/to/key.pem") + #expect(validated.keyID == Self.validKeyID) + #expect(validated.privateKey.filePath == "/path/to/key.pem") #expect(validated.environment == .production) } @@ -56,11 +60,11 @@ internal struct CloudKitConfigurationTests { internal func testValidConfigurationWithDefaultEnvironment() throws { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem" ) - let validated = try config.validated() + let validated = try config.validatedForCelestra() #expect(validated.environment == .development) } @@ -69,12 +73,12 @@ internal struct CloudKitConfigurationTests { internal func testMissingContainerIDThrowsError() { let config = CloudKitConfiguration( containerID: nil, - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem" ) - #expect(throws: EnhancedConfigurationError.self) { - try config.validated() + #expect(throws: ConfigurationError.self) { + try config.validatedForCelestra() } } @@ -82,16 +86,16 @@ internal struct CloudKitConfigurationTests { internal func testEmptyContainerIDThrowsError() { let config = CloudKitConfiguration( containerID: "", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem" ) do { - _ = try config.validated() + _ = try config.validatedForCelestra() Issue.record("Expected error to be thrown for empty containerID") - } catch let error as EnhancedConfigurationError { + } catch let error as ConfigurationError { #expect(error.message == "CloudKit container ID must be non-empty") - #expect(error.key == "cloudkit.container_id") + #expect(error.key == "cloudkit.container-id") } catch { Issue.record("Unexpected error type: \(error)") } @@ -105,8 +109,8 @@ internal struct CloudKitConfigurationTests { privateKeyPath: "/path/to/key.pem" ) - #expect(throws: EnhancedConfigurationError.self) { - try config.validated() + #expect(throws: ConfigurationError.self) { + try config.validatedForCelestra() } } @@ -119,11 +123,11 @@ internal struct CloudKitConfigurationTests { ) do { - _ = try config.validated() + _ = try config.validatedForCelestra() Issue.record("Expected error to be thrown for empty keyID") - } catch let error as EnhancedConfigurationError { + } catch let error as ConfigurationError { #expect(error.message == "CloudKit key ID must be non-empty") - #expect(error.key == "cloudkit.key_id") + #expect(error.key == "cloudkit.key-id") } catch { Issue.record("Unexpected error type: \(error)") } @@ -133,12 +137,12 @@ internal struct CloudKitConfigurationTests { internal func testMissingPrivateKeyPathThrowsError() { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: nil ) - #expect(throws: EnhancedConfigurationError.self) { - try config.validated() + #expect(throws: ConfigurationError.self) { + try config.validatedForCelestra() } } @@ -146,16 +150,18 @@ internal struct CloudKitConfigurationTests { internal func testEmptyPrivateKeyPathThrowsError() { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "" ) do { - _ = try config.validated() + _ = try config.validatedForCelestra() Issue.record("Expected error to be thrown for empty privateKeyPath") - } catch let error as EnhancedConfigurationError { - #expect(error.message == "CloudKit private key path must be non-empty") - #expect(error.key == "cloudkit.private_key_path") + } catch let error as ConfigurationError { + #expect( + error.message + == "Either CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH must be provided") + #expect(error.key == "cloudkit.private-key") } catch { Issue.record("Unexpected error type: \(error)") } @@ -165,18 +171,18 @@ internal struct CloudKitConfigurationTests { internal func testEnvironmentSetToProduction() throws { let config = CloudKitConfiguration( containerID: "iCloud.com.example.Test", - keyID: "TEST_KEY_ID", + keyID: Self.validKeyID, privateKeyPath: "/path/to/key.pem", - environment: .production + environment: "production" ) - let validated = try config.validated() + let validated = try config.validatedForCelestra() #expect(validated.environment == .production) } @Test("Default container ID constant") internal func testDefaultContainerIDConstant() { - #expect(CloudKitConfiguration.defaultContainerID == "iCloud.com.brightdigit.Celestra") + #expect(ConfigurationKeys.defaultContainerID == "iCloud.com.brightdigit.Celestra") } } diff --git a/Examples/MistDemo/.swift-version b/Examples/MistDemo/.swift-version new file mode 100644 index 000000000..c596943a9 --- /dev/null +++ b/Examples/MistDemo/.swift-version @@ -0,0 +1 @@ +6.4 diff --git a/Examples/MistDemo/Package.resolved b/Examples/MistDemo/Package.resolved index 7a1961f82..880317d7f 100644 --- a/Examples/MistDemo/Package.resolved +++ b/Examples/MistDemo/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "323c779f5e9cb02107c3e61f0a8a2b1608eb6e67b7923401f1b2767942516400", + "originHash" : "18ab5c7ce47b802d34f49cbd082522ade7b3e8b125db7298f2ff028e4a016cb3", "pins" : [ { "identity" : "async-http-client", @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/brightdigit/ConfigKeyKit.git", "state" : { - "revision" : "6949abb4b7f3e50f7f81d668e6c11aa4721e97fd", - "version" : "1.0.0-beta.2" + "revision" : "3c8ae3825b4cdcbf60fb1adeaaaf324557e7fd41", + "version" : "1.0.0-beta.3" } }, { diff --git a/Examples/MistDemo/Package.swift b/Examples/MistDemo/Package.swift index 94e0d6d06..20cb6d878 100644 --- a/Examples/MistDemo/Package.swift +++ b/Examples/MistDemo/Package.swift @@ -1,94 +1,11 @@ -// swift-tools-version: 6.2 +// swift-tools-version: 6.4 // swiftlint:disable explicit_acl explicit_top_level_acl import PackageDescription -// MARK: - AsyncAlgorithms wasi gating -// -// AsyncAlgorithms 1.0.x's Locking.swift references pthread_mutex_*. The Swift 6.2 -// wasm32-unknown-wasip1 SDK doesn't ship libwasi-emulated-pthread.a, so linking -// fails. Swift 6.3+ wasi SDKs link cleanly. Gate the wasi exclusion to 6.2 only; -// the `#else` self-deletes when the floor moves to 6.3. - -#if compiler(>=6.3) -let asyncAlgorithmsCondition: TargetDependencyCondition? = nil -#else -let asyncAlgorithmsCondition: TargetDependencyCondition? = .when( - platforms: Platform.without(.wasi) -) -#endif - -// MARK: - Swift Settings Configuration - let swiftSettings: [SwiftSetting] = [ - // Swift 6.2 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking - .unsafeFlags([ - // Enable concurrency warnings - "-warn-concurrency", - // Enable actor data race checks - "-enable-actor-data-race-checks", - // Complete strict concurrency checking - "-strict-concurrency=complete", - // Enable testing support - "-enable-testing", - // Warn about functions with >100 lines - "-Xfrontend", "-warn-long-function-bodies=100", - // Warn about slow type checking expressions - "-Xfrontend", "-warn-long-expression-type-checking=100", - ]), ] let package = Package( @@ -106,7 +23,10 @@ let package = Package( ], dependencies: [ .package(name: "MistKit", path: "../.."), - .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.2"), + // Monorepo dogfood overlay — publishable consumers use a tagged `from:` once + // MistKitConfiguration is released. + .package(path: "../../Packages/MistKitConfiguration"), + .package(url: "https://github.com/brightdigit/ConfigKeyKit.git", from: "1.0.0-beta.3"), .package( url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0" @@ -136,6 +56,7 @@ let package = Package( dependencies: [ .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), .product( name: "Hummingbird", package: "hummingbird", @@ -150,8 +71,7 @@ let package = Package( ), .product( name: "AsyncAlgorithms", - package: "swift-async-algorithms", - condition: asyncAlgorithmsCondition + package: "swift-async-algorithms" ), ], resources: [ @@ -176,6 +96,7 @@ let package = Package( "MistDemoKit", .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), .product(name: "MistKit", package: "MistKit"), + .product(name: "MistKitConfiguration", package: "MistKitConfiguration"), .product( name: "Hummingbird", package: "hummingbird", @@ -192,8 +113,7 @@ let package = Package( ), .product( name: "AsyncAlgorithms", - package: "swift-async-algorithms", - condition: asyncAlgorithmsCondition + package: "swift-async-algorithms" ), ], swiftSettings: swiftSettings @@ -201,17 +121,4 @@ let package = Package( ] ) -extension Platform { - static let all: [Platform] = [ - .macOS, .iOS, .tvOS, .watchOS, .visionOS, .macCatalyst, - .linux, .windows, .android, .driverKit, .wasi, - ] - - static func without(_ platform: Platform) -> [Platform] { - var result = all - result.removeAll { $0 == platform } - return result - } -} - // swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Examples/MistDemo/README.md b/Examples/MistDemo/README.md index 6a933844c..5e4bea0a1 100644 --- a/Examples/MistDemo/README.md +++ b/Examples/MistDemo/README.md @@ -42,9 +42,8 @@ swift run mistdemo test-public # integration suite, public DB swift run mistdemo test-private # integration suite, private DB ``` -Configuration comes from `MistDemoConfiguration` — flags, -`CLOUDKIT_*` env vars, or `--config-file ~/.mistdemo/config.json` all -work. `test-private` requires both a sharer and a sharee web-auth +Configuration comes from `MistDemoConfiguration` — command-line flags or +`CLOUDKIT_*` env vars. `test-private` requires both a sharer and a sharee web-auth token (`CLOUDKIT_WEB_AUTH_TOKEN` + `CLOUDKIT_SHAREE_WEB_AUTH_TOKEN`) and the sharee's iCloud email (`CLOUDKIT_SHAREE_EMAIL`); capture them with `mistdemo auth-tokens --sharee-email sharee@example.com`. @@ -86,17 +85,17 @@ sharee credentials for `test-private`. | Flag | Default | Notes | |---|---|---| | `--api-token ` | (required) | Or set `CLOUDKIT_API_TOKEN` | -| `--container-identifier ` | `iCloud.com.brightdigit.MistDemo` | Your CloudKit container | -| `--environment ` | `development` | `development` or `production` | +| `--cloudkit-container-id ` | `iCloud.com.brightdigit.MistDemo` | Your CloudKit container (`CLOUDKIT_CONTAINER_ID`) | +| `--cloudkit-environment ` | `development` | `development` or `production` | | `--host ` | `127.0.0.1` | Bind address | | `--port ` | `8080` | Server port | | `--browser` | on for `auth-token` / `auth-tokens`, off for `web` | Open browser on startup | | `--no-browser` | — | Suppress the open (wins if both flags set) | -Configuration is read via `MistDemoConfiguration`, so the same keys -(`api.token`, `container.identifier`, `environment`, `port`, `host`, -`browser`, `no.browser`) can be supplied through `--config-file ~/.mistdemo/config.json` -or environment variables. +Configuration is read via `MistDemoConfiguration` from typed `MistDemoKeys`, so the +same keys (`api.token`, `cloudkit.container-id`, `cloudkit.environment`, `port`, +`host`, `browser`, `no.browser`) can equally be supplied as environment variables +(`CLOUDKIT_API_TOKEN`, `CLOUDKIT_CONTAINER_ID`, `CLOUDKIT_ENVIRONMENT`, …). ### What the server exposes @@ -148,7 +147,7 @@ The web command's code lives under `Sources/MistDemoKit/`: ``` Sources/MistDemoKit/ ├── Commands/WebCommand.swift # `mistdemo web` entry point -├── Configuration/WebConfig.swift # Flags / env / config-file binding +├── Configuration/WebConfig.swift # Flags / env binding ├── Resources/index.html # Served at GET / └── Server/ ├── WebServer.swift # Hummingbird router + handlers diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift index e2ef62fae..e9674b823 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AcceptConfig.swift @@ -80,18 +80,11 @@ public struct AcceptConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json - let fetchRootRecord = configuration.optionalBool( - forKey: "fetch.root.record" - ) - let fields = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) + let fetchRootRecord = configuration.read(MistDemoKeys.Sharing.fetchRootRecord) + let fields = configuration.commaSeparatedList(MistDemoKeys.Record.fields) let shortGUIDs = ResolveConfig.parseShortGUIDs(from: configuration) guard !shortGUIDs.isEmpty else { diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift index 4d89b79e0..91c229559 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokenConfig.swift @@ -28,6 +28,7 @@ // public import ConfigKeyKit +internal import MistKitConfiguration internal import Foundation public import MistKit @@ -87,7 +88,7 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { // Parse command-specific options let apiToken = - configReader.string(forKey: "api.token", isSecret: true) ?? "" + configReader.read(MistDemoKeys.Auth.apiToken) guard !apiToken.isEmpty else { throw ConfigurationError.missingRequired( "api.token", @@ -99,29 +100,24 @@ public struct AuthTokenConfig: Sendable, ConfigurationParseable { // Demo default — override via --container-identifier // or config key "container.identifier" let containerIdentifier = - configReader.string( - forKey: "container.identifier", - default: MistDemoConstants.Defaults.containerIdentifier - ) ?? MistDemoConstants.Defaults.containerIdentifier + configReader.read(MistDemoKeys.cloudKit.containerID) let envString = - configReader.string(forKey: "environment", default: "development") - ?? "development" + configReader.read(MistDemoKeys.cloudKit.environment) ?? MistDemoConstants.Defaults.environment guard let environment = MistKit.Environment(caseInsensitive: envString) else { throw ConfigurationError.invalidEnvironment(envString) } let port = - configReader.int(forKey: "port", default: 8_080) ?? 8_080 + configReader.read(MistDemoKeys.Server.port) let host = - configReader.string(forKey: "host", default: "127.0.0.1") - ?? "127.0.0.1" + configReader.read(MistDemoKeys.Server.host) let openBrowser = BrowserFlagResolver.resolve( configReader: configReader, default: true ) let resetAuth = - configReader.bool(forKey: "reset.auth", default: false) + configReader.read(MistDemoKeys.Auth.resetAuth) self.init( apiToken: apiToken, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokensConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokensConfig.swift index 584dd123c..bd80e6a13 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokensConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/AuthTokensConfig.swift @@ -28,6 +28,7 @@ // public import ConfigKeyKit +internal import MistKitConfiguration internal import Foundation public import MistKit @@ -81,7 +82,7 @@ public struct AuthTokensConfig: Sendable, ConfigurationParseable { let configReader = configuration let apiToken = - configReader.string(forKey: "api.token", isSecret: true) ?? "" + configReader.read(MistDemoKeys.Auth.apiToken) guard !apiToken.isEmpty else { throw ConfigurationError.missingRequired( "api.token", @@ -91,28 +92,23 @@ public struct AuthTokensConfig: Sendable, ConfigurationParseable { } let containerIdentifier = - configReader.string( - forKey: "container.identifier", - default: MistDemoConstants.Defaults.containerIdentifier - ) ?? MistDemoConstants.Defaults.containerIdentifier + configReader.read(MistDemoKeys.cloudKit.containerID) let envString = - configReader.string(forKey: "environment", default: "development") - ?? "development" + configReader.read(MistDemoKeys.cloudKit.environment) ?? MistDemoConstants.Defaults.environment guard let environment = MistKit.Environment(caseInsensitive: envString) else { throw ConfigurationError.invalidEnvironment(envString) } let port = - configReader.int(forKey: "port", default: 8_080) ?? 8_080 + configReader.read(MistDemoKeys.Server.port) let host = - configReader.string(forKey: "host", default: "127.0.0.1") - ?? "127.0.0.1" + configReader.read(MistDemoKeys.Server.host) let openBrowser = BrowserFlagResolver.resolve( configReader: configReader, default: true ) - let shareeEmail = configReader.string(forKey: "sharee.email") + let shareeEmail = configReader.read(MistDemoKeys.Auth.shareeEmail) self.init( apiToken: apiToken, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift index 9a1bdb3c5..77c35a93c 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/BrowserFlagResolver.swift @@ -40,11 +40,11 @@ internal enum BrowserFlagResolver { configReader: MistDemoConfiguration, default defaultValue: Bool ) -> Bool { - let noBrowser = configReader.bool(forKey: "no.browser", default: false) + let noBrowser = configReader.read(MistDemoKeys.Server.noBrowser) if noBrowser { return false } - let browser = configReader.bool(forKey: "browser", default: false) + let browser = configReader.read(MistDemoKeys.Server.browser) if browser { return true } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CloudKitConfigurationError+Mapping.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CloudKitConfigurationError+Mapping.swift new file mode 100644 index 000000000..1cbd1900e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CloudKitConfigurationError+Mapping.swift @@ -0,0 +1,68 @@ +// +// CloudKitConfigurationError+Mapping.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitConfiguration + +extension CloudKitConfigurationError { + /// Maps a package error onto MistDemo's existing ``ConfigurationError`` cases. + internal func map() -> ConfigurationError { + switch self { + case .missing(.containerID): + .missingRequired( + "container.id", + suggestion: "Set CLOUDKIT_CONTAINER_ID or --cloudkit-container-id." + ) + case .missing(.keyID): + .missingRequired( + "key.id", + suggestion: "Set CLOUDKIT_KEY_ID or --cloudkit-key-id." + ) + case .missing(.privateKey), .missing(.privateKeyPath): + .missingRequired( + "private.key", + suggestion: "Set CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH." + ) + case .missing(.environment): + .invalidEnvironment("") + case .invalidKeyID: + .missingRequired( + "key.id", + suggestion: "Provide a 64-character hexadecimal CloudKit Server-to-Server Key ID." + ) + case .invalidPrivateKey: + .missingRequired( + "private.key", + suggestion: + "Provide a PEM-encoded private key via CLOUDKIT_PRIVATE_KEY or CLOUDKIT_PRIVATE_KEY_PATH." + ) + case .unrecognizedEnvironment(let raw): + .invalidEnvironment(raw) + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift index 32a8e27ec..1e44ea28e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateConfig.swift @@ -86,28 +86,17 @@ public struct CreateConfig: Sendable, ConfigurationParseable { // Parse create-specific options let zone = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone, - default: MistDemoConstants.Defaults.zone - ) ?? MistDemoConstants.Defaults.zone + configReader.read(MistDemoKeys.Query.zone) let recordType = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordType, - default: MistDemoConstants.Defaults.recordType - ) ?? MistDemoConstants.Defaults.recordType - let recordName = configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordName - ) + configReader.read(MistDemoKeys.Record.recordType) + let recordName = configReader.read(MistDemoKeys.Record.recordName) // Parse fields from various sources let fields = try Self.parseFieldsFromSources(configReader) // Parse output format let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( @@ -126,7 +115,7 @@ public struct CreateConfig: Sendable, ConfigurationParseable { var fields: [Field] = [] // 1. Parse inline field definitions - if let fieldString = configReader.string(forKey: "field") { + if let fieldString = configReader.read(MistDemoKeys.Record.field) { let fieldDefinitions = fieldString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } @@ -135,18 +124,13 @@ public struct CreateConfig: Sendable, ConfigurationParseable { } // 2. Parse from JSON file - if let jsonFile = configReader.string( - forKey: MistDemoConstants.ConfigKeys.jsonFile - ) { + if let jsonFile = configReader.read(MistDemoKeys.Record.jsonFile) { let jsonFields = try parseFieldsFromJSONFile(jsonFile) fields.append(contentsOf: jsonFields) } // 3. Parse from stdin (check if data is available) - if configReader.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configReader.read(MistDemoKeys.Record.stdin) { let stdinFields = try parseFieldsFromStdin() fields.append(contentsOf: stdinFields) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift index 076f2d785..33079654f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateTokenConfig.swift @@ -81,17 +81,14 @@ public struct CreateTokenConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( base: baseConfig, - apnsToken: configuration.string(forKey: "apns-token"), - apnsEnvironment: configuration.string(forKey: "apns-environment"), - clientId: configuration.string(forKey: "client-id"), + apnsToken: configuration.read(MistDemoKeys.Subscription.apnsToken), + apnsEnvironment: configuration.read(MistDemoKeys.Subscription.apnsEnvironment), + clientId: configuration.read(MistDemoKeys.Subscription.clientID), output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift index d00010ebe..b7b0735b5 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CreateZoneConfig.swift @@ -75,7 +75,7 @@ public struct CreateZoneConfig: Sendable, ConfigurationParseable { } guard - let zoneName = configuration.string(forKey: "zone.name"), + let zoneName = configuration.read(MistDemoKeys.Query.zoneName), !zoneName.isEmpty else { throw ConfigurationError.missingRequired( @@ -84,11 +84,10 @@ public struct CreateZoneConfig: Sendable, ConfigurationParseable { ) } - let ownerRecordName = configuration.string(forKey: "zone.owner") + let ownerRecordName = configuration.read(MistDemoKeys.Query.zoneOwner) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift index bc8060077..6d91e21d0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/CurrentUserConfig.swift @@ -73,15 +73,14 @@ public struct CurrentUserConfig: Sendable, ConfigurationParseable { } // Parse fields filter - let fieldsString = configReader.string(forKey: "fields") + let fieldsString = configReader.read(MistDemoKeys.Record.fields) let fields = fieldsString?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } // Parse output format let outputString = - configReader.string(forKey: "output.format", default: "json") - ?? "json" + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift index 2a6ed4bfd..e2b528762 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteConfig.swift @@ -88,35 +88,21 @@ public struct DeleteConfig: Sendable, ConfigurationParseable { } let zone = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone, - default: MistDemoConstants.Defaults.zone - ) ?? MistDemoConstants.Defaults.zone + configReader.read(MistDemoKeys.Query.zone) let recordType = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordType, - default: MistDemoConstants.Defaults.recordType - ) ?? MistDemoConstants.Defaults.recordType + configReader.read(MistDemoKeys.Record.recordType) guard - let recordName = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordName) + let recordName = configReader.read(MistDemoKeys.Record.recordName) else { throw DeleteError.recordNameRequired } - let recordChangeTag = configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordChangeTag - ) - let force = configReader.bool( - forKey: MistDemoConstants.ConfigKeys.force, - default: false - ) + let recordChangeTag = configReader.read(MistDemoKeys.Record.recordChangeTag) + let force = configReader.read(MistDemoKeys.Record.force) let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift index d98988eb9..9fb9f0c58 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DeleteZoneConfig.swift @@ -75,7 +75,7 @@ public struct DeleteZoneConfig: Sendable, ConfigurationParseable { } guard - let zoneName = configuration.string(forKey: "zone.name"), + let zoneName = configuration.read(MistDemoKeys.Query.zoneName), !zoneName.isEmpty else { throw ConfigurationError.missingRequired( @@ -84,11 +84,10 @@ public struct DeleteZoneConfig: Sendable, ConfigurationParseable { ) } - let ownerRecordName = configuration.string(forKey: "zone.owner") + let ownerRecordName = configuration.read(MistDemoKeys.Query.zoneOwner) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift index 8084bb00e..5689a90cd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DemoErrorsConfig.swift @@ -64,8 +64,7 @@ public struct DemoErrorsConfig: Sendable, ConfigurationParseable { } let scenarioString = - configuration.string(forKey: "scenario", default: "all") - ?? "all" + configuration.read(MistDemoKeys.Integration.scenario) guard let scenario = ErrorScenario(rawValue: scenarioString) else { throw DemoErrorsError.invalidScenario(scenarioString) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift index f48fb4940..18dce9e15 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/DiscoverConfig.swift @@ -79,17 +79,11 @@ public struct DiscoverConfig: Sendable, ConfigurationParseable { let emails = Self.parseEmails(from: configuration) let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json let batchSize = - configuration.int( - forKey: MistDemoConstants.ConfigKeys.batchSize, - default: CloudKitService.maxRecordsPerRequest - ) ?? CloudKitService.maxRecordsPerRequest + configuration.read(MistDemoKeys.Record.batchSize) self.init( base: baseConfig, @@ -104,7 +98,7 @@ public struct DiscoverConfig: Sendable, ConfigurationParseable { internal static func parseEmails( from configuration: MistDemoConfiguration ) -> [String] { - if let raw = configuration.string(forKey: "discover.emails"), + if let raw = configuration.read(MistDemoKeys.Integration.discoverEmails), !raw.isEmpty { return @@ -114,10 +108,7 @@ public struct DiscoverConfig: Sendable, ConfigurationParseable { .filter { !$0.isEmpty } } - if configuration.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configuration.read(MistDemoKeys.Record.stdin) { let stdinData = FileHandle.standardInput.readDataToEndOfFile() guard let raw = String(data: stdinData, encoding: .utf8) else { return [] diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift index b77687623..9cb0aa91e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchChangesConfig.swift @@ -89,22 +89,18 @@ public struct FetchChangesConfig: Sendable, ConfigurationParseable { ) } - let syncToken = configuration.string(forKey: "sync.token") + let syncToken = configuration.read(MistDemoKeys.Changes.syncToken) let zone = - configuration.string(forKey: "zone", default: "_defaultZone") - ?? "_defaultZone" + configuration.read(MistDemoKeys.Query.zone) let fetchAll = - configuration.bool(forKey: "fetch.all", default: false) - let limit = configuration.int(forKey: "limit") - let desiredKeys = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) + configuration.read(MistDemoKeys.Changes.fetchAll) + let limit = configuration.read(MistDemoKeys.Query.optionalLimit) + let desiredKeys = configuration.commaSeparatedList(MistDemoKeys.Record.fields) let desiredRecordTypes = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.desiredRecordTypes + MistDemoKeys.Changes.desiredRecordTypes ) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift index 6147c3198..1576a6435 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchDatabaseChangesConfig.swift @@ -77,13 +77,12 @@ public struct FetchDatabaseChangesConfig: Sendable, ConfigurationParseable { ) } - let syncToken = configuration.string(forKey: "sync.token") + let syncToken = configuration.read(MistDemoKeys.Changes.syncToken) let fetchAll = - configuration.bool(forKey: "fetch.all", default: false) - let limit = configuration.int(forKey: "limit") + configuration.read(MistDemoKeys.Changes.fetchAll) + let limit = configuration.read(MistDemoKeys.Query.optionalLimit) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift index c4ccb2360..de40f3de6 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/FetchZoneRecordChangesConfig.swift @@ -91,25 +91,21 @@ public struct FetchZoneRecordChangesConfig: Sendable, ConfigurationParseable { } let zonesString = - configuration.string(forKey: "zone.names", default: "_defaultZone") - ?? "_defaultZone" + configuration.read(MistDemoKeys.Query.zoneNames) let zones = zonesString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } - let syncToken = configuration.string(forKey: "sync.token") + let syncToken = configuration.read(MistDemoKeys.Changes.syncToken) let fetchAll = - configuration.bool(forKey: "fetch.all", default: false) - let limit = configuration.int(forKey: "limit") - let desiredKeys = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) + configuration.read(MistDemoKeys.Changes.fetchAll) + let limit = configuration.read(MistDemoKeys.Query.optionalLimit) + let desiredKeys = configuration.commaSeparatedList(MistDemoKeys.Record.fields) let desiredRecordTypes = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.desiredRecordTypes + MistDemoKeys.Changes.desiredRecordTypes ) let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Asset.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Asset.swift new file mode 100644 index 000000000..10419754b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Asset.swift @@ -0,0 +1,65 @@ +// +// MistDemoKeys+Asset.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Asset upload and re-reference keys. + internal enum Asset { + /// `--file` / `CLOUDKIT_FILE`. + internal static let file = OptionalConfigKey( + "file", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--field-name` / `CLOUDKIT_FIELD_NAME`. + internal static let fieldName = ConfigKey( + "field-name", envPrefix: MistDemoKeys.envPrefix, default: "image" + ) + + /// `--source-record` / `CLOUDKIT_SOURCE_RECORD`. + internal static let sourceRecord = OptionalConfigKey( + "source-record", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--asset-field` / `CLOUDKIT_ASSET_FIELD`. + internal static let assetField = OptionalConfigKey( + "asset-field", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--target-record` / `CLOUDKIT_TARGET_RECORD`. + internal static let targetRecord = OptionalConfigKey( + "target-record", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--target-asset-field` / `CLOUDKIT_TARGET_ASSET_FIELD`. + internal static let targetAssetField = OptionalConfigKey( + "target-asset-field", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Auth.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Auth.swift new file mode 100644 index 000000000..68d86e2f9 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Auth.swift @@ -0,0 +1,87 @@ +// +// MistDemoKeys+Auth.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Web-auth and sharee credential keys. + /// + /// These are MistDemo-only — neither BushelCloud nor CelestraCloud models web auth — + /// so they keep their historical bases plus ``MistDemoKeys/envPrefix``. + internal enum Auth { + /// `--api-token` / `CLOUDKIT_API_TOKEN`. + internal static let apiToken = ConfigKey( + "api.token", + envPrefix: MistDemoKeys.envPrefix, + default: "", + isSecret: true + ) + + /// `--web-auth-token` / `CLOUDKIT_WEB_AUTH_TOKEN`. + internal static let webAuthToken = OptionalConfigKey( + "web.auth.token", + envPrefix: MistDemoKeys.envPrefix, + isSecret: true + ) + + /// `--sharee-web-auth-token` / `CLOUDKIT_SHAREE_WEB_AUTH_TOKEN`. + internal static let shareeWebAuthToken = OptionalConfigKey( + "sharee.web.auth.token", + envPrefix: MistDemoKeys.envPrefix, + isSecret: true + ) + + /// `--sharee-email` / `CLOUDKIT_SHAREE_EMAIL`. + internal static let shareeEmail = OptionalConfigKey( + "sharee.email", + envPrefix: MistDemoKeys.envPrefix + ) + + /// `--reset-auth` / `CLOUDKIT_RESET_AUTH`. + internal static let resetAuth = ConfigKey( + "reset.auth", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + + /// `--skip-auth` / `CLOUDKIT_SKIP_AUTH`. + internal static let skipAuth = ConfigKey( + "skip.auth", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + + /// `--bad-credentials` / `CLOUDKIT_BAD_CREDENTIALS`. + internal static let badCredentials = ConfigKey( + "bad.credentials", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+AuthModes.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+AuthModes.swift new file mode 100644 index 000000000..94e599a5f --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+AuthModes.swift @@ -0,0 +1,55 @@ +// +// MistDemoKeys+AuthModes.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Flags selecting which authentication modes the demo exercises. + internal enum AuthModes { + /// `--test-all-auth` / `CLOUDKIT_TEST_ALL_AUTH`. + internal static let testAllAuth = ConfigKey( + "test.all.auth", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--test-api-only` / `CLOUDKIT_TEST_API_ONLY`. + internal static let testAPIOnly = ConfigKey( + "test.api.only", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--test-adaptive` / `CLOUDKIT_TEST_ADAPTIVE`. + internal static let testAdaptive = ConfigKey( + "test.adaptive", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--test-server-to-server` / `CLOUDKIT_TEST_SERVER_TO_SERVER`. + internal static let testServerToServer = ConfigKey( + "test.server.to.server", envPrefix: MistDemoKeys.envPrefix, default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Changes.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Changes.swift new file mode 100644 index 000000000..022792489 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Changes.swift @@ -0,0 +1,50 @@ +// +// MistDemoKeys+Changes.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Change-tracking keys for the `fetch-*-changes` commands. + internal enum Changes { + /// `--sync-token` / `CLOUDKIT_SYNC_TOKEN`. + internal static let syncToken = OptionalConfigKey( + "sync.token", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--fetch-all` / `CLOUDKIT_FETCH_ALL`, auto-paginate. + internal static let fetchAll = ConfigKey( + "fetch.all", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--record-types` / `CLOUDKIT_RECORD_TYPES`, comma separated. + internal static let desiredRecordTypes = OptionalConfigKey( + "record.types", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Integration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Integration.swift new file mode 100644 index 000000000..d0c90545e --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Integration.swift @@ -0,0 +1,81 @@ +// +// MistDemoKeys+Integration.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Keys for the `test-public` / `test-private` integration runners and the + /// error/validation demos. + internal enum Integration { + /// `--record-count` / `CLOUDKIT_RECORD_COUNT`. + internal static let recordCount = ConfigKey( + "record.count", envPrefix: MistDemoKeys.envPrefix, default: 10 + ) + + /// `--asset-size` / `CLOUDKIT_ASSET_SIZE`, in bytes. + internal static let assetSize = ConfigKey( + "asset.size", envPrefix: MistDemoKeys.envPrefix, default: 100 + ) + + /// `--skip-cleanup` / `CLOUDKIT_SKIP_CLEANUP`. + internal static let skipCleanup = ConfigKey( + "skip.cleanup", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--lookup-email` / `CLOUDKIT_LOOKUP_EMAIL`. + internal static let lookupEmail = OptionalConfigKey( + "lookup.email", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--share-short-guid` / `CLOUDKIT_SHARE_SHORT_GUID`. + internal static let shareShortGUID = OptionalConfigKey( + "share.short.guid", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--discover-emails` / `CLOUDKIT_DISCOVER_EMAILS`, comma separated. + internal static let discoverEmails = OptionalConfigKey( + "discover.emails", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--scenario` / `CLOUDKIT_SCENARIO`. + internal static let scenario = ConfigKey( + "scenario", envPrefix: MistDemoKeys.envPrefix, default: "all" + ) + + /// `--validate-skip-network` / `CLOUDKIT_VALIDATE_SKIP_NETWORK`. + internal static let validateSkipNetwork = ConfigKey( + "validate.skip-network", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--validate-test-query` / `CLOUDKIT_VALIDATE_TEST_QUERY`. + internal static let validateTestQuery = ConfigKey( + "validate.test-query", envPrefix: MistDemoKeys.envPrefix, default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Output.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Output.swift new file mode 100644 index 000000000..7e8633df2 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Output.swift @@ -0,0 +1,52 @@ +// +// MistDemoKeys+Output.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Presentation keys. + internal enum Output { + /// `--output-format` / `CLOUDKIT_OUTPUT_FORMAT`. + /// + /// One key with **one** default for every command. Previously read at 25 sites with + /// three different defaults (`Defaults.outputFormat`, a literal `"json"`, and a + /// literal `"table"`); `table` is now the single human-facing default and + /// `--output-format json` opts back in. + internal static let format = ConfigKey( + "output.format", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.outputFormat + ) + + /// `--verbose` / `CLOUDKIT_VERBOSE`. + internal static let verbose = ConfigKey( + "verbose", envPrefix: MistDemoKeys.envPrefix, default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Query.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Query.swift new file mode 100644 index 000000000..f1f3ee864 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Query.swift @@ -0,0 +1,104 @@ +// +// MistDemoKeys+Query.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Query, zone-selection and pagination keys. + internal enum Query { + /// `--zone` / `CLOUDKIT_ZONE`. + internal static let zone = ConfigKey( + "zone", envPrefix: MistDemoKeys.envPrefix, default: MistDemoConstants.Defaults.zone + ) + + /// `--zone` without a default, for commands where it is optional. + internal static let optionalZone = OptionalConfigKey( + "zone", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--zone-owner` / `CLOUDKIT_ZONE_OWNER`, the `ownerName` of a shared zone. + internal static let zoneOwner = OptionalConfigKey( + "zone.owner", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--zone-name` / `CLOUDKIT_ZONE_NAME`. + internal static let zoneName = OptionalConfigKey( + "zone.name", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--zone-names` / `CLOUDKIT_ZONE_NAMES`, comma separated. + internal static let zoneNames = ConfigKey( + "zone.names", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.zone + ) + + /// `--zones-include-default` / `CLOUDKIT_ZONES_INCLUDE_DEFAULT`. + internal static let zonesIncludeDefault = ConfigKey( + "zones.include-default", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--zone-wide` / `CLOUDKIT_ZONE_WIDE`, query across all zones. + internal static let zoneWide = OptionalConfigKey( + "zone.wide", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--filter` / `CLOUDKIT_FILTER`, pipe separated. + internal static let filter = OptionalConfigKey( + "filter", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--sort` / `CLOUDKIT_SORT`. + internal static let sort = OptionalConfigKey( + "sort", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--limit` / `CLOUDKIT_LIMIT`, with a default for `query`. + internal static let limit = ConfigKey( + "limit", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.queryLimit + ) + + /// `--limit` without a default, for the change-tracking commands. + internal static let optionalLimit = OptionalConfigKey( + "limit", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--offset` / `CLOUDKIT_OFFSET`. + internal static let offset = ConfigKey( + "offset", envPrefix: MistDemoKeys.envPrefix, default: 0 + ) + + /// `--continuation-marker` / `CLOUDKIT_CONTINUATION_MARKER`. + internal static let continuationMarker = OptionalConfigKey( + "continuation.marker", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Record.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Record.swift new file mode 100644 index 000000000..47d933103 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Record.swift @@ -0,0 +1,118 @@ +// +// MistDemoKeys+Record.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import MistKit + +extension MistDemoKeys { + /// Record identity and payload keys shared by the CRUD commands. + internal enum Record { + /// `--record-type` / `CLOUDKIT_RECORD_TYPE`. + /// + /// The former `record.type` and `record-type` spellings both encoded to this same + /// flag and variable, so they were already aliases; they are unified here. + internal static let recordType = ConfigKey( + "record-type", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.recordType + ) + + /// `--record-type` without a default, for commands where it is optional. + internal static let optionalRecordType = OptionalConfigKey( + "record-type", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--alternate-record-type` / `CLOUDKIT_ALTERNATE_RECORD_TYPE`. + internal static let alternateRecordType = ConfigKey( + "alternate-record-type", envPrefix: MistDemoKeys.envPrefix, default: "Article" + ) + + /// `--record-name` / `CLOUDKIT_RECORD_NAME`. + internal static let recordName = OptionalConfigKey( + "record-name", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--record-names` / `CLOUDKIT_RECORD_NAMES`, comma separated. + internal static let recordNames = OptionalConfigKey( + "record.names", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--record-change-tag` / `CLOUDKIT_RECORD_CHANGE_TAG`. + internal static let recordChangeTag = OptionalConfigKey( + "record.change.tag", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--fields` / `CLOUDKIT_FIELDS`. + internal static let fields = OptionalConfigKey( + "fields", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--field` / `CLOUDKIT_FIELD`, repeated or comma separated. + internal static let field = OptionalConfigKey( + "field", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--json-file` / `CLOUDKIT_JSON_FILE`. + internal static let jsonFile = OptionalConfigKey( + "json.file", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--operations-file` / `CLOUDKIT_OPERATIONS_FILE`. + internal static let operationsFile = OptionalConfigKey( + "operations.file", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--stdin` / `CLOUDKIT_STDIN`. + internal static let stdin = ConfigKey( + "stdin", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--force` / `CLOUDKIT_FORCE`. + internal static let force = ConfigKey( + "force", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--atomic` / `CLOUDKIT_ATOMIC`. + internal static let atomic = ConfigKey( + "atomic", envPrefix: MistDemoKeys.envPrefix, default: false + ) + + /// `--batch-size` / `CLOUDKIT_BATCH_SIZE`. + internal static let batchSize = ConfigKey( + "batch.size", + envPrefix: MistDemoKeys.envPrefix, + default: CloudKitService.maxRecordsPerRequest + ) + + /// `--numbers-as-strings` / `CLOUDKIT_NUMBERS_AS_STRINGS`. + internal static let numbersAsStrings = OptionalConfigKey( + "numbers.as.strings", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Server.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Server.swift new file mode 100644 index 000000000..03a5ed872 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Server.swift @@ -0,0 +1,81 @@ +// +// MistDemoKeys+Server.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Local web-server and browser keys used by `web` and the auth-token flows. + internal enum Server { + /// `--database` / `CLOUDKIT_DATABASE`. + /// + /// Defaults to `public`, matching the historical runtime default. The former + /// `MistDemoConstants.Defaults.database` constant said `private`, contradicted the + /// code, and was never read. + internal static let database = ConfigKey( + "database", + envPrefix: MistDemoKeys.envPrefix, + default: "public" + ) + + /// `--host` / `CLOUDKIT_HOST`. + internal static let host = ConfigKey( + "host", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.host + ) + + /// `--port` / `CLOUDKIT_PORT`. + internal static let port = ConfigKey( + "port", + envPrefix: MistDemoKeys.envPrefix, + default: MistDemoConstants.Defaults.port + ) + + /// `--auth-timeout` / `CLOUDKIT_AUTH_TIMEOUT`, in seconds. + internal static let authTimeout = ConfigKey( + "auth.timeout", + envPrefix: MistDemoKeys.envPrefix, + default: 300 + ) + + /// `--browser` / `CLOUDKIT_BROWSER`. + internal static let browser = ConfigKey( + "browser", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + + /// `--no-browser` / `CLOUDKIT_NO_BROWSER`. + internal static let noBrowser = ConfigKey( + "no.browser", + envPrefix: MistDemoKeys.envPrefix, + default: false + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Sharing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Sharing.swift new file mode 100644 index 000000000..018dace2b --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Sharing.swift @@ -0,0 +1,50 @@ +// +// MistDemoKeys+Sharing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Share resolve/accept keys. + internal enum Sharing { + /// `--short-guid` / `CLOUDKIT_SHORT_GUID`, comma separated. + internal static let shortGUID = OptionalConfigKey( + "short.guid", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--share-url` / `CLOUDKIT_SHARE_URL`, comma separated. + internal static let shareURL = OptionalConfigKey( + "share.url", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--fetch-root-record` / `CLOUDKIT_FETCH_ROOT_RECORD`. + internal static let fetchRootRecord = OptionalConfigKey( + "fetch.root.record", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Subscription.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Subscription.swift new file mode 100644 index 000000000..9e68b5e85 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys+Subscription.swift @@ -0,0 +1,70 @@ +// +// MistDemoKeys+Subscription.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit + +extension MistDemoKeys { + /// Subscription and push-token keys. + internal enum Subscription { + /// `--subscription-id` / `CLOUDKIT_SUBSCRIPTION_ID`. + internal static let subscriptionID = OptionalConfigKey( + "subscription-id", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--subscription-ids` / `CLOUDKIT_SUBSCRIPTION_IDS`, comma separated. + internal static let subscriptionIDs = ConfigKey( + "subscription-ids", envPrefix: MistDemoKeys.envPrefix, default: "" + ) + + /// `--fires-on` / `CLOUDKIT_FIRES_ON`, comma separated. + internal static let firesOn = ConfigKey( + "fires-on", envPrefix: MistDemoKeys.envPrefix, default: "create,update,delete" + ) + + /// `--operation` / `CLOUDKIT_OPERATION`. + internal static let operation = ConfigKey( + "operation", envPrefix: MistDemoKeys.envPrefix, default: "create" + ) + + /// `--apns-token` / `CLOUDKIT_APNS_TOKEN`. + internal static let apnsToken = OptionalConfigKey( + "apns-token", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--apns-environment` / `CLOUDKIT_APNS_ENVIRONMENT`. + internal static let apnsEnvironment = OptionalConfigKey( + "apns-environment", envPrefix: MistDemoKeys.envPrefix + ) + + /// `--client-id` / `CLOUDKIT_CLIENT_ID`. + internal static let clientID = OptionalConfigKey( + "client-id", envPrefix: MistDemoKeys.envPrefix + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys.swift new file mode 100644 index 000000000..c94e5b099 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/Keys/MistDemoKeys.swift @@ -0,0 +1,57 @@ +// +// MistDemoKeys.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import MistKitConfiguration + +/// Typed configuration keys for MistDemo. +/// +/// Every key resolves to a command-line flag and an environment variable: +/// +/// - **CloudKit credential keys** come from ``CloudKitConfigurationKeys`` (shared with +/// MistKitConfiguration, BushelCloud and CelestraCloud). +/// - **Every other key** keeps its historical base and passes ``envPrefix``, faithfully +/// reproducing the blanket `prefixKeys(with: "cloudkit")` the provider stack used to +/// apply to the whole key space. Bases stay unchanged so no flag or variable moves. +/// +/// Bases must be **dash-case** within a component (`cloudkit.key-id`, never +/// `cloudkit.key_id`): `CLIKeyEncoder` joins components verbatim, so an underscore +/// survives into an unusable flag and silently defeats secret redaction. +internal enum MistDemoKeys { + /// Environment-variable prefix applied to every non-CloudKit key. + internal static let envPrefix = "CLOUDKIT" + + /// CloudKit credential keys with MistDemo's container default. + /// + /// `environment` is optional on the package key; callers apply + /// ``MistDemoConstants/Defaults/environment`` when the value is absent. + internal static let cloudKit = CloudKitConfigurationKeys( + defaultContainerID: MistDemoConstants.Defaults.containerIdentifier + ) +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift index f5bc571cd..fd2be8911 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListSubscriptionsConfig.swift @@ -67,10 +67,7 @@ public struct ListSubscriptionsConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "table" - ) ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init(base: baseConfig, output: output) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift index d4206f9ef..7ebc1ea37 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ListZonesConfig.swift @@ -71,16 +71,10 @@ public struct ListZonesConfig: Sendable, ConfigurationParseable { ) } - let includeDefault = configuration.bool( - forKey: "zones.include-default", - default: false - ) + let includeDefault = configuration.read(MistDemoKeys.Query.zonesIncludeDefault) let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "table" - ) ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift index edc142898..c1b6a3814 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupConfig.swift @@ -84,15 +84,13 @@ public struct LookupConfig: Sendable, ConfigurationParseable { // --record-names accepts a comma-separated list. // --record-name (singular) also works for a single name. let recordNames: [String] - if let raw = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordNames) { + if let raw = configReader.read(MistDemoKeys.Record.recordNames) { recordNames = raw .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } - } else if let single = configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordName - ) { + } else if let single = configReader.read(MistDemoKeys.Record.recordName) { recordNames = [single] } else { recordNames = [] @@ -102,26 +100,18 @@ public struct LookupConfig: Sendable, ConfigurationParseable { throw LookupError.recordNamesRequired } - let fieldsString = configReader.string( - forKey: MistDemoConstants.ConfigKeys.fields - ) + let fieldsString = configReader.read(MistDemoKeys.Record.fields) let fields = fieldsString? .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespaces) } .filter { !$0.isEmpty } let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json let batchSize = - configReader.int( - forKey: MistDemoConstants.ConfigKeys.batchSize, - default: CloudKitService.maxRecordsPerRequest - ) ?? CloudKitService.maxRecordsPerRequest + configReader.read(MistDemoKeys.Record.batchSize) self.init( base: baseConfig, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift index ac5e23b25..a17267e21 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupSubscriptionConfig.swift @@ -70,7 +70,7 @@ public struct LookupSubscriptionConfig: Sendable, ConfigurationParseable { ) } - let idsString = configuration.string(forKey: "subscription-ids") ?? "" + let idsString = configuration.read(MistDemoKeys.Subscription.subscriptionIDs) let subscriptionIDs = idsString .split(separator: ",") @@ -78,10 +78,7 @@ public struct LookupSubscriptionConfig: Sendable, ConfigurationParseable { .filter { !$0.isEmpty } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift index f85215ba2..d506cada3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/LookupZonesConfig.swift @@ -71,17 +71,13 @@ public struct LookupZonesConfig: Sendable, ConfigurationParseable { } let zoneNamesString = - configuration.string( - forKey: "zone.names", - default: "_defaultZone" - ) ?? "_defaultZone" + configuration.read(MistDemoKeys.Query.zoneNames) let zoneNames = zoneNamesString.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) } let outputString = - configuration.string(forKey: "output.format", default: "table") - ?? "table" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .table self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift index a793b4c66..b9c7a20e9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig+Parsing.swift @@ -28,6 +28,7 @@ // internal import MistKit +internal import MistKitConfiguration extension MistDemoConfig { internal struct CoreConfig { @@ -62,21 +63,14 @@ extension MistDemoConfig { _ config: MistDemoConfiguration ) throws -> CoreConfig { let containerIdentifier = - config.string( - forKey: "container.identifier", - default: MistDemoConstants.Defaults.containerIdentifier - ) ?? MistDemoConstants.Defaults.containerIdentifier + config.read(MistDemoKeys.cloudKit.containerID) let apiToken = - config.string( - forKey: "api.token", - default: "", - isSecret: true - ) ?? "" + config.read(MistDemoKeys.Auth.apiToken) - let defaultEnv = MistKit.Environment.development.rawValue + let defaultEnv = MistDemoConstants.Defaults.environment let envString = - config.string(forKey: "environment", default: defaultEnv) ?? defaultEnv + config.read(MistDemoKeys.cloudKit.environment) ?? defaultEnv guard let environment = MistKit.Environment(caseInsensitive: envString) else { throw ConfigurationError.invalidEnvironment(envString) } @@ -92,18 +86,10 @@ extension MistDemoConfig { _ config: MistDemoConfiguration ) -> AuthConfig { AuthConfig( - webAuthToken: config.string( - forKey: "web.auth.token", - isSecret: true - ), - keyID: config.string(forKey: "key.id"), - privateKey: config.string( - forKey: "private.key", - isSecret: true - ), - privateKeyFile: config.string( - forKey: "private.key.path" - ) + webAuthToken: config.read(MistDemoKeys.Auth.webAuthToken), + keyID: config.read(MistDemoKeys.cloudKit.keyID), + privateKey: config.read(MistDemoKeys.cloudKit.privateKey), + privateKeyFile: config.read(MistDemoKeys.cloudKit.privateKeyPath) ) } @@ -111,22 +97,13 @@ extension MistDemoConfig { _ config: MistDemoConfiguration ) -> ServerConfig { let host = - config.string( - forKey: "host", - default: "127.0.0.1" - ) ?? "127.0.0.1" + config.read(MistDemoKeys.Server.host) let port = - config.int( - forKey: "port", - default: 8_080 - ) ?? 8_080 + config.read(MistDemoKeys.Server.port) let authTimeout = Double( - config.int( - forKey: "auth.timeout", - default: 300 - ) ?? 300 + config.read(MistDemoKeys.Server.authTimeout) ) return ServerConfig( @@ -140,30 +117,12 @@ extension MistDemoConfig { _ config: MistDemoConfiguration ) -> FlagConfig { FlagConfig( - skipAuth: config.bool( - forKey: "skip.auth", - default: false - ), - testAllAuth: config.bool( - forKey: "test.all.auth", - default: false - ), - testApiOnly: config.bool( - forKey: "test.api.only", - default: false - ), - testAdaptive: config.bool( - forKey: "test.adaptive", - default: false - ), - testServerToServer: config.bool( - forKey: "test.server.to.server", - default: false - ), - badCredentials: config.bool( - forKey: "bad.credentials", - default: false - ) + skipAuth: config.read(MistDemoKeys.Auth.skipAuth), + testAllAuth: config.read(MistDemoKeys.AuthModes.testAllAuth), + testApiOnly: config.read(MistDemoKeys.AuthModes.testAPIOnly), + testAdaptive: config.read(MistDemoKeys.AuthModes.testAdaptive), + testServerToServer: config.read(MistDemoKeys.AuthModes.testServerToServer), + badCredentials: config.read(MistDemoKeys.Auth.badCredentials) ) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift index 858dd4eb2..6aff4a406 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfig.swift @@ -105,7 +105,7 @@ public struct MistDemoConfig: Sendable, ConfigurationParseable { self.environment = core.environment let databaseString = - config.string(forKey: "database", default: "public") ?? "public" + config.read(MistDemoKeys.Server.database) guard let database = MistDemoConfig.parseDatabase(databaseString) else { throw ConfigurationError.invalidDatabase(databaseString) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift index eee3b02f9..2803ca63e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/MistDemoConfiguration.swift @@ -27,11 +27,16 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit internal import Configuration internal import Foundation internal import SystemPackage /// Swift Configuration-based setup for MistDemo. +/// +/// Wraps a `ConfigReader` and resolves ``MistDemoKeys`` values through ConfigKeyKit's +/// ``ConfigValueReading``, which consults the command line first and then the +/// environment before falling back to each key's own default. public struct MistDemoConfiguration: Sendable { // MARK: Private @@ -46,122 +51,82 @@ public struct MistDemoConfiguration: Sendable { allowMissing: true ) + // No `prefixKeys(with: "cloudkit")`: the `CLOUDKIT_` namespace now rides on each + // key via `envPrefix` (or a `cloudkit.` base), so prefixing here would produce + // `CLOUDKIT_CLOUDKIT_*`. No `InMemoryProvider` either — defaults live on the keys. self.configReader = ConfigReader(providers: [ - // 1. Command line arguments (highest priority) CommandLineArgumentsProvider(), - - // 2. Process environment variables (CLOUDKIT_ prefix) - EnvironmentVariablesProvider().prefixKeys(with: "cloudkit"), - - // 3. .env file variables (CLOUDKIT_ prefix) - envProvider.prefixKeys(with: "cloudkit"), - - // 4. In-memory defaults (lowest priority) - InMemoryProvider(values: [ - "port": 8_080, - "skip.auth": false, - "test.all.auth": false, - "test.api.only": false, - "test.adaptive": false, - "test.server.to.server": false, - ]), + EnvironmentVariablesProvider(), + envProvider, ]) } - /// Internal initializer for testing with InMemoryProvider. - internal init(testProvider: InMemoryProvider) { - self.configReader = ConfigReader(providers: [ - testProvider - ]) + /// Creates an instance over an injected reader. + /// + /// Tests use this with the real providers over injected argv/environment, so key + /// normalization and value coercion behave exactly as they do in production. + internal init(configReader: ConfigReader) { + self.configReader = configReader } - // MARK: Public - - /// Read string value with hierarchy: CLI -> ENV -> defaults. - public func string( - forKey key: String, - default defaultValue: String? = nil, - isSecret: Bool = false - ) -> String? { - if let defaultValue = defaultValue { - return configReader.string( - forKey: Configuration.ConfigKey(key), - isSecret: isSecret, - default: defaultValue - ) - } else { - return configReader.string( - forKey: Configuration.ConfigKey(key), - isSecret: isSecret - ) - } - } + // MARK: Internal - /// Read required string value. - public func requiredString( - forKey key: String, - isSecret: Bool = false - ) throws -> String { - try configReader.requiredString( - forKey: Configuration.ConfigKey(key), - isSecret: isSecret - ) - } + /// Reads a required string value. + internal func read(_ key: ConfigKeyKit.ConfigKey) -> String { configReader.read(key) } - /// Read int value with hierarchy. - public func int( - forKey key: String, - default defaultValue: Int? = nil - ) -> Int? { - if let defaultValue = defaultValue { - return configReader.int( - forKey: Configuration.ConfigKey(key), - default: defaultValue - ) - } else { - return configReader.int( - forKey: Configuration.ConfigKey(key) - ) - } + /// Reads an optional string value. + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> String? { + configReader.read(key) } - /// Read required int value. - public func requiredInt(forKey key: String) throws -> Int { - try configReader.requiredInt( - forKey: Configuration.ConfigKey(key) - ) - } + /// Reads a required integer value. + internal func read(_ key: ConfigKeyKit.ConfigKey) -> Int { configReader.read(key) } - /// Read bool value with hierarchy. - public func bool( - forKey key: String, - default defaultValue: Bool = false - ) -> Bool { - configReader.bool( - forKey: Configuration.ConfigKey(key), - default: defaultValue - ) - } + /// Reads an optional integer value. + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Int? { configReader.read(key) } - /// Read an optional bool: `nil` when the key is absent, else its parsed value. + /// Reads a required boolean value. /// - /// Distinguishes "flag not provided" (`nil` → omit from the request) from an - /// explicit `false`, unlike `bool(forKey:default:)` which collapses both. - public func optionalBool(forKey key: String) -> Bool? { - string(forKey: key) != nil ? bool(forKey: key) : nil + /// Deliberately **not** ConfigKeyKit's `read(_:)`: that resolves booleans by probing + /// `string(forKey:)`, and swift-configuration's CLI provider surfaces a valueless flag + /// only through `bool(forKey:)`. Routing through the string path would make every bare + /// flag (`--force`, `--stdin`, `--verbose`, …) silently read as its default. + internal func read(_ key: ConfigKeyKit.ConfigKey) -> Bool { + resolveBool(key) ?? key.defaultValue + } + + // swiftlint:disable:next discouraged_optional_boolean + /// Reads an optional boolean, distinguishing "flag absent" from an explicit `false`. + internal func read(_ key: ConfigKeyKit.OptionalConfigKey) -> Bool? { + resolveBool(key) } - /// Read a comma-separated list of strings, or `nil` when the key is absent. - public func commaSeparatedList(forKey key: String) -> [String]? { - string(forKey: key)? + /// Reads a comma-separated list, or `nil` when the key is absent. + internal func commaSeparatedList(_ key: ConfigKeyKit.OptionalConfigKey) -> [String]? { + read(key)? .split(separator: ",") .map { String($0).trimmingCharacters(in: .whitespaces) } } - /// Read a pipe-separated list of strings from configuration. - public func filterStrings(forKey key: String) -> [String] { - string(forKey: key)? + /// Reads a pipe-separated list, empty when the key is absent. + internal func filterStrings(_ key: ConfigKeyKit.OptionalConfigKey) -> [String] { + read(key)? .split(separator: "|") .map { String($0).trimmingCharacters(in: .whitespaces) } ?? [] } + + // MARK: Private + + // swiftlint:disable:next discouraged_optional_boolean + /// Resolves a boolean across sources via `bool(forKey:)`, which reports a valueless + /// command-line flag as `true`, an absent key as `nil`, and `false`/`no`/`0` as `false`. + private func resolveBool(_ key: any ConfigurationKey) -> Bool? { + for source in ConfigKeySource.priority { + guard let keyString = key.key(for: source) else { continue } + if let value = configReader.bool(forKey: Configuration.ConfigKey(keyString)) { + return value + } + } + return nil + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift index 9625d1449..945726840 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyConfig.swift @@ -92,26 +92,14 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { configReader ) - let atomic = configReader.bool( - forKey: MistDemoConstants.ConfigKeys.atomic, - default: false - ) + let atomic = configReader.read(MistDemoKeys.Record.atomic) - let zone = configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone - ) - let desiredKeys = configReader.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) - let numbersAsStrings = configReader.optionalBool( - forKey: MistDemoConstants.ConfigKeys.numbersAsStrings - ) + let zone = configReader.read(MistDemoKeys.Query.optionalZone) + let desiredKeys = configReader.commaSeparatedList(MistDemoKeys.Record.fields) + let numbersAsStrings = configReader.read(MistDemoKeys.Record.numbersAsStrings) let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( @@ -146,9 +134,7 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { private static func parseOperationsFromSources( _ configReader: MistDemoConfiguration ) throws -> [ModifyOperationInput] { - if let path = configReader.string( - forKey: MistDemoConstants.ConfigKeys.operationsFile - ) { + if let path = configReader.read(MistDemoKeys.Record.operationsFile) { do { let data = try Data( contentsOf: URL(fileURLWithPath: path) @@ -164,10 +150,7 @@ public struct ModifyConfig: Sendable, ConfigurationParseable { } } - if configReader.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configReader.read(MistDemoKeys.Record.stdin) { let stdinData = FileHandle.standardInput.readDataToEndOfFile() guard !stdinData.isEmpty else { throw ModifyError.emptyStdin diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift index fc5c04d8e..2bb011b02 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifySubscriptionsConfig.swift @@ -82,7 +82,7 @@ public struct ModifySubscriptionsConfig: Sendable, ConfigurationParseable { ) } - let firesOnString = configuration.string(forKey: "fires-on") ?? "create,update,delete" + let firesOnString = configuration.read(MistDemoKeys.Subscription.firesOn) let firesOn = firesOnString .split(separator: ",") @@ -90,17 +90,14 @@ public struct ModifySubscriptionsConfig: Sendable, ConfigurationParseable { .filter { !$0.isEmpty } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( base: baseConfig, - operation: configuration.string(forKey: "operation", default: "create") ?? "create", - subscriptionID: configuration.string(forKey: "subscription-id"), - recordType: configuration.string(forKey: "record-type"), + operation: configuration.read(MistDemoKeys.Subscription.operation), + subscriptionID: configuration.read(MistDemoKeys.Subscription.subscriptionID), + recordType: configuration.read(MistDemoKeys.Record.optionalRecordType), firesOn: firesOn, output: output ) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift index b9a9d8f7e..359dc026e 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ModifyZonesConfig.swift @@ -73,10 +73,7 @@ public struct ModifyZonesConfig: Sendable, ConfigurationParseable { let operations = try Self.parseOperationsFromSources(configuration) let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( @@ -108,9 +105,7 @@ public struct ModifyZonesConfig: Sendable, ConfigurationParseable { private static func parseOperationsFromSources( _ configReader: MistDemoConfiguration ) throws -> [ZoneOperationInput] { - if let path = configReader.string( - forKey: MistDemoConstants.ConfigKeys.operationsFile - ) { + if let path = configReader.read(MistDemoKeys.Record.operationsFile) { do { let data = try Data(contentsOf: URL(fileURLWithPath: path)) return try parseOperations(from: data) @@ -124,10 +119,7 @@ public struct ModifyZonesConfig: Sendable, ConfigurationParseable { } } - if configReader.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configReader.read(MistDemoKeys.Record.stdin) { let stdinData = FileHandle.standardInput.readDataToEndOfFile() guard !stdinData.isEmpty else { throw ModifyZonesError.emptyStdin diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift index e0f2bbdef..7a666d448 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ProbeDuplicateSubscriptionConfig.swift @@ -75,10 +75,10 @@ public struct ProbeDuplicateSubscriptionConfig: Sendable, ConfigurationParseable } let recordType = - configuration.string(forKey: "record-type", default: "Note") ?? "Note" + configuration.read(MistDemoKeys.Record.recordType) let alternateRecordType = - configuration.string(forKey: "alternate-record-type", default: "Article") ?? "Article" - let verbose = configuration.bool(forKey: "verbose", default: false) + configuration.read(MistDemoKeys.Record.alternateRecordType) + let verbose = configuration.read(MistDemoKeys.Output.verbose) self.init( base: baseConfig, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift index 3a0fc3f92..88a12d365 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig+Parsing.swift @@ -51,26 +51,14 @@ extension QueryConfig { _ configReader: MistDemoConfiguration ) throws -> ParsedOptions { let zone = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone, - default: MistDemoConstants.Defaults.zone - ) ?? MistDemoConstants.Defaults.zone - let zoneOwner = configReader.string( - forKey: MistDemoConstants.ConfigKeys.zoneOwner - ) + configReader.read(MistDemoKeys.Query.zone) + let zoneOwner = configReader.read(MistDemoKeys.Query.zoneOwner) let recordType = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordType, - default: MistDemoConstants.Defaults.recordType - ) ?? MistDemoConstants.Defaults.recordType + configReader.read(MistDemoKeys.Record.recordType) - let filters = configReader.filterStrings( - forKey: MistDemoConstants.ConfigKeys.filter - ) + let filters = configReader.filterStrings(MistDemoKeys.Query.filter) - let sortString = configReader.string( - forKey: MistDemoConstants.ConfigKeys.sort - ) + let sortString = configReader.read(MistDemoKeys.Query.sort) let sort = try parseSortOption(sortString) let pagination = try parsePagination(configReader) @@ -89,10 +77,7 @@ extension QueryConfig { _ configReader: MistDemoConfiguration ) throws -> ParsedPagination { let limit = - configReader.int( - forKey: MistDemoConstants.ConfigKeys.limit, - default: MistDemoConstants.Defaults.queryLimit - ) ?? MistDemoConstants.Defaults.queryLimit + configReader.read(MistDemoKeys.Query.limit) guard limit >= MistDemoConstants.Limits.minQueryLimit, limit <= MistDemoConstants.Limits.maxQueryLimit @@ -101,24 +86,17 @@ extension QueryConfig { } let offset = - configReader.int(forKey: "offset", default: 0) ?? 0 + configReader.read(MistDemoKeys.Query.offset) - let fieldsString = configReader.string( - forKey: MistDemoConstants.ConfigKeys.fields - ) + let fieldsString = configReader.read(MistDemoKeys.Record.fields) let fields = fieldsString?.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } - let continuationMarker = configReader.string( - forKey: "continuation.marker" - ) + let continuationMarker = configReader.read(MistDemoKeys.Query.continuationMarker) let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json return ParsedPagination( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift index 6dc91beb4..9313987dd 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/QueryConfig.swift @@ -125,12 +125,8 @@ public struct QueryConfig: Sendable, ConfigurationParseable { offset: parsed.pagination.offset, fields: parsed.pagination.fields, continuationMarker: parsed.pagination.continuationMarker, - zoneWide: configReader.optionalBool( - forKey: MistDemoConstants.ConfigKeys.zoneWide - ), - numbersAsStrings: configReader.optionalBool( - forKey: MistDemoConstants.ConfigKeys.numbersAsStrings - ), + zoneWide: configReader.read(MistDemoKeys.Query.zoneWide), + numbersAsStrings: configReader.read(MistDemoKeys.Record.numbersAsStrings), output: parsed.pagination.output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift index 3ddf574cd..f0a60cde3 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RegisterTokenConfig.swift @@ -83,17 +83,14 @@ public struct RegisterTokenConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( base: baseConfig, - apnsToken: configuration.string(forKey: "apns-token"), - apnsEnvironment: configuration.string(forKey: "apns-environment"), - clientId: configuration.string(forKey: "client-id"), + apnsToken: configuration.read(MistDemoKeys.Subscription.apnsToken), + apnsEnvironment: configuration.read(MistDemoKeys.Subscription.apnsEnvironment), + clientId: configuration.read(MistDemoKeys.Subscription.clientID), output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift index 6a1d8e95a..b3f96f87b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/RereferenceAssetConfig.swift @@ -83,18 +83,15 @@ public struct RereferenceAssetConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( base: baseConfig, - sourceRecord: configuration.string(forKey: "source-record"), - assetField: configuration.string(forKey: "asset-field"), - targetRecord: configuration.string(forKey: "target-record"), - targetAssetField: configuration.string(forKey: "target-asset-field"), + sourceRecord: configuration.read(MistDemoKeys.Asset.sourceRecord), + assetField: configuration.read(MistDemoKeys.Asset.assetField), + targetRecord: configuration.read(MistDemoKeys.Asset.targetRecord), + targetAssetField: configuration.read(MistDemoKeys.Asset.targetAssetField), output: output ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift index fdae5da72..3347229ba 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ResolveConfig.swift @@ -80,18 +80,11 @@ public struct ResolveConfig: Sendable, ConfigurationParseable { } let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: "json" - ) ?? "json" + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json - let fetchRootRecord = configuration.optionalBool( - forKey: "fetch.root.record" - ) - let fields = configuration.commaSeparatedList( - forKey: MistDemoConstants.ConfigKeys.fields - ) + let fetchRootRecord = configuration.read(MistDemoKeys.Sharing.fetchRootRecord) + let fields = configuration.commaSeparatedList(MistDemoKeys.Record.fields) let shortGUIDs = Self.parseShortGUIDs(from: configuration) guard !shortGUIDs.isEmpty else { @@ -114,13 +107,13 @@ public struct ResolveConfig: Sendable, ConfigurationParseable { internal static func parseShortGUIDs( from configuration: MistDemoConfiguration ) -> [String] { - if let fromGUIDs = configuration.commaSeparatedList(forKey: "short.guid"), + if let fromGUIDs = configuration.commaSeparatedList(MistDemoKeys.Sharing.shortGUID), !fromGUIDs.isEmpty { return fromGUIDs } - guard let shareURLs = configuration.commaSeparatedList(forKey: "share.url") + guard let shareURLs = configuration.commaSeparatedList(MistDemoKeys.Sharing.shareURL) else { return [] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift index 77e156b54..6617a5b36 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPrivateConfig.swift @@ -116,10 +116,7 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { } guard - let shareeWebAuthToken = configuration.string( - forKey: "sharee.web.auth.token", - isSecret: true - ), + let shareeWebAuthToken = configuration.read(MistDemoKeys.Auth.shareeWebAuthToken), !shareeWebAuthToken.isEmpty else { throw ConfigurationError.missingRequired( @@ -130,7 +127,7 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { } guard - let shareeEmail = configuration.string(forKey: "sharee.email"), + let shareeEmail = configuration.read(MistDemoKeys.Auth.shareeEmail), !shareeEmail.isEmpty else { throw ConfigurationError.missingRequired( @@ -141,15 +138,15 @@ public struct TestPrivateConfig: Sendable, ConfigurationParseable { } let recordCount = - configuration.int(forKey: "record.count", default: 10) ?? 10 + configuration.read(MistDemoKeys.Integration.recordCount) let assetSizeKB = - configuration.int(forKey: "asset.size", default: 100) ?? 100 + configuration.read(MistDemoKeys.Integration.assetSize) let skipCleanup = - configuration.bool(forKey: "skip.cleanup", default: false) + configuration.read(MistDemoKeys.Integration.skipCleanup) let verbose = - configuration.bool(forKey: "verbose", default: false) - let lookupEmail = configuration.string(forKey: "lookup.email") - let shareShortGUID = configuration.string(forKey: "share.short.guid") + configuration.read(MistDemoKeys.Output.verbose) + let lookupEmail = configuration.read(MistDemoKeys.Integration.lookupEmail) + let shareShortGUID = configuration.read(MistDemoKeys.Integration.shareShortGUID) self.init( base: baseConfig, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift index 3c8ebb858..a7c524101 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/TestPublicConfig.swift @@ -88,15 +88,15 @@ public struct TestPublicConfig: Sendable, ConfigurationParseable { } let recordCount = - configuration.int(forKey: "record.count", default: 10) ?? 10 + configuration.read(MistDemoKeys.Integration.recordCount) let assetSizeKB = - configuration.int(forKey: "asset.size", default: 100) ?? 100 + configuration.read(MistDemoKeys.Integration.assetSize) let skipCleanup = - configuration.bool(forKey: "skip.cleanup", default: false) + configuration.read(MistDemoKeys.Integration.skipCleanup) let verbose = - configuration.bool(forKey: "verbose", default: false) - let lookupEmail = configuration.string(forKey: "lookup.email") - let shareShortGUID = configuration.string(forKey: "share.short.guid") + configuration.read(MistDemoKeys.Output.verbose) + let lookupEmail = configuration.read(MistDemoKeys.Integration.lookupEmail) + let shareShortGUID = configuration.read(MistDemoKeys.Integration.shareShortGUID) self.init( base: baseConfig, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift index 214576e37..2e244aa22 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UpdateConfig.swift @@ -94,40 +94,26 @@ public struct UpdateConfig: Sendable, ConfigurationParseable { // Parse update-specific options let zone = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.zone, - default: MistDemoConstants.Defaults.zone - ) ?? MistDemoConstants.Defaults.zone + configReader.read(MistDemoKeys.Query.zone) let recordType = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordType, - default: MistDemoConstants.Defaults.recordType - ) ?? MistDemoConstants.Defaults.recordType + configReader.read(MistDemoKeys.Record.recordType) // Validate recordName is provided (REQUIRED for update) guard - let recordName = configReader.string(forKey: MistDemoConstants.ConfigKeys.recordName) + let recordName = configReader.read(MistDemoKeys.Record.recordName) else { throw UpdateError.recordNameRequired } - let recordChangeTag = configReader.string( - forKey: MistDemoConstants.ConfigKeys.recordChangeTag - ) - let force = configReader.bool( - forKey: MistDemoConstants.ConfigKeys.force, - default: false - ) + let recordChangeTag = configReader.read(MistDemoKeys.Record.recordChangeTag) + let force = configReader.read(MistDemoKeys.Record.force) // Parse fields from various sources let fields = try Self.parseFieldsFromSources(configReader) // Parse output format let outputString = - configReader.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( @@ -148,7 +134,7 @@ public struct UpdateConfig: Sendable, ConfigurationParseable { var fields: [Field] = [] // 1. Parse inline field definitions - if let fieldString = configReader.string(forKey: "field") { + if let fieldString = configReader.read(MistDemoKeys.Record.field) { let fieldDefinitions = fieldString.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) } @@ -157,18 +143,13 @@ public struct UpdateConfig: Sendable, ConfigurationParseable { } // 2. Parse from JSON file - if let jsonFile = configReader.string( - forKey: MistDemoConstants.ConfigKeys.jsonFile - ) { + if let jsonFile = configReader.read(MistDemoKeys.Record.jsonFile) { let jsonFields = try parseFieldsFromJSONFile(jsonFile) fields.append(contentsOf: jsonFields) } // 3. Parse from stdin (check if data is available) - if configReader.bool( - forKey: MistDemoConstants.ConfigKeys.stdin, - default: false - ) { + if configReader.read(MistDemoKeys.Record.stdin) { let stdinFields = try parseFieldsFromStdin() fields.append(contentsOf: stdinFields) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift index 71f6366a1..acc68b71f 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/UploadAssetConfig.swift @@ -85,25 +85,24 @@ public struct UploadAssetConfig: Sendable, ConfigurationParseable { } // Get file path from configuration - guard let filePath = configReader.string(forKey: "file") else { + guard let filePath = configReader.read(MistDemoKeys.Asset.file) else { throw UploadAssetError.filePathRequired } // Get record type (defaults to "Note") let recordType = - configReader.string(forKey: "record-type") ?? "Note" + configReader.read(MistDemoKeys.Record.optionalRecordType) ?? "Note" // Get field name (defaults to "image") let fieldName = - configReader.string(forKey: "field-name") ?? "image" + configReader.read(MistDemoKeys.Asset.fieldName) // Parse optional record name - let recordName = configReader.string(forKey: "record-name") + let recordName = configReader.read(MistDemoKeys.Record.recordName) // Parse output format let outputString = - configReader.string(forKey: "output.format", default: "json") - ?? "json" + configReader.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift index 09a361c1a..5fb7dacb0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/ValidateConfig.swift @@ -76,20 +76,11 @@ public struct ValidateConfig: Sendable, ConfigurationParseable { ) } - let skipNetwork = configuration.bool( - forKey: "validate.skip-network", - default: false - ) - let testQuery = configuration.bool( - forKey: "validate.test-query", - default: false - ) + let skipNetwork = configuration.read(MistDemoKeys.Integration.validateSkipNetwork) + let testQuery = configuration.read(MistDemoKeys.Integration.validateTestQuery) let outputString = - configuration.string( - forKey: MistDemoConstants.ConfigKeys.outputFormat, - default: MistDemoConstants.Defaults.outputFormat - ) ?? MistDemoConstants.Defaults.outputFormat + configuration.read(MistDemoKeys.Output.format) let output = OutputFormat(rawValue: outputString) ?? .json self.init( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift index ebfd8557f..2ce8f4c1a 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Configuration/WebConfig.swift @@ -28,6 +28,7 @@ // public import ConfigKeyKit +internal import MistKitConfiguration internal import Foundation public import MistKit @@ -110,7 +111,7 @@ public struct WebConfig: Sendable, ConfigurationParseable { let configReader = configuration let apiToken = - configReader.string(forKey: "api.token", isSecret: true) ?? "" + configReader.read(MistDemoKeys.Auth.apiToken) guard !apiToken.isEmpty else { throw ConfigurationError.missingRequired( "api.token", @@ -120,34 +121,26 @@ public struct WebConfig: Sendable, ConfigurationParseable { } let containerIdentifier = - configReader.string( - forKey: "container.identifier", - default: MistDemoConstants.Defaults.containerIdentifier - ) ?? MistDemoConstants.Defaults.containerIdentifier + configReader.read(MistDemoKeys.cloudKit.containerID) let envString = - configReader.string(forKey: "environment", default: "development") - ?? "development" + configReader.read(MistDemoKeys.cloudKit.environment) ?? MistDemoConstants.Defaults.environment guard let environment = MistKit.Environment(caseInsensitive: envString) else { throw ConfigurationError.invalidEnvironment(envString) } let port = - configReader.int(forKey: "port", default: 8_080) ?? 8_080 + configReader.read(MistDemoKeys.Server.port) let host = - configReader.string(forKey: "host", default: "127.0.0.1") - ?? "127.0.0.1" + configReader.read(MistDemoKeys.Server.host) let openBrowser = BrowserFlagResolver.resolve( configReader: configReader, default: false ) - let keyID = configReader.string(forKey: "key.id") - let privateKey = configReader.string( - forKey: "private.key", - isSecret: true - ) - let privateKeyFile = configReader.string(forKey: "private.key.path") + let keyID = configReader.read(MistDemoKeys.cloudKit.keyID) + let privateKey = configReader.read(MistDemoKeys.cloudKit.privateKey) + let privateKeyFile = configReader.read(MistDemoKeys.cloudKit.privateKeyPath) self.init( apiToken: apiToken, diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift index afdb10445..81bf34ed0 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants+Defaults.swift @@ -41,13 +41,14 @@ extension MistDemoConstants { /// Default port number. public static let port = 8_080 /// Default output format. - public static let outputFormat = "json" + /// + /// `table` is the human-facing default for every command; pass + /// `--output-format json` for machine-readable output. + public static let outputFormat = "table" /// Default query result limit. public static let queryLimit = 20 /// Default CloudKit environment. public static let environment = "development" - /// Default CloudKit database. - public static let database = "private" /// Default container identifier. public static let containerIdentifier = "iCloud.com.brightdigit.MistDemo" diff --git a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift index 5953a4b1a..5bef8be51 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Constants/MistDemoConstants.swift @@ -31,72 +31,6 @@ internal import Foundation /// Central constants for MistDemo application. public enum MistDemoConstants { - // MARK: - Configuration Keys - - /// Configuration key names used throughout the application. - public enum ConfigKeys { - /// API token configuration key. - public static let apiToken = "api.token" - /// Web auth token configuration key. - public static let webAuthToken = "web.auth.token" - /// Sharee web auth token configuration key. - public static let shareeWebAuthToken = "sharee.web.auth.token" - /// Sharee iCloud email configuration key. - public static let shareeEmail = "sharee.email" - /// Container ID configuration key. - public static let containerID = "container.id" - /// Environment configuration key. - public static let environment = "environment" - /// Database configuration key. - public static let database = "database" - /// Record type configuration key. - public static let recordType = "record.type" - /// Record name configuration key. - public static let recordName = "record.name" - /// Zone configuration key. - public static let zone = "zone" - /// Zone owner configuration key (ownerName for shared zones). - public static let zoneOwner = "zone.owner" - /// Limit configuration key. - public static let limit = "limit" - /// Fields configuration key. - public static let fields = "fields" - /// Output format configuration key. - public static let outputFormat = "output.format" - /// Sort configuration key. - public static let sort = "sort" - /// Filter configuration key. - public static let filter = "filter" - /// No-browser configuration key. - public static let noBrowser = "no.browser" - /// Host configuration key. - public static let host = "host" - /// Port configuration key. - public static let port = "port" - /// JSON file configuration key. - public static let jsonFile = "json.file" - /// Stdin configuration key. - public static let stdin = "stdin" - /// Record change tag configuration key. - public static let recordChangeTag = "record.change.tag" - /// Force configuration key. - public static let force = "force" - /// Record names configuration key. - public static let recordNames = "record.names" - /// Operations file configuration key. - public static let operationsFile = "operations.file" - /// Atomic configuration key. - public static let atomic = "atomic" - /// Batch size configuration key for the auto-chunking `*-all` commands. - public static let batchSize = "batch.size" - /// Zone-wide query configuration key (query across all zones). - public static let zoneWide = "zone.wide" - /// Numbers-as-strings configuration key (return numeric fields as strings). - public static let numbersAsStrings = "numbers.as.strings" - /// Desired record types configuration key (limits the change feed). - public static let desiredRecordTypes = "record.types" - } - // MARK: - Field Names /// Standard CloudKit field names. diff --git a/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift b/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift index ccb5188ec..2f1725f77 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Extensions/FieldValue+FieldType.swift @@ -71,10 +71,12 @@ extension FieldValue { return .date(dateValue) case .bytes: - guard let stringValue = value as? String else { + guard let stringValue = value as? String, + let data = Data(base64Encoded: stringValue) + else { return nil } - return .bytes(stringValue) + return .bytes(data) case .asset: return convertAsset(value: value) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift new file mode 100644 index 000000000..8c468e847 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/DownloadAssetPhase.swift @@ -0,0 +1,87 @@ +// +// DownloadAssetPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Looks up a created record's `image` asset and downloads the CDN bytes, +/// checking the byte count against the asset's declared `size`. +/// +/// Checksum verification is deliberately not requested: CloudKit's +/// `fileChecksum` is an opaque server-minted value, not a digest of the +/// plaintext, so it cannot be recomputed client-side. +internal struct DownloadAssetPhase: IntegrationPhase { + internal typealias Input = CreatedRecordNames + internal typealias Output = NoState + + internal static let title = "Download asset" + internal static let emoji = "📥" + internal static let apiName = "downloadAsset" + + internal func run( + input: CreatedRecordNames, context: PhaseContext + ) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let recordName = input.names.first else { + throw IntegrationTestError.missingPhaseState("createdRecordNames") + } + + let results = try await context.service.lookupRecords( + recordNames: [recordName], + database: context.database + ) + guard case .success(let record)? = results.first else { + throw IntegrationTestError.verificationFailed( + "Lookup of '\(recordName)' did not return a record for asset download" + ) + } + guard case .asset(let asset) = record.fields["image"] else { + throw IntegrationTestError.verificationFailed( + "Record '\(recordName)' has no 'image' asset to download" + ) + } + + if context.verbose { + print(" Record: \(recordName)") + print(" fileChecksum: \(asset.fileChecksum ?? "nil")") + print(" downloadURL: \(asset.downloadURL ?? "nil")") + } + + let data = try await asset.download() + if let declaredSize = asset.size, Int64(data.count) != declaredSize { + throw IntegrationTestError.verificationFailed( + "Downloaded \(data.count) bytes but the asset declares \(declaredSize)" + ) + } + print("✅ Downloaded \(data.count) bytes") + + return NoState() + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift index 84b492780..6c66156d9 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ShareCreateAndAcceptPhase.swift @@ -57,8 +57,22 @@ internal struct ShareCreateAndAcceptPhase: IntegrationPhase { // Distinct Apple IDs are required: inviting yourself is not a useful // create→accept roundtrip. Compare users/caller record names up front. - let sharerIdentity = try await context.service.fetchCaller() - let shareeIdentity = try await shareeService.fetchCaller() + let sharerIdentity: UserInfo + do { + sharerIdentity = try await context.service.fetchCaller() + } catch { + throw IntegrationTestError.verificationFailed( + "sharer users/caller failed: \(error)" + ) + } + let shareeIdentity: UserInfo + do { + shareeIdentity = try await shareeService.fetchCaller() + } catch { + throw IntegrationTestError.verificationFailed( + "sharee users/caller failed: \(error)" + ) + } if sharerIdentity.userRecordName == shareeIdentity.userRecordName { throw IntegrationTestError.shareeSameAsSharer( userRecordName: sharerIdentity.userRecordName diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SharedZoneRoundtripPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SharedZoneRoundtripPhase.swift new file mode 100644 index 000000000..2149151c3 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/SharedZoneRoundtripPhase.swift @@ -0,0 +1,309 @@ +// +// SharedZoneRoundtripPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Live shared-zone write round-trip: sharer creates a custom zone and share, +/// sharee accepts, then sharee update/lookup/zone-changes and zone-scoped asset +/// upload on the **shared root** (`database: .shared`, `zoneID.ownerName` = +/// sharer's user record name). CloudKit does not allow sharees to create +/// arbitrary new top-level records in the zone (ACCESS_DENIED). +/// +/// Self-cleaning. Requires the same sharee credentials as +/// ``ShareCreateAndAcceptPhase``. +internal struct SharedZoneRoundtripPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Shared-zone CRUD and asset round-trip" + internal static let emoji = "🔗" + internal static let apiName = "shared zone write+uploadAssets" + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + guard let shareeService = context.shareeService, + let shareeEmail = context.shareeEmail, + !shareeEmail.isEmpty + else { + throw IntegrationTestError.missingShareeCredentials + } + + let sharerIdentity = try await context.service.fetchCaller() + let shareeIdentity = try await shareeService.fetchCaller() + if sharerIdentity.userRecordName == shareeIdentity.userRecordName { + throw IntegrationTestError.shareeSameAsSharer( + userRecordName: sharerIdentity.userRecordName + ) + } + + let zoneName = "mistkit-shared-crud-\(UUID().uuidString.lowercased())" + let privateZoneID = ZoneID(zoneName: zoneName) + let rootRecordName = "mistkit-shared-root-\(UUID().uuidString.lowercased())" + let sharedDatabase: MistKit.Database = .shared + + _ = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(zoneName)") + print(" Sharer: \(sharerIdentity.userRecordName)") + print(" Sharee: \(shareeIdentity.userRecordName)") + } + + var shareRecordName: String? + do { + let created = try await context.service.createShare( + rootRecordType: MistDemoConfig.recordType, + rootRecordName: rootRecordName, + rootFields: [ + "title": .string("Shared zone root"), + "index": .int64(0), + ], + zoneID: privateZoneID, + publicPermission: .none, + participants: [ + ShareParticipant( + userIdentity: UserIdentity( + lookupInfo: UserIdentityLookupInfo(emailAddress: shareeEmail) + ), + permission: .readWrite, + type: .user, + acceptanceStatus: .invited + ) + ], + database: context.database + ) + shareRecordName = created.shareRecordName + print("✅ Created share \(created.shortGUID)") + + let shortGUID = ShortGUIDDictionary( + value: created.shortGUID, + shouldFetchRootRecord: true + ) + _ = try await shareeService.resolveShares([shortGUID]) + let accepted = try await shareeService.acceptShares([shortGUID]) + guard let acceptInfo = accepted.first else { + throw IntegrationTestError.shareAcceptEmpty + } + if let status = acceptInfo.participantStatus, status == .invited { + throw IntegrationTestError.shareStillInvited + } + print( + "✅ Sharee accepted — status: " + + "\(acceptInfo.participantStatus?.rawValue ?? "-")" + ) + + guard let acceptZoneID = acceptInfo.zoneID else { + throw IntegrationTestError.verificationFailed( + "acceptShares omitted zoneID — cannot address shared DB" + ) + } + // Pin owner to the sharer's users/caller name — accept may omit it. + let sharedZoneID = ZoneID( + zoneName: acceptZoneID.zoneName, + ownerName: acceptZoneID.ownerName ?? sharerIdentity.userRecordName + ) + let sharedRootName = acceptInfo.rootRecordName ?? created.rootRecord.recordName + if context.verbose { + print(" Shared zone: \(sharedZoneID.zoneName) owner \(sharedZoneID.ownerName ?? "-")") + print(" Shared root: \(sharedRootName)") + } + + guard let sharedRoot = acceptInfo.rootRecord else { + throw IntegrationTestError.verificationFailed( + "acceptShares omitted rootRecord (shouldFetchRootRecord was true)" + ) + } + + // Sharees can modify records in the share, not create arbitrary new + // top-level records in the zone (CloudKit returns ACCESS_DENIED). + let updatedRoot = try await shareeService.updateRecord( + recordType: MistDemoConfig.recordType, + recordName: sharedRootName, + fields: [ + "title": .string("Shared CRUD updated"), + "index": .int64(42), + ], + recordChangeTag: sharedRoot.recordChangeTag, + zoneID: sharedZoneID, + database: sharedDatabase + ) + if context.verbose { + print(" ✅ Sharee updated shared root \(updatedRoot.recordName)") + } + + guard updatedRoot.fields["title"] == .string("Shared CRUD updated"), + updatedRoot.fields["index"] == .int64(42) + else { + throw IntegrationTestError.verificationFailed( + "shared update did not round-trip title/index on \(sharedRootName)" + ) + } + if context.verbose { + print(" ✅ Sharee read back updated fields from modify response") + } + + let changeResult = try await shareeService.fetchRecordZoneChanges( + zones: [ZoneChangesRequest(zoneID: sharedZoneID)], + database: sharedDatabase + ) + try ChangeTrackingVerification.requireNoZoneFailures( + changeResult.failures, + operation: "fetchRecordZoneChanges (shared)" + ) + let changeNames = ChangeTrackingVerification.recordNames(in: changeResult.changes) + if !changeNames.contains(sharedRootName) { + throw IntegrationTestError.verificationFailed( + "shared changes/zone missing root \(sharedRootName); found \(changeNames.sorted())" + ) + } + + // Asset must request an upload URL in the same zone as the update. + let png = PNGData.generate(withSizeInKB: min(context.assetSizeKB, 8)) + let receipt = try await shareeService.uploadAssets( + data: png, + recordType: MistDemoConfig.recordType, + fieldName: "image", + recordName: sharedRootName, + zoneID: sharedZoneID, + database: sharedDatabase + ) + let withAsset = try await shareeService.updateRecord( + recordType: MistDemoConfig.recordType, + recordName: sharedRootName, + fields: [ + "title": .string("Shared asset"), + "index": .int64(43), + "image": .asset(receipt.asset), + ], + recordChangeTag: updatedRoot.recordChangeTag, + zoneID: sharedZoneID, + database: sharedDatabase + ) + if context.verbose { + print(" ✅ Sharee uploaded+attached asset on shared root") + } + + _ = withAsset // used for verification via successful update + + print("✅ Shared-zone CRUD and asset round-trip succeeded") + + try await cleanup( + sharer: context.service, + database: context.database, + zoneID: privateZoneID, + rootRecordName: sharedRootName, + shareRecordName: created.shareRecordName, + extraRecordNames: [], + verbose: context.verbose, + skipCleanup: context.skipCleanup + ) + } catch { + try? await cleanup( + sharer: context.service, + database: context.database, + zoneID: privateZoneID, + rootRecordName: rootRecordName, + shareRecordName: shareRecordName, + extraRecordNames: [], + verbose: context.verbose, + skipCleanup: context.skipCleanup + ) + throw error + } + + return NoState() + } + + private func cleanup( + sharer: CloudKitService, + database: MistKit.Database, + zoneID: ZoneID, + rootRecordName: String, + shareRecordName: String?, + extraRecordNames: [String], + verbose: Bool, + skipCleanup: Bool + ) async throws { + if skipCleanup { + print(" ⏭️ Skipping shared-zone cleanup — inspect zone '\(zoneID.zoneName)'") + return + } + + var ops = [ + RecordOperation( + operationType: .forceDelete, + recordType: MistDemoConfig.recordType, + recordName: rootRecordName + ) + ] + for name in extraRecordNames { + ops.append( + RecordOperation( + operationType: .forceDelete, + recordType: MistDemoConfig.recordType, + recordName: name + ) + ) + } + if let shareRecordName { + ops.append( + RecordOperation( + operationType: .forceDelete, + recordType: ShareInfo.recordType, + recordName: shareRecordName + ) + ) + } + do { + _ = try await sharer.modifyRecords(ops, zoneID: zoneID, database: database) + if verbose { + print(" ✅ Deleted shared-zone records in \(zoneID.zoneName)") + } + } catch { + if verbose { + print(" ⚠️ Shared record cleanup failed: \(error)") + } + } + + do { + try await sharer.deleteZone(zoneName: zoneID.zoneName, database: database) + if verbose { + print(" ✅ Deleted zone: \(zoneID.zoneName)") + } + } catch { + if verbose { + print(" ⚠️ Zone cleanup failed: \(error)") + } + } + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift new file mode 100644 index 000000000..b917161c4 --- /dev/null +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Phases/ZonePayloadMetadataPhase.swift @@ -0,0 +1,205 @@ +// +// ZonePayloadMetadataPhase.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Live verification for issue #444: zone list/lookup responses carry +/// `ownerRecordName` and `zoneType`, and `changes/database` surfaces zone +/// deletions via `deleted`. +/// +/// Creates a uniquely-named custom zone, asserts the metadata round-trips on +/// `lookupZones` / `listZones`, then deletes the zone and polls +/// `fetchDatabaseChanges` for a `deleted: true` tombstone. +internal struct ZonePayloadMetadataPhase: IntegrationPhase { + internal typealias Input = NoState + internal typealias Output = NoState + + internal static let title = "Zone payload metadata (ownerRecordName, zoneType, deleted)" + internal static let emoji = "🏷️" + internal static let apiName = "zonePayloadMetadata" + + private static let customZoneType: ZoneType = .regularCustom + private static let changeFeedPollAttempts = 5 + private static let changeFeedPollDelay: Duration = .seconds(2) + + internal func run(input: NoState, context: PhaseContext) async throws -> NoState { + print("\n\(Self.emoji) \(Self.title)") + + let zoneName = "mistkit-zone-payload-\(UUID().uuidString.lowercased())" + let zoneID = ZoneID(zoneName: zoneName) + + let baseline = try await context.service.fetchDatabaseChanges( + database: context.database + ) + try ChangeTrackingVerification.requireNoZoneFailures( + baseline.failures, + operation: "fetchDatabaseChanges (baseline)" + ) + + do { + let created = try await context.service.createZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Created zone: \(created.zoneName)") + } + + let lookedUp = try await context.service.lookupZones( + zoneIDs: [zoneID], + database: context.database + ) + guard let lookupZone = lookedUp.first(where: { $0.zoneName == zoneName }) else { + throw IntegrationTestError.verificationFailed( + "lookupZones did not return the created zone '\(zoneName)'" + ) + } + try Self.requireLiveZoneMetadata(lookupZone, source: "lookupZones") + + let listed = try await context.service.listZones(database: context.database) + guard let listZone = listed.first(where: { $0.zoneName == zoneName }) else { + throw IntegrationTestError.verificationFailed( + "listZones did not include the created zone '\(zoneName)'" + ) + } + try Self.requireLiveZoneMetadata(listZone, source: "listZones") + + let (createdChange, afterCreateDatabaseToken) = try await Self.pollDatabaseChanges( + syncToken: baseline.syncToken, + zoneName: zoneName, + context: context, + description: "zone creation" + ) { $0.deleted != true } + try Self.requireLiveZoneMetadata(createdChange, source: "fetchDatabaseChanges (create)") + + try await context.service.deleteZone( + zoneName: zoneName, + database: context.database + ) + if context.verbose { + print(" ✅ Deleted zone: \(zoneName)") + } + + let (deletedChange, _) = try await Self.pollDatabaseChanges( + syncToken: afterCreateDatabaseToken ?? baseline.syncToken, + zoneName: zoneName, + context: context, + description: "zone deletion tombstone" + ) { $0.deleted == true } + + guard deletedChange.deleted == true else { + throw IntegrationTestError.verificationFailed( + "fetchDatabaseChanges did not mark '\(zoneName)' deleted after deleteZone" + ) + } + + if context.verbose { + print(" ✅ Change feed tombstone: deleted=true for \(zoneName)") + if let owner = deletedChange.ownerRecordName { + print(" Owner: \(owner)") + } + if let zoneType = deletedChange.zoneType { + print(" Zone type: \(zoneType)") + } + } + + print("✅ Zone payload metadata verified for '\(zoneName)'") + return NoState() + } catch { + try? await context.service.deleteZone( + zoneName: zoneName, + database: context.database + ) + throw error + } + } + + private static func requireLiveZoneMetadata( + _ zone: ZoneInfo, + source: String + ) throws { + guard let owner = zone.ownerRecordName, !owner.isEmpty else { + throw IntegrationTestError.verificationFailed( + "\(source) omitted ownerRecordName for zone '\(zone.zoneName)'" + ) + } + guard zone.zoneType == customZoneType else { + throw IntegrationTestError.verificationFailed( + "\(source) reported zoneType '\(zone.zoneType?.rawValue ?? "nil")' for '\(zone.zoneName)' " + + "(expected \(customZoneType.rawValue))" + ) + } + guard zone.deleted != true else { + throw IntegrationTestError.verificationFailed( + "\(source) reported deleted=true for live zone '\(zone.zoneName)'" + ) + } + } + + private static func pollDatabaseChanges( + syncToken: String?, + zoneName: String, + context: PhaseContext, + description: String, + matching predicate: (ZoneInfo) -> Bool + ) async throws -> (zone: ZoneInfo, databaseSyncToken: String?) { + for attempt in 1...changeFeedPollAttempts { + let result = try await context.service.fetchDatabaseChanges( + syncToken: syncToken, + database: context.database + ) + try ChangeTrackingVerification.requireNoZoneFailures( + result.failures, + operation: "fetchDatabaseChanges (\(description))" + ) + + if let zone = result.changedZones.first(where: { + $0.zoneName == zoneName && predicate($0) + }) { + return (zone, result.syncToken) + } + + if attempt < changeFeedPollAttempts { + if context.verbose { + print( + " ⏳ Change feed empty for '\(zoneName)' (\(description)); " + + "retrying (\(attempt)/\(changeFeedPollAttempts))…" + ) + } + try await Task.sleep(for: changeFeedPollDelay) + } + } + + throw IntegrationTestError.verificationFailed( + "fetchDatabaseChanges never reported \(description) for zone '\(zoneName)' " + + "after \(changeFeedPollAttempts) attempts" + ) + } +} diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift index 5a75644ca..9e82ea4ef 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PrivateDatabaseTest.swift @@ -45,8 +45,10 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { ModifyZonesPhase(), LookupZonePhase(), ZoneRoundtripPhase(), + ZonePayloadMetadataPhase(), UploadAssetPhase(), CreateRecordsPhase(), + DownloadAssetPhase(), RereferenceAssetPhase(), QueryRecordsPhase(), LookupRecordsPhase(), @@ -66,6 +68,7 @@ internal struct PrivateDatabaseTest: PhasedIntegrationTest { TokenRoundtripPhase(), NotificationRoundtripPhase(), ShareCreateAndAcceptPhase(), + SharedZoneRoundtripPhase(), CleanupPhase(), ] } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift index 850c9d386..72e26b791 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Integration/Tests/PublicDatabaseTest.swift @@ -57,6 +57,7 @@ internal struct PublicDatabaseTest: PhasedIntegrationTest { LookupZonePhase(), UploadAssetPhase(), CreateRecordsPhase(), + DownloadAssetPhase(), RereferenceAssetPhase(), QueryRecordsPhase(), LookupRecordsPhase(), diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html index 6a6ed0d30..d50877991 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/index.html @@ -64,6 +64,14 @@

Notes MistKit Limit +
+ + +
+
+ + +
diff --git a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js index 83f575fc5..ae4a2bf33 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js +++ b/Examples/MistDemo/Sources/MistDemoKit/Resources/js/app.js @@ -42,6 +42,8 @@ const deleteBtn = document.getElementById('delete-btn'); const refreshBtn = document.getElementById('refresh-btn'); const recordTypeInput = document.getElementById('record-type'); const queryLimitInput = document.getElementById('query-limit'); +const queryZoneInput = document.getElementById('query-zone'); +const queryZoneOwnerInput = document.getElementById('query-zone-owner'); const rawResponseEl = document.getElementById('raw-response'); const formImageGenerateBtn = document.getElementById('form-image-generate'); const formImageClearBtn = document.getElementById('form-image-clear'); @@ -493,10 +495,28 @@ function ckJsFields(fields) { // ---- Notes CRUD operations ---- +/** Selected toolbar zone for query and writes. Drops stray owner without name. */ +function selectedZone() { + const zoneName = queryZoneInput.value.trim() || undefined; + const zoneOwner = zoneName + ? (queryZoneOwnerInput.value.trim() || undefined) + : undefined; + return { zoneName, zoneOwner }; +} + +/** CloudKit JS per-record zoneID shape (unlike query's top-level zoneID). */ +function ckJsRecordZoneID(zoneName, zoneOwner) { + if (!zoneName) return undefined; + return zoneOwner + ? { zoneName, ownerRecordName: zoneOwner } + : { zoneName }; +} + async function queryNotes() { if (queryInFlight) return; const recordType = recordTypeInput.value.trim(); const limit = parseInt(queryLimitInput.value, 10); + const { zoneName, zoneOwner } = selectedZone(); queryInFlight = true; setQueryControlsDisabled(true); const dbLabel = currentDatabase === 'public' ? 'public' : 'private'; @@ -512,6 +532,8 @@ async function queryNotes() { sortBy: currentSort ? [{ field: currentSort.field, ascending: currentSort.ascending }] : undefined, + zoneName, + zoneOwner, }); } else { const query = { recordType }; @@ -521,6 +543,10 @@ async function queryNotes() { ascending: currentSort.ascending, }]; } + // CloudKit JS names the owner `ownerRecordName` inside zoneID. + if (zoneName) { + query.zoneID = ckJsRecordZoneID(zoneName, zoneOwner); + } payload = await ckJsDatabase().performQuery(query, { resultsLimit: isFinite(limit) ? limit : undefined, }); @@ -552,6 +578,7 @@ function setQueryControlsDisabled(disabled) { 'refresh-btn', 'db-private', 'db-public', 'mode-mistkit', 'mode-cloudkitjs', 'save-btn', 'delete-btn', + 'query-zone', 'query-zone-owner', ]; for (const id of ids) { const el = document.getElementById(id); @@ -583,6 +610,7 @@ async function saveNote() { // /api/assets/upload, then create/update with the returned descriptor. // CloudKit JS handles upload inline through saveRecords by passing a // Blob in the field value. + const { zoneName, zoneOwner } = selectedZone(); let uploadedRecordName = null; if (hasPendingImage && currentMode === 'mistkit') { setStatus(formStatusEl, 'Uploading image…', 'loading'); @@ -592,6 +620,8 @@ async function saveNote() { recordName: isUpdate ? note.recordName : undefined, database: currentDatabase, data: pendingImage.base64, + zoneName, + zoneOwner, }); uploadedRecordName = receipt.recordName; // FieldValue's Asset case decodes the bare Asset shape directly. @@ -605,6 +635,8 @@ async function saveNote() { recordName: note.recordName, fields, recordChangeTag: note.recordChangeTag, + zoneName, + zoneOwner, }); } else { payload = await postJSON('/api/records/create', { @@ -612,6 +644,8 @@ async function saveNote() { database: currentDatabase, recordName: uploadedRecordName || undefined, fields, + zoneName, + zoneOwner, }); } } else { @@ -626,6 +660,8 @@ async function saveNote() { record.recordName = note.recordName; record.recordChangeTag = note.recordChangeTag; } + const zoneID = ckJsRecordZoneID(zoneName, zoneOwner); + if (zoneID) record.zoneID = zoneID; payload = await ckJsDatabase().saveRecords([record]); if (payload && payload.hasErrors && payload.errors.length) { throw new Error(payload.errors[0].reason || 'CloudKit JS save failed'); @@ -654,15 +690,21 @@ async function deleteNote(note, statusEl = tableStatusEl) { clearStatus(statusEl); try { let payload; + const { zoneName, zoneOwner } = selectedZone(); if (currentMode === 'mistkit') { payload = await postJSON('/api/records/delete', { recordType: note.recordType, database: currentDatabase, recordName: note.recordName, recordChangeTag: note.recordChangeTag, + zoneName, + zoneOwner, }); } else { - payload = await ckJsDatabase().deleteRecords([{ recordName: note.recordName }]); + const deleteSpec = { recordName: note.recordName }; + const zoneID = ckJsRecordZoneID(zoneName, zoneOwner); + if (zoneID) deleteSpec.zoneID = zoneID; + payload = await ckJsDatabase().deleteRecords([deleteSpec]); if (payload && payload.hasErrors && payload.errors.length) { throw new Error(payload.errors[0].reason || 'CloudKit JS delete failed'); } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift index 02b8ab3fd..acffe839b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/CloudKitService+WebBackend.swift @@ -35,22 +35,18 @@ extension CloudKitService: WebBackend { recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, - zoneName: String?, - zoneOwner: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> [RecordInfo] { let querySorts = sortBy?.map { sort in QuerySort.sort(sort.field, ascending: sort.ascending) } - let zoneID = zoneName.map { - ZoneID(zoneName: $0, ownerName: zoneOwner) - } let result = try await queryRecords( Query(recordType: recordType, sortBy: querySorts ?? []), limit: limit, desiredKeys: nil, continuationMarker: nil, - zoneID: zoneID, + zoneID: zone?.zoneID, database: database ) return result.records @@ -60,12 +56,14 @@ extension CloudKitService: WebBackend { recordType: String, recordName: String?, fields: [String: FieldValue], + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo { try await createRecord( recordType: recordType, recordName: recordName, fields: fields, + zoneID: zone?.zoneID, database: database ) } @@ -75,6 +73,7 @@ extension CloudKitService: WebBackend { recordName: String, fields: [String: FieldValue], recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo { try await updateRecord( @@ -82,6 +81,7 @@ extension CloudKitService: WebBackend { recordName: recordName, fields: fields, recordChangeTag: recordChangeTag, + zoneID: zone?.zoneID, database: database ) } @@ -90,12 +90,14 @@ extension CloudKitService: WebBackend { recordType: String, recordName: String, recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws { try await deleteRecord( recordType: recordType, recordName: recordName, recordChangeTag: recordChangeTag, + zoneID: zone?.zoneID, database: database ) } @@ -187,6 +189,7 @@ extension CloudKitService: WebBackend { recordType: String, fieldName: String, recordName: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> AssetUploadReceipt { try await uploadAssets( @@ -194,6 +197,7 @@ extension CloudKitService: WebBackend { recordType: recordType, fieldName: fieldName, recordName: recordName, + zoneID: zone?.zoneID, database: database ) } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift index b59707954..b946614f8 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebBackend.swift @@ -43,8 +43,7 @@ internal protocol WebBackend: Sendable { recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, - zoneName: String?, - zoneOwner: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> [RecordInfo] @@ -52,6 +51,7 @@ internal protocol WebBackend: Sendable { recordType: String, recordName: String?, fields: [String: FieldValue], + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo @@ -60,6 +60,7 @@ internal protocol WebBackend: Sendable { recordName: String, fields: [String: FieldValue], recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo @@ -67,6 +68,7 @@ internal protocol WebBackend: Sendable { recordType: String, recordName: String, recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws @@ -154,6 +156,7 @@ internal protocol WebBackend: Sendable { recordType: String, fieldName: String, recordName: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> AssetUploadReceipt diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Assets.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Assets.swift index b66e739da..5723de4ba 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Assets.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests+Assets.swift @@ -49,6 +49,8 @@ extension WebRequests { case recordName case data case database + case zoneName + case zoneOwner } internal let recordType: String @@ -56,6 +58,8 @@ extension WebRequests { internal let recordName: String? internal let data: Data internal let database: MistKit.Database + /// Must match the zone on the following create/update that attaches the asset. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -68,6 +72,7 @@ extension WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zone = try WebRequests.decodeZoneSelector(from: container) } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift index 9f26597a3..1f539938b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebRequests.swift @@ -51,7 +51,37 @@ internal enum WebRequests { internal let ascending: Bool } + /// Zone target for a web request: default zone is `nil`, own custom zone is + /// name-only, shared zone is name + owner. A lone owner is unrepresentable. + /// + /// Kept separate from MistKit's ``ZoneID`` so request selectors never carry + /// response-only fields like `zoneType`. + internal struct ZoneSelector: Sendable, Equatable { + internal let zoneName: String + internal let zoneOwner: String? + + internal init(zoneName: String, zoneOwner: String? = nil) { + self.zoneName = zoneName + self.zoneOwner = zoneOwner + } + + /// MistKit zone identity for `queryRecords` / `modifyRecords`. + internal var zoneID: ZoneID { + ZoneID(zoneName: zoneName, ownerName: zoneOwner) + } + } + + /// Coding keys shared by request bodies that accept a flat zone selector. + internal enum ZoneCodingKeys: String, CodingKey { + case zoneName + case zoneOwner + } + /// `POST /api/records/query` + /// + /// Wire format stays flat (`zoneName` / `zoneOwner` at the top level) so + /// `app.js` and existing tests keep working; decode folds them into + /// ``zone``. internal struct Query: Decodable { private enum CodingKeys: String, CodingKey { case recordType @@ -66,10 +96,8 @@ internal enum WebRequests { internal let limit: Int? internal let sortBy: [QuerySortField]? internal let database: MistKit.Database - /// Optional zone name for custom/shared zone queries. - internal let zoneName: String? - /// Optional zone owner (ownerName) for shared zones. - internal let zoneOwner: String? + /// `nil` = default zone; otherwise a custom or shared zone. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -81,19 +109,7 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) - self.zoneName = try container.decodeIfPresent( - String.self, forKey: .zoneName - ) - self.zoneOwner = try container.decodeIfPresent( - String.self, forKey: .zoneOwner - ) - if self.zoneOwner != nil, self.zoneName == nil { - throw DecodingError.dataCorruptedError( - forKey: .zoneOwner, - in: container, - debugDescription: "zoneOwner requires zoneName" - ) - } + self.zone = try WebRequests.decodeZoneSelector(from: container) } } @@ -108,12 +124,16 @@ internal enum WebRequests { case recordName case fields case database + case zoneName + case zoneOwner } internal let recordType: String internal let recordName: String? internal let fields: [String: FieldValue] internal let database: MistKit.Database + /// `nil` = default zone; otherwise a custom or shared zone. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -127,6 +147,7 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zone = try WebRequests.decodeZoneSelector(from: container) } } @@ -142,6 +163,8 @@ internal enum WebRequests { case fields case recordChangeTag case database + case zoneName + case zoneOwner } internal let recordType: String @@ -149,6 +172,8 @@ internal enum WebRequests { internal let fields: [String: FieldValue] internal let recordChangeTag: String? internal let database: MistKit.Database + /// `nil` = default zone; otherwise a custom or shared zone. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -163,6 +188,7 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zone = try WebRequests.decodeZoneSelector(from: container) } } @@ -177,12 +203,16 @@ internal enum WebRequests { case recordName case recordChangeTag case database + case zoneName + case zoneOwner } internal let recordType: String internal let recordName: String internal let recordChangeTag: String? internal let database: MistKit.Database + /// `nil` = default zone; otherwise a custom or shared zone. + internal let zone: ZoneSelector? internal init(from decoder: any Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) @@ -194,6 +224,7 @@ internal enum WebRequests { self.database = try WebRequests.decodeDatabase( from: container, forKey: .database ) + self.zone = try WebRequests.decodeZoneSelector(from: container) } } @@ -223,4 +254,32 @@ internal enum WebRequests { } return database } + + /// Decode flat `zoneName` / `zoneOwner` into a ``ZoneSelector``. + /// Rejects owner-without-name so shared-zone wire mistakes surface as 400. + internal static func decodeZoneSelector( + from container: KeyedDecodingContainer + ) throws -> ZoneSelector? { + guard let zoneNameKey = Key(stringValue: ZoneCodingKeys.zoneName.rawValue), + let zoneOwnerKey = Key(stringValue: ZoneCodingKeys.zoneOwner.rawValue) + else { + return nil + } + let zoneName = try container.decodeIfPresent( + String.self, forKey: zoneNameKey + ) + let zoneOwner = try container.decodeIfPresent( + String.self, forKey: zoneOwnerKey + ) + if zoneOwner != nil, zoneName == nil { + throw DecodingError.dataCorruptedError( + forKey: zoneOwnerKey, + in: container, + debugDescription: "zoneOwner requires zoneName" + ) + } + return zoneName.map { + ZoneSelector(zoneName: $0, zoneOwner: zoneOwner) + } + } } diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Assets.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Assets.swift index a8637b2f0..1cb01388b 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Assets.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+Assets.swift @@ -58,6 +58,7 @@ recordType: body.recordType, fieldName: body.fieldName, recordName: body.recordName, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode(receipt) diff --git a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift index 18208e818..834226124 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Server/WebServer+CRUD.swift @@ -51,8 +51,7 @@ recordType: body.recordType, limit: body.limit, sortBy: body.sortBy, - zoneName: body.zoneName, - zoneOwner: body.zoneOwner, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode( @@ -80,6 +79,7 @@ recordType: body.recordType, recordName: body.recordName, fields: body.fields, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode( @@ -108,6 +108,7 @@ recordName: body.recordName, fields: body.fields, recordChangeTag: body.recordChangeTag, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode( @@ -135,6 +136,7 @@ recordType: body.recordType, recordName: body.recordName, recordChangeTag: body.recordChangeTag, + zone: body.zone, database: body.database ) return try WebJSON.encoder().encode( diff --git a/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift b/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift index 8d9d16458..dc817b793 100644 --- a/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift +++ b/Examples/MistDemo/Sources/MistDemoKit/Utilities/FieldValueFormatter.swift @@ -45,7 +45,7 @@ internal enum FieldValueFormatter { case .double(let double): return "\(double)" case .bytes(let bytes): - return bytes + return bytes.base64EncodedString() case .date(let date): return formatDate(date) case .location(let location): @@ -73,7 +73,7 @@ internal enum FieldValueFormatter { case .double(let double): return "\(double)" case .bytes(let bytes): - return "bytes(\(bytes.count) chars, base64: \(bytes))" + return "bytes(\(bytes.count) bytes, base64: \(bytes.base64EncodedString()))" case .date(let date): return "date(\(formatDate(date)))" case .location(let location): diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift index e2d939441..17f9bde98 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokenConfigTests.swift @@ -27,6 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit +internal import MistKitConfiguration internal import Configuration internal import Foundation internal import MistKit @@ -36,18 +38,10 @@ internal import Testing @Suite("AuthTokenConfig Tests") internal struct AuthTokenConfigTests { - private static func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - private static func configuration( - values: [String: ConfigValue] + values: [(key: any ConfigurationKey, value: String)] ) -> MistDemoConfiguration { - var mapped: [AbsoluteConfigKey: ConfigValue] = [:] - for (path, value) in values { - mapped[key(path)] = value - } - return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) + MistDemoConfiguration.forTesting(values) } @Test("Memberwise init applies defaults for port, host, openBrowser, container") @@ -87,9 +81,9 @@ internal struct AuthTokenConfigTests { @Test("Configuration init throws missingRequired when api.token is absent") internal func missingApiTokenThrows() async { - let configuration = Self.configuration(values: [:]) + let configuration = Self.configuration(values: []) - await #expect(throws: ConfigurationError.self) { + await #expect(throws: MistDemoKit.ConfigurationError.self) { _ = try await AuthTokenConfig(configuration: configuration) } } @@ -97,10 +91,10 @@ internal struct AuthTokenConfigTests { @Test("Configuration init throws missingRequired when api.token is empty") internal func emptyApiTokenThrows() async { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "") + (MistDemoKeys.Auth.apiToken, "") ]) - await #expect(throws: ConfigurationError.self) { + await #expect(throws: MistDemoKit.ConfigurationError.self) { _ = try await AuthTokenConfig(configuration: configuration) } } @@ -108,7 +102,7 @@ internal struct AuthTokenConfigTests { @Test("Configuration init applies all defaults when only api.token is set") internal func parsedDefaults() async throws { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok-xyz") + (MistDemoKeys.Auth.apiToken, "tok-xyz") ]) let config = try await AuthTokenConfig(configuration: configuration) @@ -125,13 +119,13 @@ internal struct AuthTokenConfigTests { @Test("Configuration init honors every override key") internal func parsedOverrides() async throws { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok-xyz"), - "container.identifier": .init(stringLiteral: "iCloud.custom.id"), - "environment": .init(stringLiteral: "production"), - "port": .init(integerLiteral: 9_090), - "host": .init(stringLiteral: "192.168.1.10"), - "no.browser": .init(booleanLiteral: true), - "reset.auth": .init(booleanLiteral: true), + (MistDemoKeys.Auth.apiToken, "tok-xyz"), + (MistDemoKeys.cloudKit.containerID, "iCloud.custom.id"), + (MistDemoKeys.cloudKit.environment, "production"), + (MistDemoKeys.Server.port, String(9_090)), + (MistDemoKeys.Server.host, "192.168.1.10"), + (MistDemoKeys.Server.noBrowser, String(true)), + (MistDemoKeys.Auth.resetAuth, String(true)), ]) let config = try await AuthTokenConfig(configuration: configuration) @@ -148,9 +142,9 @@ internal struct AuthTokenConfigTests { @Test("--no-browser wins when both browser flags are set") internal func noBrowserWinsOverBrowser() async throws { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok-xyz"), - "browser": .init(booleanLiteral: true), - "no.browser": .init(booleanLiteral: true), + (MistDemoKeys.Auth.apiToken, "tok-xyz"), + (MistDemoKeys.Server.browser, String(true)), + (MistDemoKeys.Server.noBrowser, String(true)), ]) let config = try await AuthTokenConfig(configuration: configuration) @@ -161,11 +155,11 @@ internal struct AuthTokenConfigTests { @Test("Configuration init throws on invalid environment") internal func invalidEnvironmentThrows() async { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok-xyz"), - "environment": .init(stringLiteral: "staging"), + (MistDemoKeys.Auth.apiToken, "tok-xyz"), + (MistDemoKeys.cloudKit.environment, "staging"), ]) - await #expect(throws: ConfigurationError.self) { + await #expect(throws: MistDemoKit.ConfigurationError.self) { _ = try await AuthTokenConfig(configuration: configuration) } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokensConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokensConfigTests.swift index 8304615fc..0145b9c54 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokensConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/AuthTokensConfigTests.swift @@ -27,6 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit internal import Configuration internal import Foundation internal import MistKit @@ -36,18 +37,10 @@ internal import Testing @Suite("AuthTokensConfig Tests") internal struct AuthTokensConfigTests { - private static func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - private static func configuration( - values: [String: ConfigValue] + values: [(key: any ConfigurationKey, value: String)] ) -> MistDemoConfiguration { - var mapped: [AbsoluteConfigKey: ConfigValue] = [:] - for (path, value) in values { - mapped[key(path)] = value - } - return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) + MistDemoConfiguration.forTesting(values) } @Test("Memberwise init applies defaults") @@ -74,7 +67,7 @@ internal struct AuthTokensConfigTests { @Test("Configuration init throws when api.token is absent") internal func missingApiTokenThrows() async { - let configuration = Self.configuration(values: [:]) + let configuration = Self.configuration(values: []) await #expect(throws: ConfigurationError.self) { _ = try await AuthTokensConfig(configuration: configuration) @@ -84,8 +77,8 @@ internal struct AuthTokensConfigTests { @Test("Configuration init parses sharee.email") internal func parsesShareeEmail() async throws { let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "tok"), - "sharee.email": .init(stringLiteral: "sharee@example.com"), + (MistDemoKeys.Auth.apiToken, "tok"), + (MistDemoKeys.Auth.shareeEmail, "sharee@example.com"), ]) let config = try await AuthTokensConfig(configuration: configuration) diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift new file mode 100644 index 000000000..e77287af4 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/Keys/MistDemoKeysTests.swift @@ -0,0 +1,94 @@ +// +// MistDemoKeysTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import MistKitConfiguration +internal import Configuration +internal import Testing + +@testable import MistDemoKit + +/// Pins the wire contract of ``MistDemoKeys``: the environment-variable names CI and the +/// deployment docs rely on, and the command-line flags the README documents. +@Suite("MistDemoKeys") +internal struct MistDemoKeysTests { + /// The eight variables `MistDemo-Integration.yml` and `docs/cloudkit-guide/` set. + /// + /// `CLOUDKIT_CONTAINER_ID` is the one that changed: the old `container.identifier` + /// base resolved to `CLOUDKIT_CONTAINER_IDENTIFIER`, so the value CI supplied was + /// silently ignored and every run fell back to the built-in default. + @Test( + "Deployment environment variables resolve", + arguments: [ + (MistDemoKeys.cloudKit.containerID as any ConfigurationKey, "CLOUDKIT_CONTAINER_ID"), + (MistDemoKeys.cloudKit.keyID, "CLOUDKIT_KEY_ID"), + (MistDemoKeys.cloudKit.privateKey, "CLOUDKIT_PRIVATE_KEY"), + (MistDemoKeys.cloudKit.privateKeyPath, "CLOUDKIT_PRIVATE_KEY_PATH"), + (MistDemoKeys.cloudKit.environment, "CLOUDKIT_ENVIRONMENT"), + (MistDemoKeys.Auth.apiToken, "CLOUDKIT_API_TOKEN"), + (MistDemoKeys.Auth.webAuthToken, "CLOUDKIT_WEB_AUTH_TOKEN"), + (MistDemoKeys.Auth.shareeWebAuthToken, "CLOUDKIT_SHAREE_WEB_AUTH_TOKEN"), + (MistDemoKeys.Auth.shareeEmail, "CLOUDKIT_SHAREE_EMAIL"), + ] + ) + internal func environmentVariableNames(key: any ConfigurationKey, expected: String) throws { + let resolved = try #require(key.key(for: .environment)) + let normalized = String(resolved.uppercased().map { $0.isLetter || $0.isNumber ? $0 : "_" }) + #expect(normalized == expected) + } + + /// Every credential key must be dash-case, never snake_case: `CLIKeyEncoder` joins key + /// components verbatim, so an underscore survives into an unusable flag and silently + /// defeats secret redaction. + @Test("Credential key bases are dash-case") + internal func basesAreDashCase() { + let bases = [ + MistDemoKeys.cloudKit.containerID.base, + MistDemoKeys.cloudKit.keyID.base, + MistDemoKeys.cloudKit.privateKey.base, + MistDemoKeys.cloudKit.privateKeyPath.base, + MistDemoKeys.cloudKit.environment.base, + ] + for base in bases { + let unwrapped = base ?? "" + #expect(!unwrapped.contains("_"), "\(unwrapped) must not contain an underscore") + #expect(unwrapped.hasPrefix("cloudkit."), "\(unwrapped) must be cloudkit-namespaced") + } + } + + /// The three credential keys carry `isSecret`, so a value passed by flag is redacted. + @Test("Credential keys are marked secret") + internal func credentialKeysAreSecret() { + #expect(MistDemoKeys.cloudKit.keyID.isSecret) + #expect(MistDemoKeys.cloudKit.privateKey.isSecret) + #expect(MistDemoKeys.cloudKit.privateKeyPath.isSecret) + #expect(MistDemoKeys.Auth.apiToken.isSecret) + #expect(MistDemoKeys.Auth.webAuthToken.isSecret) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift new file mode 100644 index 000000000..adb706451 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/MistDemoConfigurationBoolTests.swift @@ -0,0 +1,94 @@ +// +// MistDemoConfigurationBoolTests.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import MistKitConfiguration +internal import Configuration +internal import Testing + +@testable import MistDemoKit + +/// Pins boolean resolution through the **real** command-line provider. +/// +/// ``MistDemoConfiguration`` deliberately does not use ConfigKeyKit's `read(_:)` for +/// booleans: that resolves by probing `string(forKey:)`, and swift-configuration reports +/// a valueless flag only through `bool(forKey:)`. Routing booleans through the string +/// path makes every bare flag read as its default — measured directly: +/// `string(forKey: "verbose")` is `nil` while `bool(forKey: "verbose")` is `true`. +@Suite("MistDemoConfiguration booleans") +internal struct MistDemoConfigurationBoolTests { + private static func cli(_ arguments: [String]) -> MistDemoConfiguration { + MistDemoConfiguration( + configReader: ConfigReader( + providers: [CommandLineArgumentsProvider(arguments: ["mistdemo"] + arguments)] + ) + ) + } + + @Test("A bare flag resolves true for a required boolean") + internal func bareFlagIsTrue() { + #expect(Self.cli(["--force"]).read(MistDemoKeys.Record.force)) + #expect(Self.cli(["--stdin"]).read(MistDemoKeys.Record.stdin)) + #expect(Self.cli(["--verbose"]).read(MistDemoKeys.Output.verbose)) + } + + @Test("A bare flag resolves true for an optional boolean") + internal func bareFlagIsTrueWhenOptional() { + #expect(Self.cli(["--zone-wide"]).read(MistDemoKeys.Query.zoneWide) == true) + #expect( + Self.cli(["--numbers-as-strings"]).read(MistDemoKeys.Record.numbersAsStrings) == true + ) + #expect( + Self.cli(["--fetch-root-record"]).read(MistDemoKeys.Sharing.fetchRootRecord) == true + ) + } + + @Test("An absent optional boolean stays nil, distinct from an explicit false") + internal func absentIsNil() { + #expect(Self.cli([]).read(MistDemoKeys.Query.zoneWide) == nil) + #expect(Self.cli(["--zone-wide", "false"]).read(MistDemoKeys.Query.zoneWide) == false) + } + + @Test("A required boolean falls back to its default when absent") + internal func defaultsWhenAbsent() { + #expect(Self.cli([]).read(MistDemoKeys.Record.force) == false) + } + + @Test("Renamed CloudKit credential flags resolve") + internal func renamedCredentialFlags() { + let config = Self.cli([ + "--cloudkit-container-id", "iCloud.com.example.Renamed", + "--cloudkit-key-id", "abc123", + "--cloudkit-environment", "production", + ]) + #expect(config.read(MistDemoKeys.cloudKit.containerID) == "iCloud.com.example.Renamed") + #expect(config.read(MistDemoKeys.cloudKit.keyID) == "abc123") + #expect(config.read(MistDemoKeys.cloudKit.environment) == "production") + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift index 685f8e108..289edfd24 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Configuration/TestPrivateConfigTests.swift @@ -27,6 +27,7 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit internal import Configuration internal import Foundation internal import Testing @@ -35,18 +36,10 @@ internal import Testing @Suite("TestPrivateConfig Tests") internal struct TestPrivateConfigTests { - private static func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - private static func configuration( - values: [String: ConfigValue] + values: [(key: any ConfigurationKey, value: String)] ) -> MistDemoConfiguration { - var mapped: [AbsoluteConfigKey: ConfigValue] = [:] - for (path, value) in values { - mapped[key(path)] = value - } - return MistDemoConfiguration(testProvider: InMemoryProvider(values: mapped)) + MistDemoConfiguration.forTesting(values) } @Test("TestPrivateConfig retains sharee credentials") @@ -65,16 +58,16 @@ internal struct TestPrivateConfigTests { internal func missingShareeTokenThrows() async throws { let base = try await MistDemoConfig( configuration: Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.email": .init(stringLiteral: "sharee@example.com"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeEmail, "sharee@example.com"), ]), base: nil ) let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.email": .init(stringLiteral: "sharee@example.com"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeEmail, "sharee@example.com"), ]) await #expect(throws: ConfigurationError.self) { @@ -86,16 +79,16 @@ internal struct TestPrivateConfigTests { internal func missingShareeEmailThrows() async throws { let base = try await MistDemoConfig( configuration: Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.web.auth.token": .init(stringLiteral: "sharee-tok"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeWebAuthToken, "sharee-tok"), ]), base: nil ) let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.web.auth.token": .init(stringLiteral: "sharee-tok"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeWebAuthToken, "sharee-tok"), ]) await #expect(throws: ConfigurationError.self) { @@ -107,16 +100,16 @@ internal struct TestPrivateConfigTests { internal func parsesShareeCredentials() async throws { let base = try await MistDemoConfig( configuration: Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), ]), base: nil ) let configuration = Self.configuration(values: [ - "api.token": .init(stringLiteral: "api-tok"), - "web.auth.token": .init(stringLiteral: "sharer-tok"), - "sharee.web.auth.token": .init(stringLiteral: "sharee-tok"), - "sharee.email": .init(stringLiteral: "sharee@example.com"), + (MistDemoKeys.Auth.apiToken, "api-tok"), + (MistDemoKeys.Auth.webAuthToken, "sharer-tok"), + (MistDemoKeys.Auth.shareeWebAuthToken, "sharee-tok"), + (MistDemoKeys.Auth.shareeEmail, "sharee@example.com"), ]) let config = try await TestPrivateConfig( diff --git a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift index 79333ed42..4612d5326 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Extensions/FieldValue+FieldType/FieldValue+FieldTypeTests+BytesType.swift @@ -36,18 +36,25 @@ internal import Testing extension FieldValueFieldTypeTests { @Suite("Bytes Type") internal struct BytesType { - @Test("Initialize FieldValue.bytes from String value and bytes type") + @Test("Initialize FieldValue.bytes from valid base64 String value and bytes type") internal func initializeBytesFromStringValue() { - let fieldValue = FieldValue(value: "base64data" as String, fieldType: .bytes) + let fieldValue = FieldValue(value: "aGVsbG8=" as String, fieldType: .bytes) #expect(fieldValue != nil) if case .bytes(let value) = fieldValue { - #expect(value == "base64data") + #expect(value == Data("hello".utf8)) } else { Issue.record("Expected .bytes case") } } + @Test("Bytes type with malformed base64 returns nil") + internal func bytesTypeWithMalformedBase64ReturnsNil() { + let fieldValue = FieldValue(value: "not!valid!" as String, fieldType: .bytes) + + #expect(fieldValue == nil) + } + @Test("Bytes type with non-String value returns nil") internal func bytesTypeWithNonStringValueReturnsNil() { let fieldValue = FieldValue(value: 42 as Int, fieldType: .bytes) diff --git a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift index 8cd6adae7..79210e4e3 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfig+Testing.swift @@ -1,6 +1,6 @@ // // MistDemoConfig+Testing.swift -// MistDemoTests +// MistDemo // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -27,6 +27,8 @@ // OTHER DEALINGS IN THE SOFTWARE. // +internal import ConfigKeyKit +internal import MistKitConfiguration internal import Configuration internal import Foundation internal import MistKit @@ -49,17 +51,13 @@ extension MistDemoConfig { /// `database` is left unset so it falls through to the production /// parser's default and cannot affect environment-test semantics. internal init(rawEnvironment: String) async throws { - func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - - let testProvider = InMemoryProvider(values: [ - key("container.identifier"): .init(stringLiteral: "iCloud.com.test.App"), - key("api.token"): .init(stringLiteral: "test-api-token"), - key("environment"): .init(stringLiteral: rawEnvironment), - ]) - let configuration = MistDemoConfiguration(testProvider: testProvider) - self = try await MistDemoConfig(configuration: configuration) + self = try await MistDemoConfig( + configuration: Self.makeConfiguration([ + (MistDemoKeys.cloudKit.containerID, "iCloud.com.test.App"), + (MistDemoKeys.Auth.apiToken, "test-api-token"), + (MistDemoKeys.cloudKit.environment, rawEnvironment), + ]) + ) } /// Create a test configuration with custom values @@ -82,43 +80,40 @@ extension MistDemoConfig { testServerToServer: Bool = false, badCredentials: Bool = false ) async throws { - let envString = environment == .production ? "production" : "development" - - func key(_ path: String) -> AbsoluteConfigKey { - AbsoluteConfigKey(path.split(separator: ".").map(String.init), context: [:]) - } - - var values: [AbsoluteConfigKey: ConfigValue] = [ - key("container.identifier"): .init(stringLiteral: containerIdentifier), - key("api.token"): .init(stringLiteral: apiToken), - key("environment"): .init(stringLiteral: envString), - key("database"): .init(stringLiteral: database.pathSegment), - key("host"): .init(stringLiteral: host), - key("port"): .init(integerLiteral: port), - key("auth.timeout"): .init(integerLiteral: Int(authTimeout)), - key("skip.auth"): .init(booleanLiteral: skipAuth), - key("test.all.auth"): .init(booleanLiteral: testAllAuth), - key("test.api.only"): .init(booleanLiteral: testApiOnly), - key("test.adaptive"): .init(booleanLiteral: testAdaptive), - key("test.server.to.server"): .init(booleanLiteral: testServerToServer), - key("bad.credentials"): .init(booleanLiteral: badCredentials), + var values: [(key: any ConfigurationKey, value: String)] = [ + (MistDemoKeys.cloudKit.containerID, containerIdentifier), + (MistDemoKeys.Auth.apiToken, apiToken), + ( + MistDemoKeys.cloudKit.environment, + environment == .production ? "production" : "development" + ), + (MistDemoKeys.Server.database, database.pathSegment), + (MistDemoKeys.Server.host, host), + (MistDemoKeys.Server.port, String(port)), + (MistDemoKeys.Server.authTimeout, String(Int(authTimeout))), + (MistDemoKeys.Auth.skipAuth, String(skipAuth)), + (MistDemoKeys.AuthModes.testAllAuth, String(testAllAuth)), + (MistDemoKeys.AuthModes.testAPIOnly, String(testApiOnly)), + (MistDemoKeys.AuthModes.testAdaptive, String(testAdaptive)), + (MistDemoKeys.AuthModes.testServerToServer, String(testServerToServer)), + (MistDemoKeys.Auth.badCredentials, String(badCredentials)), ] - if let webAuthToken { - values[key("web.auth.token")] = .init(stringLiteral: webAuthToken) - } - if let keyID { - values[key("key.id")] = .init(stringLiteral: keyID) - } - if let privateKey { - values[key("private.key")] = .init(stringLiteral: privateKey) - } + if let webAuthToken { values.append((MistDemoKeys.Auth.webAuthToken, webAuthToken)) } + if let keyID { values.append((MistDemoKeys.cloudKit.keyID, keyID)) } + if let privateKey { values.append((MistDemoKeys.cloudKit.privateKey, privateKey)) } + // Previously seeded `private.key.file`, a key production never read, so the + // `privateKeyFile:` argument silently did nothing. if let privateKeyFile { - values[key("private.key.file")] = .init(stringLiteral: privateKeyFile) + values.append((MistDemoKeys.cloudKit.privateKeyPath, privateKeyFile)) } - let testProvider = InMemoryProvider(values: values) - let configuration = MistDemoConfiguration(testProvider: testProvider) - self = try await MistDemoConfig(configuration: configuration) + self = try await MistDemoConfig(configuration: Self.makeConfiguration(values)) + } + + private static func makeConfiguration( + _ values: [(key: any ConfigurationKey, value: String)] + ) -> MistDemoConfiguration { + MistDemoConfiguration.forTesting(values) } } diff --git a/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfiguration+Testing.swift b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfiguration+Testing.swift new file mode 100644 index 000000000..9ae79bc7d --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/MistDemoConfiguration+Testing.swift @@ -0,0 +1,73 @@ +// +// MistDemoConfiguration+Testing.swift +// MistDemo +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import Configuration + +@testable import MistDemoKit + +extension MistDemoConfiguration { + /// Builds a configuration over the **real** environment provider, keyed by each key's + /// own resolved environment-variable name. + /// + /// Takes typed keys rather than key-path strings so a rename cannot silently + /// desynchronise the tests from production — the previous string-keyed double seeded + /// `private.key.file`, which production never read, and nobody noticed. + /// + /// Deliberately not `InMemoryProvider`: it matches keys literally and serves only the + /// type it stored, so it diverged from production on key normalization and on + /// numeric/boolean coercion. + internal static func forTesting( + _ values: [(key: any ConfigurationKey, value: String)] + ) -> MistDemoConfiguration { + var environment: [String: String] = [:] + for entry in values { + guard let name = entry.key.key(for: .environment) else { continue } + environment[Self.environmentVariableName(name)] = entry.value + } + return MistDemoConfiguration( + configReader: ConfigReader( + providers: [EnvironmentVariablesProvider(environmentVariables: environment)] + ) + ) + } + + /// Applies the same normalization swift-configuration's `EnvironmentKeyEncoder` does. + /// + /// ConfigKeyKit's `screamingSnakeCase` only maps `.` to `_`, so a dash-case base such + /// as `cloudkit.container-id` yields `CLOUDKIT_CONTAINER-ID`. In production that string + /// is handed to the reader, whose encoder then maps every non-alphanumeric to `_` and + /// arrives at `CLOUDKIT_CONTAINER_ID`. Seeding a provider directly skips that step, so + /// it has to be reproduced here or the variable never matches. + private static func environmentVariableName(_ resolved: String) -> String { + String( + resolved.uppercased().map { $0.isLetter || $0.isNumber ? $0 : "_" } + ) + } +} diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift index c0af44f41..16b61448e 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+Calls.swift @@ -39,8 +39,7 @@ internal let recordType: String internal let limit: Int? internal let sortBy: [WebRequests.QuerySortField]? - internal let zoneName: String? - internal let zoneOwner: String? + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } @@ -49,6 +48,7 @@ internal let recordType: String internal let recordName: String? internal let fields: [String: String] + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } @@ -58,6 +58,7 @@ internal let recordName: String internal let fields: [String: String] internal let recordChangeTag: String? + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } @@ -66,6 +67,7 @@ internal let recordType: String internal let recordName: String internal let recordChangeTag: String? + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } @@ -161,6 +163,7 @@ internal let recordType: String internal let fieldName: String internal let recordName: String? + internal let zone: WebRequests.ZoneSelector? internal let database: MistKit.Database } diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift index cbcdf7172..7ca5cbf2c 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+RecordOperations.swift @@ -37,16 +37,14 @@ recordType: String, limit: Int?, sortBy: [WebRequests.QuerySortField]?, - zoneName: String?, - zoneOwner: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> [RecordInfo] { lastQuery = QueryCall( recordType: recordType, limit: limit, sortBy: sortBy, - zoneName: zoneName, - zoneOwner: zoneOwner, + zone: zone, database: database ) try consumePendingError() @@ -59,12 +57,14 @@ recordType: String, recordName: String?, fields: [String: FieldValue], + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo { lastCreate = CreateCall( recordType: recordType, recordName: recordName, fields: Self.flatten(fields), + zone: zone, database: database ) try consumePendingError() @@ -78,6 +78,7 @@ recordName: String, fields: [String: FieldValue], recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> RecordInfo { lastUpdate = UpdateCall( @@ -85,6 +86,7 @@ recordName: recordName, fields: Self.flatten(fields), recordChangeTag: recordChangeTag, + zone: zone, database: database ) try consumePendingError() @@ -97,12 +99,14 @@ recordType: String, recordName: String, recordChangeTag: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws { lastDelete = DeleteCall( recordType: recordType, recordName: recordName, recordChangeTag: recordChangeTag, + zone: zone, database: database ) try consumePendingError() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ServiceOperations.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ServiceOperations.swift index 0f586ca0c..0d1d30048 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ServiceOperations.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/MockBackend+ServiceOperations.swift @@ -129,6 +129,7 @@ recordType: String, fieldName: String, recordName: String?, + zone: WebRequests.ZoneSelector?, database: MistKit.Database ) async throws -> AssetUploadReceipt { lastUploadAsset = UploadAssetCall( @@ -136,6 +137,7 @@ recordType: recordType, fieldName: fieldName, recordName: recordName, + zone: zone, database: database ) try consumePendingError() diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift index 675b891ec..78f7c92ce 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+Index.swift @@ -107,5 +107,27 @@ ) ) } + + @Test("Query panel exposes zone name and owner inputs") + internal func indexExposesQueryZoneInputs() async throws { + let html = try await body(at: "/") + #expect(html.contains(#"id="query-zone""#)) + #expect(html.contains(#"id="query-zone-owner""#)) + #expect(html.contains("zone for query")) + + let appJs = try await body(at: "/js/app.js") + // Both backends read the same two inputs for query and writes... + #expect(appJs.contains("selectedZone()")) + #expect(appJs.contains("queryZoneInput")) + #expect(appJs.contains("queryZoneOwnerInput")) + // ...the MistKit path forwards them as-is on the query body, + // and the CloudKit JS path maps the owner to `ownerRecordName`. + #expect(appJs.contains("ownerRecordName: zoneOwner")) + // Writes set per-record zoneID for CloudKit JS save/delete. + #expect(appJs.contains("record.zoneID = zoneID")) + #expect(appJs.contains("deleteSpec.zoneID = zoneID")) + // The inputs join the controls disabled while a query is in flight. + #expect(appJs.contains("'query-zone', 'query-zone-owner'")) + } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift index 4e64c6eea..76ed15c6f 100644 --- a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+QueryZone.swift @@ -75,8 +75,8 @@ } let captured = await fixture.backend.lastQuery - #expect(captured?.zoneName == "Articles") - #expect(captured?.zoneOwner == "_abc123") + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") } } #endif diff --git a/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+WriteZone.swift b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+WriteZone.swift new file mode 100644 index 000000000..238021786 --- /dev/null +++ b/Examples/MistDemo/Tests/MistDemoTests/Server/WebServerTests+WriteZone.swift @@ -0,0 +1,219 @@ +// +// WebServerTests+WriteZone.swift +// MistDemoTests +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if canImport(Hummingbird) + internal import Foundation + internal import HTTPTypes + internal import Hummingbird + internal import HummingbirdTesting + internal import MistKit + internal import Testing + + @testable import MistDemoKit + + extension WebServerTests { + @Test("POST /api/records/create rejects zoneOwner without zoneName") + internal func createRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/create", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer( + string: #"{"recordType":"Note","fields":{"title":"x"},"zoneOwner":"_abc"}"# + ) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/records/create forwards zoneName and zoneOwner to the backend") + internal func createForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","fields":{"title":"Hi"},\ + "zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/create", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastCreate + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") + } + + @Test("POST /api/records/update rejects zoneOwner without zoneName") + internal func updateRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/update", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer( + string: #"{"recordType":"Note","recordName":"n1","fields":{},"zoneOwner":"_abc"}"# + ) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/records/update forwards zoneName and zoneOwner to the backend") + internal func updateForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","recordName":"n1","fields":{"title":"Hi"},\ + "recordChangeTag":"t1","zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/update", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastUpdate + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") + } + + @Test("POST /api/records/delete rejects zoneOwner without zoneName") + internal func deleteRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/delete", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer( + string: #"{"recordType":"Note","recordName":"n1","zoneOwner":"_abc"}"# + ) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/records/delete forwards zoneName and zoneOwner to the backend") + internal func deleteForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","recordName":"n1","recordChangeTag":"t1",\ + "zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/records/delete", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastDelete + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") + } + + @Test("POST /api/assets/upload rejects zoneOwner without zoneName") + internal func uploadRejectsZoneOwnerWithoutZoneName() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + // Minimal base64 payload ("AQ==" = one byte). + let jsonBody = """ + {"recordType":"Note","fieldName":"image","data":"AQ==","zoneOwner":"_abc"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/assets/upload", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .badRequest) + } + } + } + + @Test("POST /api/assets/upload forwards zoneName and zoneOwner to the backend") + internal func uploadForwardsZoneSelection() async throws { + let fixture = Self.makeFixture(authenticated: true) + let app = Application(router: try fixture.server.makeRouter()) + let jsonBody = """ + {"recordType":"Note","fieldName":"image","data":"AQ==",\ + "zoneName":"Articles","zoneOwner":"_abc123"} + """ + + try await app.test(.router) { client in + try await client.execute( + uri: "/api/assets/upload", + method: .post, + headers: [.contentType: "application/json"], + body: ByteBuffer(string: jsonBody) + ) { response in + #expect(response.status == .ok) + } + } + + let captured = await fixture.backend.lastUploadAsset + #expect(captured?.zone?.zoneName == "Articles") + #expect(captured?.zone?.zoneOwner == "_abc123") + } + } +#endif diff --git a/Examples/MistDemo/project.yml b/Examples/MistDemo/project.yml index 3e9f6b460..e52066601 100644 --- a/Examples/MistDemo/project.yml +++ b/Examples/MistDemo/project.yml @@ -14,7 +14,7 @@ packages: settings: base: - SWIFT_VERSION: "6.0" + SWIFT_VERSION: "6.4" MARKETING_VERSION: "1.0" CURRENT_PROJECT_VERSION: "1" PRODUCT_NAME: MistDemoApp diff --git a/Makefile b/Makefile index 6f517dd7c..ed48203cb 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,24 @@ -.PHONY: help example-server clean build +.PHONY: help example-server clean build test lint \ + release-preflight release-check release-notes-draft # Default target help: @echo "Available targets:" - @echo " example-server - Run the MistKit example server" - @echo " build - Build the MistDemo example" - @echo " clean - Clean build artifacts" - @echo " help - Show this help message" + @echo " example-server - Run the MistKit example server" + @echo " build - Build the MistDemo example" + @echo " test - Run the test suite" + @echo " lint - Run swift-format + swiftlint + periphery via Scripts/lint.sh" + @echo " clean - Clean build artifacts" + @echo " help - Show this help message" + @echo "" + @echo "Release (BRANCH defaults to the current branch, e.g. v1.0.0-beta.5):" + @echo " release-preflight - Gate a release branch: CI, pins, build/test/lint" + @echo " release-notes-draft - Write the ReleaseNotes.md section for the release" + @echo " release-check - Validate notes, README and pins before the release PR" + @echo " See .claude/skills/release/SKILL.md for the full runbook." + @echo "" + @echo "Lint tooling is pinned in mise.toml. Run 'mise install' once so" + @echo "Scripts/lint.sh can find swift-format, swiftlint, and periphery locally." # Run the example server example-server: build @@ -18,6 +30,21 @@ build: @echo "🔨 Building MistDemo example..." @cd Examples && swift build +test: + swift test + +lint: + @mise exec -- ./Scripts/lint.sh + +release-preflight: + @./Scripts/release.sh preflight $(BRANCH) + +release-notes-draft: + @./Scripts/release.sh notes-draft $(BRANCH) + +release-check: + @./Scripts/release.sh check $(BRANCH) + # Clean build artifacts clean: @echo "🧹 Cleaning build artifacts..." diff --git a/Package.swift b/Package.swift index 4cbcf39e3..db365ad59 100644 --- a/Package.swift +++ b/Package.swift @@ -5,80 +5,10 @@ import PackageDescription -// MARK: - Swift Settings Configuration - -// Swift settings for the generated OpenAPI target. swift-openapi-generator -// emits bare `import Foundation` / `import OpenAPIRuntime`; under SE-0409 -// (InternalImportsByDefault) those flip to `internal`, which breaks the -// public initializers on the generated `Client`. Leave InternalImportsByDefault -// off for the generated target. -let generatedSwiftSettings: [SwiftSetting] = [ - .enableUpcomingFeature("ExistentialAny"), - .enableUpcomingFeature("MemberImportVisibility"), - .enableUpcomingFeature("FullTypedThrows"), -] - -// Base Swift settings for all platforms +// Bare imports in swift-openapi-generator output must stay public; do not apply +// InternalImportsByDefault to the generated MistKitOpenAPI target. let swiftSettings: [SwiftSetting] = [ - // Swift 6.2 Upcoming Features (not yet enabled by default) - // SE-0335: Introduce existential `any` - .enableUpcomingFeature("ExistentialAny"), - // SE-0409: Access-level modifiers on import declarations .enableUpcomingFeature("InternalImportsByDefault"), - // SE-0444: Member import visibility (Swift 6.1+) - .enableUpcomingFeature("MemberImportVisibility"), - // SE-0413: Typed throws - .enableUpcomingFeature("FullTypedThrows"), - - // Experimental Features (stable enough for use) - // SE-0426: BitwiseCopyable protocol - .enableExperimentalFeature("BitwiseCopyable"), - // SE-0432: Borrowing and consuming pattern matching for noncopyable types - .enableExperimentalFeature("BorrowingSwitch"), - // Extension macros - .enableExperimentalFeature("ExtensionMacros"), - // Freestanding expression macros - .enableExperimentalFeature("FreestandingExpressionMacros"), - // Init accessors - .enableExperimentalFeature("InitAccessors"), - // Isolated any types - .enableExperimentalFeature("IsolatedAny"), - // Move-only classes - .enableExperimentalFeature("MoveOnlyClasses"), - // Move-only enum deinits - .enableExperimentalFeature("MoveOnlyEnumDeinits"), - // SE-0429: Partial consumption of noncopyable values - .enableExperimentalFeature("MoveOnlyPartialConsumption"), - // Move-only resilient types - .enableExperimentalFeature("MoveOnlyResilientTypes"), - // Move-only tuples - .enableExperimentalFeature("MoveOnlyTuples"), - // SE-0427: Noncopyable generics - .enableExperimentalFeature("NoncopyableGenerics"), - // One-way closure parameters - // .enableExperimentalFeature("OneWayClosureParameters"), - // Raw layout types - .enableExperimentalFeature("RawLayout"), - // Reference bindings - .enableExperimentalFeature("ReferenceBindings"), - // SE-0430: sending parameter and result values - .enableExperimentalFeature("SendingArgsAndResults"), - // Symbol linkage markers - .enableExperimentalFeature("SymbolLinkageMarkers"), - // Transferring args and results - .enableExperimentalFeature("TransferringArgsAndResults"), - // SE-0393: Value and Type Parameter Packs - .enableExperimentalFeature("VariadicGenerics"), - // Warn unsafe reflection - .enableExperimentalFeature("WarnUnsafeReflection"), - - // Enhanced compiler checking -// .unsafeFlags([ -// // Warn about functions with >100 lines -// "-Xfrontend", "-warn-long-function-bodies=100", -// // Warn about slow type checking expressions -// "-Xfrontend", "-warn-long-expression-type-checking=100" -// ]) ] let package = Package( @@ -120,8 +50,7 @@ let package = Package( name: "MistKitOpenAPI", dependencies: [ .product(name: "OpenAPIRuntime", package: "swift-openapi-runtime"), - ], - swiftSettings: generatedSwiftSettings + ] ), .target( name: "MistKit", diff --git a/Packages/MistKitConfiguration/.claude/hooks/session-start.sh b/Packages/MistKitConfiguration/.claude/hooks/session-start.sh new file mode 100755 index 000000000..90ff73974 --- /dev/null +++ b/Packages/MistKitConfiguration/.claude/hooks/session-start.sh @@ -0,0 +1,127 @@ +#!/bin/bash +set -euo pipefail + +# SessionStart hook: install a Swift toolchain for Claude Code on the web +# (Linux). Only runs in remote sessions; local sessions are untouched. Runs +# async so the session starts immediately: progress lands in +# ~/.claude-session-setup.log and ~/.claude-session-setup.done marks the end. +# +# The toolchain is all this installs. Lint tooling is deliberately left out to +# keep cold start short: swift-format ships inside the toolchain, and both +# SwiftLint and periphery are skipped in web sessions (Scripts/lint.sh omits +# them when CLAUDE_CODE_REMOTE is set). Run `make lint` locally, where mise +# provides the pinned versions, to get full coverage. +# +# This hook is the second tier of a two-tier setup. The first tier is +# Scripts/cloud-setup.sh, pasted into the cloud environment's "Setup script" +# field: it runs once, then the filesystem is snapshotted and later sessions +# reuse it, so the ~1 GB toolchain download happens once per environment +# instead of once per container. When that snapshot exists, the `command -v +# swift` check below short-circuits and this hook finishes in about a second. +# +# The hook is still required on every session for two reasons: a snapshot +# restores files but not environment variables, so PATH has to be re-exported +# into CLAUDE_ENV_FILE each time; and an environment with no setup script +# configured (a fresh clone, another contributor) still needs the toolchain +# installed from here. +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +echo '{"async": true, "asyncTimeout": 2400000}' + +SETUP_LOG="$HOME/.claude-session-setup.log" +SETUP_DONE="$HOME/.claude-session-setup.done" +rm -f "$SETUP_DONE" +exec >> "$SETUP_LOG" 2>&1 + +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" +PROJECT_DIR="${CLAUDE_PROJECT_DIR:-$PWD}" + +# Make swift reachable for the session up front; an entry pointing at a +# not-yet-populated directory is harmless. +if [ -n "${CLAUDE_ENV_FILE:-}" ]; then + { + echo "export SWIFTLY_HOME_DIR=\"$HOME/.local/share/swiftly\"" + echo "export SWIFTLY_BIN_DIR=\"$HOME/.local/share/swiftly/bin\"" + echo "export PATH=\"$HOME/.local/share/swiftly/bin:\$PATH\"" + } >> "$CLAUDE_ENV_FILE" +fi + +install_swift() { + # System dependencies for Swift on Ubuntu 24.04 (per swift.org Linux + # instructions), plus curl for fetching swiftly. Most are already in the + # base image, so only reach for apt when something is genuinely missing -- + # `apt-get update` alone costs ~10s. + local packages missing pkg + packages=( + binutils + curl + git + gnupg2 + libc6-dev + libcurl4-openssl-dev + libedit2 + libgcc-13-dev + libncurses-dev + libpython3-dev + libsqlite3-0 + libstdc++-13-dev + libxml2-dev + libz3-dev + pkg-config + tzdata + zlib1g-dev + ) + missing=() + for pkg in "${packages[@]}"; do + if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then + missing+=("$pkg") + fi + done + + if [ "${#missing[@]}" -gt 0 ]; then + echo "Installing missing system packages: ${missing[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + apt-get install -y -qq --no-install-recommends "${missing[@]}" + else + echo "All system packages already present; skipping apt." + fi + + # Install swiftly non-interactively, then the toolchain pinned by the + # repo's .swift-version (falling back to latest if no pin resolves). + local workdir + workdir="$(mktemp -d)" + pushd "$workdir" > /dev/null + curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" + tar zxf "swiftly-$(uname -m).tar.gz" + ./swiftly init -y --skip-install + popd > /dev/null + rm -rf "$workdir" + + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" + + cd "$PROJECT_DIR" + if ! swiftly install -y; then + echo "Pinned toolchain install failed; falling back to latest." >&2 + swiftly install -y latest + swiftly use -y latest + fi +} + +# Pick up a swiftly install from a previous (cached) hook run. +if [ -f "$SWIFTLY_ENV" ]; then + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" +fi + +if command -v swift > /dev/null 2>&1; then + echo "Swift already installed: $(swift --version 2>&1 | head -1)" +else + install_swift +fi + +swift --version +touch "$SETUP_DONE" diff --git a/Packages/MistKitConfiguration/.claude/settings.json b/Packages/MistKitConfiguration/.claude/settings.json new file mode 100644 index 000000000..6738f065d --- /dev/null +++ b/Packages/MistKitConfiguration/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "\"$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh\"" + } + ] + } + ] + } +} diff --git a/Packages/MistKitConfiguration/.github/actions/setup-mistkitconfiguration/action.yml b/Packages/MistKitConfiguration/.github/actions/setup-mistkitconfiguration/action.yml new file mode 100644 index 000000000..68d3d939d --- /dev/null +++ b/Packages/MistKitConfiguration/.github/actions/setup-mistkitconfiguration/action.yml @@ -0,0 +1,87 @@ +name: Setup MistKitConfiguration +description: > + Replaces monorepo path dependencies for MistKit and MistKitConfiguration with + remote references pinned to each branch's current commit. Standalone example CI + must rewrite both — a path: MistKit and a url: MistKit cannot coexist. + +inputs: + mistkit-branch: + description: MistKit branch to pin (leave empty to keep the local MistKit path dependency) + required: false + default: "" + mistkitconfiguration-branch: + description: MistKitConfiguration branch to pin (leave empty to keep the local path dependency) + required: false + default: "" + +runs: + using: composite + steps: + # Resolve each branch to its current HEAD and pin by `revision:` so + # `swift package dump-package` (hashed by swift-build@v1) changes when the + # branch advances. Falls back to `branch:` if the ref can't be resolved. + - name: Update Package.swift (Unix) + if: (inputs.mistkit-branch != '' || inputs.mistkitconfiguration-branch != '') && runner.os != 'Windows' + shell: bash + run: | + set -euo pipefail + + pin_dep() { + local NAME="$1" + local REPO="$2" + local BRANCH="$3" + local PATH_PATTERN="$4" + local REF + REF=$(git ls-remote "https://github.com/brightdigit/${REPO}.git" "$BRANCH" | head -n1 | cut -f1) + local REQ + if [ -n "$REF" ]; then + REQ="revision: \"$REF\"" + echo "Pinning $NAME to $BRANCH @ $REF" + else + REQ="branch: \"$BRANCH\"" + echo "Could not resolve $BRANCH for $NAME; pinning by branch" + fi + if [ "$RUNNER_OS" = "macOS" ]; then + sed -i '' "s|${PATH_PATTERN}|.package(url: \"https://github.com/brightdigit/${REPO}.git\", ${REQ})|g" Package.swift + else + sed -i "s|${PATH_PATTERN}|.package(url: \"https://github.com/brightdigit/${REPO}.git\", ${REQ})|g" Package.swift + fi + } + + if [ -n '${{ inputs.mistkit-branch }}' ]; then + pin_dep MistKit MistKit '${{ inputs.mistkit-branch }}' \ + '\.package(name: "MistKit", path: "\.\./\.\.")' + fi + + if [ -n '${{ inputs.mistkitconfiguration-branch }}' ]; then + # Monorepo dogfood form used by Examples/*/Package.swift + pin_dep MistKitConfiguration MistKitConfiguration '${{ inputs.mistkitconfiguration-branch }}' \ + '\.package(path: "\.\./\.\./Packages/MistKitConfiguration")' + fi + + rm -f Package.resolved + + - name: Update Package.swift (Windows) + if: (inputs.mistkit-branch != '' || inputs.mistkitconfiguration-branch != '') && runner.os == 'Windows' + shell: pwsh + run: | + function Pin-Dep($Name, $Repo, $Branch, $PathPattern) { + $ref = (git ls-remote "https://github.com/brightdigit/$Repo.git" $Branch | Select-Object -First 1) -split "`t" | Select-Object -First 1 + if ($ref) { + $req = "revision: `"$ref`"" + Write-Host "Pinning $Name to $Branch @ $ref" + } else { + $req = "branch: `"$Branch`"" + Write-Host "Could not resolve $Branch for $Name; pinning by branch" + } + $replacement = ".package(url: `"https://github.com/brightdigit/$Repo.git`", $req)" + (Get-Content Package.swift) -replace $PathPattern, $replacement | Set-Content Package.swift + } + + if ('${{ inputs.mistkit-branch }}' -ne '') { + Pin-Dep MistKit MistKit '${{ inputs.mistkit-branch }}' '\.package\(name: "MistKit", path: "\.\./\.\."\)' + } + if ('${{ inputs.mistkitconfiguration-branch }}' -ne '') { + Pin-Dep MistKitConfiguration MistKitConfiguration '${{ inputs.mistkitconfiguration-branch }}' '\.package\(path: "\.\./\.\./Packages/MistKitConfiguration"\)' + } + Remove-Item -Path Package.resolved -Force -ErrorAction SilentlyContinue diff --git a/Packages/MistKitConfiguration/.github/actions/setup-tools/action.yml b/Packages/MistKitConfiguration/.github/actions/setup-tools/action.yml new file mode 100644 index 000000000..069f32e9b --- /dev/null +++ b/Packages/MistKitConfiguration/.github/actions/setup-tools/action.yml @@ -0,0 +1,29 @@ +name: Setup mise tools +description: >- + Restore (or build + save) the mise tool cache and put the binaries on PATH. + Implemented as a composite action so the cache scope is the caller job's + scope — reusable workflows scope caches separately, which silently breaks + hand-off between a setup job and a consumer lint job. + +runs: + using: composite + steps: + - name: Cache mise tools + id: mise-cache + uses: actions/cache@v4 + with: + path: ~/.local/share/mise/installs + key: mise-v2-${{ runner.os }}-${{ runner.arch }}-${{ hashFiles('mise.toml') }} + restore-keys: | + mise-v2-${{ runner.os }}-${{ runner.arch }}- + - name: Install mise tools (cache miss) + if: steps.mise-cache.outputs.cache-hit != 'true' + uses: jdx/mise-action@v4 + with: + cache: false + - name: Configure PATH for cached mise tools + if: steps.mise-cache.outputs.cache-hit == 'true' + uses: jdx/mise-action@v4 + with: + install: false + cache: false diff --git a/Packages/MistKitConfiguration/.github/workflows/MistKitConfiguration.yml b/Packages/MistKitConfiguration/.github/workflows/MistKitConfiguration.yml new file mode 100644 index 000000000..6b49535c4 --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/MistKitConfiguration.yml @@ -0,0 +1,313 @@ +name: MistKitConfiguration +on: + push: + branches: + - main + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + pull_request: + paths-ignore: + - '**.md' + - 'docs/**' + - 'LICENSE' + - '.github/ISSUE_TEMPLATE/**' + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.ref }} + cancel-in-progress: true + +env: + PACKAGE_NAME: MistKitConfiguration + MISTKIT_BRANCH: v1.0.0-beta.5 + +jobs: + configure: + name: Configure Matrix + runs-on: ubuntu-latest + outputs: + full-matrix: ${{ steps.check.outputs.full }} + ubuntu-os: ${{ steps.matrix.outputs.ubuntu-os }} + ubuntu-swift: ${{ steps.matrix.outputs.ubuntu-swift }} + steps: + - id: check + name: Determine matrix scope + run: | + FULL=false + REF="${{ github.ref }}" + EVENT="${{ github.event_name }}" + BASE_REF="${{ github.base_ref }}" + + # Full matrix on main + if [[ "$REF" == "refs/heads/main" ]]; then + FULL=true + # Full matrix on semver branches (v1.0.0, 1.2.3-alpha.1, etc.) + elif [[ "$REF" =~ ^refs/heads/v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + # Full matrix on PRs targeting main or semver branches + elif [[ "$EVENT" == "pull_request" ]]; then + if [[ "$BASE_REF" == "main" || "$BASE_REF" =~ ^v?[0-9]+\.[0-9]+\.[0-9]+ ]]; then + FULL=true + fi + fi + + echo "full=$FULL" >> "$GITHUB_OUTPUT" + echo "Full matrix: $FULL (ref=$REF, event=$EVENT, base_ref=$BASE_REF)" + + - id: matrix + name: Build matrix values + run: | + # MistKitConfiguration's Package.swift declares swift-tools-version: 6.4. + # Swift 6.4 has no release toolchain yet, so the nightly image is the + # only Linux toolchain that can parse the manifest — no 6.3 lanes. + SWIFT='[{"version":"6.4","image":"swiftlang/swift:nightly-6.4.x"}]' + if [[ "${{ steps.check.outputs.full }}" == "true" ]]; then + echo 'ubuntu-os=["noble","jammy"]' >> "$GITHUB_OUTPUT" + else + echo 'ubuntu-os=["noble"]' >> "$GITHUB_OUTPUT" + fi + echo "ubuntu-swift=$SWIFT" >> "$GITHUB_OUTPUT" + + build-ubuntu: + name: Build on Ubuntu + needs: configure + runs-on: ubuntu-latest + # The `||` branch (a plain swift:- image) is unreachable while + # the matrix emits only the 6.4 nightly entry, which always sets `image`. + # Kept deliberately: it is what a release-toolchain lane would use once + # Swift 6.4 ships and can be added back to the matrix. + container: ${{ matrix.swift.image && format('{0}-{1}', matrix.swift.image, matrix.os) || format('swift:{0}-{1}', matrix.swift.version, matrix.os) }} + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(needs.configure.outputs.ubuntu-os) }} + swift: ${{ fromJSON(needs.configure.outputs.ubuntu-swift) }} + + steps: + - uses: actions/checkout@v6 + + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + + - uses: brightdigit/swift-build@v1 + id: build + with: + skip-package-resolved: true + - name: Install curl (required by Codecov uploader) + if: steps.build.outputs.contains-code-coverage == 'true' + run: | + if command -v apt-get >/dev/null 2>&1; then + apt-get update && apt-get install -y --no-install-recommends curl ca-certificates + fi + - uses: sersoft-gmbh/swift-coverage-action@v5 + if: steps.build.outputs.contains-code-coverage == 'true' + id: coverage-files + with: + fail-on-empty-output: true + - name: Upload coverage to Codecov + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + fail_ci_if_error: false + flags: swift-${{ matrix.swift.version }}-${{ matrix.os }} + verbose: true + token: ${{ secrets.CODECOV_TOKEN }} + files: ${{ join(fromJSON(steps.coverage-files.outputs.files), ',') }} + + # build-windows: + # name: Build on Windows + # needs: configure + # runs-on: ${{ matrix.runs-on }} + # if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + # strategy: + # fail-fast: false + # matrix: + # runs-on: [windows-2022, windows-2025] + # swift: + # - version: swift-6.3-release + # build: 6.3-RELEASE + # steps: + # - uses: actions/checkout@v6 + # - name: Setup MistKit + # uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + # with: + # branch: ${{ env.MISTKIT_BRANCH }} + # - uses: brightdigit/swift-build@v1 + # id: build + # with: + # windows-swift-version: ${{ matrix.swift.version }} + # windows-swift-build: ${{ matrix.swift.build }} + # skip-package-resolved: true + # - name: Upload coverage to Codecov + # if: steps.build.outputs.contains-code-coverage == 'true' + # uses: codecov/codecov-action@v6 + # with: + # fail_ci_if_error: false + # flags: swift-${{ matrix.swift.version }},windows + # verbose: true + # token: ${{ secrets.CODECOV_TOKEN }} + # os: windows + + # Minimal macOS builds — always runs (SPM + iOS) + build-macos: + name: Build on macOS + runs-on: ${{ matrix.runs-on }} + if: ${{ !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # A swift-tools-version: 6.4 manifest needs a Swift 6.4 toolchain, so + # these lanes run on the xcode-27 image (Xcode 27.0) rather than + # macos-26 / Xcode 26.6, which cannot parse the manifest. + # SPM build + - runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + + # iOS build + - type: ios + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "iPhone 17 Pro" + osVersion: "27.0" + download-platform: true + steps: + - uses: actions/checkout@v6 + + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + skip-package-resolved: true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + # Full macOS platform builds — only on main, semver branches, and PRs targeting them + build-macos-platforms: + name: Build on macOS (Platforms) + needs: configure + runs-on: ${{ matrix.runs-on }} + if: ${{ needs.configure.outputs.full-matrix == 'true' && !contains(github.event.head_commit.message, 'ci skip') }} + strategy: + fail-fast: false + matrix: + include: + # ── Xcode 27 (preview image, beta toolchain) ───────────────────── + # A swift-tools-version: 6.4 manifest needs a Swift 6.4 toolchain, so + # every Apple lane runs here; the macos-26 / Xcode 26.6 lanes cannot + # parse the manifest and are gone. + # + # `runs-on: xcode-27` is its own image label, not a macos-NN one, and + # is marked Preview in actions/runner-images. It ships exactly one + # Xcode — 27.0 beta — so there is no 26.x fallback on this runner. + # + # Pin the /Applications/Xcode_27.0.app symlink, NOT the real + # Xcode_27_beta_4.app path: the beta number changes on every image + # refresh and would silently break these lanes. + # + # tvOS uses "Apple TV 4K (3rd generation)" — this image has no plain + # "Apple TV" device. A wrong device name fails the simulator boot + # outright. + - type: macos + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + + - type: ios + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "iPhone 17 Pro" + osVersion: "27.0" + download-platform: true + + - type: watchos + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "Apple Watch Ultra 3 (49mm)" + osVersion: "27.0" + download-platform: true + + - type: tvos + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "Apple TV 4K (3rd generation)" + osVersion: "27.0" + download-platform: true + + - type: visionos + runs-on: xcode-27 + xcode: "/Applications/Xcode_27.0.app" + deviceName: "Apple Vision Pro" + osVersion: "27.0" + download-platform: true + steps: + - uses: actions/checkout@v6 + + - name: Setup MistKit + uses: brightdigit/MistKit/.github/actions/setup-mistkit@main + with: + branch: ${{ env.MISTKIT_BRANCH }} + + - name: Build and Test + id: build + uses: brightdigit/swift-build@v1 + with: + type: ${{ matrix.type }} + xcode: ${{ matrix.xcode }} + deviceName: ${{ matrix.deviceName }} + osVersion: ${{ matrix.osVersion }} + download-platform: ${{ matrix.download-platform }} + skip-package-resolved: true + - name: Process Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: sersoft-gmbh/swift-coverage-action@v5 + - name: Upload Coverage + if: steps.build.outputs.contains-code-coverage == 'true' + uses: codecov/codecov-action@v6 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: ${{ matrix.type && format('{0}{1}', matrix.type, matrix.osVersion) || 'spm' }} + + lint: + name: Linting + # lint.sh ends with `swift build --build-tests`; the manifest is + # swift-tools-version: 6.4, so this must run on a 6.4 toolchain (ubuntu-latest + # ships 6.3 via mise and fails to parse Package.swift). + runs-on: ubuntu-latest + container: swiftlang/swift:nightly-6.4.x-noble + if: ${{ !cancelled() && !failure() && !contains(github.event.head_commit.message, 'ci skip') }} + needs: [build-ubuntu, build-macos, build-macos-platforms] + steps: + - uses: actions/checkout@v6 + - name: Install mise tooling deps + run: | + apt-get update + apt-get install -y --no-install-recommends curl ca-certificates git + - uses: jdx/mise-action@v4 + with: + cache: true + - name: Lint + run: | + set -e + ./Scripts/lint.sh diff --git a/Packages/MistKitConfiguration/.github/workflows/claude-code-review.yml b/Packages/MistKitConfiguration/.github/workflows/claude-code-review.yml new file mode 100644 index 000000000..b5e8cfd4d --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/claude-code-review.yml @@ -0,0 +1,44 @@ +name: Claude Code Review + +on: + pull_request: + types: [opened, synchronize, ready_for_review, reopened] + # Optional: Only run on specific file changes + # paths: + # - "src/**/*.ts" + # - "src/**/*.tsx" + # - "src/**/*.js" + # - "src/**/*.jsx" + +jobs: + claude-review: + # Optional: Filter by PR author + # if: | + # github.event.pull_request.user.login == 'external-contributor' || + # github.event.pull_request.user.login == 'new-developer' || + # github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR' + + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code Review + id: claude-review + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' + plugins: 'code-review@claude-code-plugins' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + diff --git a/Packages/MistKitConfiguration/.github/workflows/claude.yml b/Packages/MistKitConfiguration/.github/workflows/claude.yml new file mode 100644 index 000000000..6b15fac7a --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/claude.yml @@ -0,0 +1,50 @@ +name: Claude Code + +on: + issue_comment: + types: [created] + pull_request_review_comment: + types: [created] + issues: + types: [opened, assigned] + pull_request_review: + types: [submitted] + +jobs: + claude: + if: | + (github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) || + (github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) || + (github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude'))) + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: read + issues: read + id-token: write + actions: read # Required for Claude to read CI results on PRs + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 1 + + - name: Run Claude Code + id: claude + uses: anthropics/claude-code-action@v1 + with: + claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} + + # This is an optional setting that allows Claude to read CI results on PRs + additional_permissions: | + actions: read + + # Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it. + # prompt: 'Update the pull request description to include a summary of changes.' + + # Optional: Add claude_args to customize behavior and configuration + # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md + # or https://code.claude.com/docs/en/cli-reference for available options + # claude_args: '--allowed-tools Bash(gh pr *)' + diff --git a/Packages/MistKitConfiguration/.github/workflows/cleanup-caches.yml b/Packages/MistKitConfiguration/.github/workflows/cleanup-caches.yml new file mode 100644 index 000000000..f0124e2c4 --- /dev/null +++ b/Packages/MistKitConfiguration/.github/workflows/cleanup-caches.yml @@ -0,0 +1,29 @@ +name: Cleanup Branch Caches +on: + delete: + +jobs: + cleanup: + runs-on: ubuntu-latest + permissions: + actions: write + steps: + - name: Cleanup caches for deleted branch + uses: actions/github-script@v9 + with: + script: | + const ref = `refs/heads/${context.payload.ref}`; + const caches = await github.rest.actions.getActionsCacheList({ + owner: context.repo.owner, + repo: context.repo.repo, + ref: ref, + }); + for (const cache of caches.data.actions_caches) { + console.log(`Deleting cache: ${cache.key}`); + await github.rest.actions.deleteActionsCacheById({ + owner: context.repo.owner, + repo: context.repo.repo, + cache_id: cache.id, + }); + } + console.log(`Deleted ${caches.data.actions_caches.length} cache(s) for ${ref}`); diff --git a/Examples/BushelCloud/.github/workflows/dependency-policy.yml b/Packages/MistKitConfiguration/.github/workflows/dependency-policy.yml similarity index 78% rename from Examples/BushelCloud/.github/workflows/dependency-policy.yml rename to Packages/MistKitConfiguration/.github/workflows/dependency-policy.yml index 17c38b636..df9605b97 100644 --- a/Examples/BushelCloud/.github/workflows/dependency-policy.yml +++ b/Packages/MistKitConfiguration/.github/workflows/dependency-policy.yml @@ -1,8 +1,9 @@ name: Dependency Policy -# Gate for PRs targeting `main`: Package.swift may only use tagged (version) -# dependencies. Branch, revision, or local-path dependencies are integration-only -# and must be bumped to a released tag before merging into `main`. +# Gate for PRs targeting `main` that are ready to merge: Package.swift may only use +# tagged (version) dependencies. Branch, revision, or local-path dependencies are +# integration-only and must be bumped to a released tag before leaving draft / +# merging into `main`. Draft PRs skip this check so integration pins can stay green. on: pull_request: @@ -16,6 +17,7 @@ concurrency: jobs: tagged-dependencies: name: Verify tagged dependencies + if: github.event.pull_request.draft == false runs-on: ubuntu-latest container: swiftlang/swift:nightly-6.4.x-noble steps: diff --git a/Packages/MistKitConfiguration/.gitignore b/Packages/MistKitConfiguration/.gitignore new file mode 100644 index 000000000..20909d840 --- /dev/null +++ b/Packages/MistKitConfiguration/.gitignore @@ -0,0 +1,84 @@ +# macOS +.DS_Store + +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## User settings +xcuserdata/ + +## Obj-C/Swift specific +*.hmap + +## App packaging +*.ipa +*.dSYM.zip +*.dSYM + +## Playgrounds +timeline.xctimeline +playground.xcworkspace + +# Swift Package Manager +# +# Add this line if you want to avoid checking in source code from Swift Package Manager dependencies. +# Packages/ +# Package.pins +# *.xcodeproj +# +# Xcode automatically generates this directory with a .xcworkspacedata file and xcuserdata +# hence it is not needed unless you have added a package configuration file to your project +Package.resolved +.swiftpm/ +.build/ +DerivedData/ +.index-build/ + +# Generated Xcode projects/workspaces +*.xcodeproj +*.xcworkspace + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +# Pods/ +# +# Add this line if you want to avoid checking in source code from the Xcode workspace +# *.xcworkspace + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build/ + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. +# Instead, use fastlane to re-generate the screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://docs.fastlane.tools/best-practices/source-control/#source-control + +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots/**/*.png +fastlane/test_output + +# IDE +.vscode/ +.idea/ + +# mise / mint local installs +.mint/ + +# Editor scratch +*.sw? + +# Claude +.claude/settings.local.json +.claude/scheduled_tasks.lock diff --git a/Packages/MistKitConfiguration/.gitrepo b/Packages/MistKitConfiguration/.gitrepo new file mode 100644 index 000000000..1d26e9ca8 --- /dev/null +++ b/Packages/MistKitConfiguration/.gitrepo @@ -0,0 +1,11 @@ +; DO NOT EDIT (unless you know what you are doing) +; +; This subdirectory is a git "subrepo", and this file is maintained by the +; git-subrepo command. See https://github.com/ingydotnet/git-subrepo#readme +; +[subrepo] + remote = git@github.com:brightdigit/MistKitConfiguration.git + branch = mistkit-beta.5 + commit = a70afee6b05575a654e442216dad47dc5ea7a672 + method = merge + cmdver = 0.4.9 diff --git a/Packages/MistKitConfiguration/.periphery.yml b/Packages/MistKitConfiguration/.periphery.yml new file mode 100644 index 000000000..963c035aa --- /dev/null +++ b/Packages/MistKitConfiguration/.periphery.yml @@ -0,0 +1,3 @@ +retain_public: true +retain_unused_protocol_func_params: true +retain_assign_only_properties: true diff --git a/Packages/MistKitConfiguration/.spi.yml b/Packages/MistKitConfiguration/.spi.yml new file mode 100644 index 000000000..70794bec1 --- /dev/null +++ b/Packages/MistKitConfiguration/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + configs: + - documentation_targets: [MistKitConfiguration] + swift_version: 6.4 diff --git a/Packages/MistKitConfiguration/.swift-format b/Packages/MistKitConfiguration/.swift-format new file mode 100644 index 000000000..257f55578 --- /dev/null +++ b/Packages/MistKitConfiguration/.swift-format @@ -0,0 +1,70 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "fileprivate" + }, + "indentation" : { + "spaces" : 2 + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : true, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : false, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : true, + "NeverUseForceTry" : true, + "NeverUseImplicitlyUnwrappedOptionals" : true, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : true, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : true, + "ValidateDocumentationComments" : true + }, + "spacesAroundRangeFormationOperators" : false, + "tabWidth" : 2, + "version" : 1 +} diff --git a/Packages/MistKitConfiguration/.swift-version b/Packages/MistKitConfiguration/.swift-version new file mode 100644 index 000000000..c596943a9 --- /dev/null +++ b/Packages/MistKitConfiguration/.swift-version @@ -0,0 +1 @@ +6.4 diff --git a/Packages/MistKitConfiguration/.swiftlint.yml b/Packages/MistKitConfiguration/.swiftlint.yml new file mode 100644 index 000000000..08f1c1fc4 --- /dev/null +++ b/Packages/MistKitConfiguration/.swiftlint.yml @@ -0,0 +1,141 @@ +opt_in_rules: + - array_init + - closure_body_length + - closure_end_indentation + - closure_spacing + - collection_alignment + - conditional_returns_on_newline + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - convenience_type + - discouraged_object_literal + - discouraged_optional_boolean + - empty_collection_literal + - empty_count + - empty_string + - empty_xctest_method + - enum_case_associated_values_count + - expiring_todo + - explicit_acl + - explicit_init + - explicit_top_level_acl + - fatal_error_message + - file_name + - file_name_no_space + - file_types_order + - first_where + - flatmap_over_map_reduce + - force_unwrapping + - ibinspectable_in_extension + - identical_operands + - implicit_return + - implicitly_unwrapped_optional + - indentation_width + - joined_default_parameter + - last_where + - legacy_multiple + - legacy_random + - literal_expression_end_indentation + - lower_acl_than_parent + - missing_docs + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - nimble_operator + - nslocalizedstring_key + - nslocalizedstring_require_bundle + - number_separator + - object_literal + - one_declaration_per_file + - operator_usage_whitespace + - optional_enum_case_matching + - overridden_super_call + - override_in_extension + - pattern_matching_keywords + - prefer_self_type_over_type_of_self + - prefer_zero_over_explicit_init + - private_action + - private_outlet + - prohibited_interface_builder + - prohibited_super_call + - quick_discouraged_call + - quick_discouraged_focused_test + - quick_discouraged_pending_test + - reduce_into + - redundant_nil_coalescing + - redundant_type_annotation + - required_enum_case + - single_test_class + - sorted_first_last + - sorted_imports + - static_operator + - strong_iboutlet + - toggle_bool + - type_contents_order + - unavailable_function + - unneeded_parentheses_in_closure_argument + - unowned_variable_capture + - untyped_error_in_catch + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + - xct_specific_matcher + - yoda_condition +analyzer_rules: + - unused_import + - unused_declaration +cyclomatic_complexity: + - 6 + - 12 +file_length: + warning: 225 + error: 300 +function_body_length: + - 50 + - 76 +function_parameter_count: 8 +line_length: + - 108 + - 200 +closure_body_length: + - 50 + - 60 +type_name: + min_length: 3 + max_length: + warning: 50 + error: 60 +identifier_name: + excluded: + - id + - no +excluded: + - DerivedData + - .build + - Package.swift +indentation_width: + indentation_width: 2 +file_name: + severity: error +fatal_error_message: + severity: error +disabled_rules: + - nesting + - implicit_getter + - switch_case_alignment + - closure_parameter_position + - trailing_comma + - opening_brace + - optional_data_string_conversion + - pattern_matching_keywords +custom_rules: + no_unchecked_sendable: + name: "No Unchecked Sendable" + regex: '@unchecked\s+Sendable' + message: "Use proper Sendable conformance instead of @unchecked Sendable to maintain strict concurrency safety" + severity: error diff --git a/Packages/MistKitConfiguration/CLAUDE.md b/Packages/MistKitConfiguration/CLAUDE.md new file mode 100644 index 000000000..b86676438 --- /dev/null +++ b/Packages/MistKitConfiguration/CLAUDE.md @@ -0,0 +1,47 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this is + +`MistKitConfiguration` is a small Swift 6.4 library (single `MistKitConfiguration` product) holding the CloudKit credential configuration glue shared by MistKit's server-side applications: read → validate → build a `CloudKitService`. It depends on **MistKit**, **ConfigKeyKit** and **apple/swift-configuration**. MistKit must never depend on it — the arrow points one way, which is the whole reason this is a separate repository rather than a MistKit product. + +## Commands + +- `make build` / `swift build` +- `make test` / `swift test` (Swift Testing, not XCTest) +- Run one suite: `swift test --filter KeyIDValidator` +- `make lint` — `Scripts/lint.sh`: swift-format, SwiftLint, license-header check, periphery +- `make clean` + +Lint tooling is pinned via **mise** (`mise.toml`): swift-format 602.0.0, SwiftLint 0.62.2, periphery 3.7.4. Run `mise install` once so `Scripts/lint.sh` finds them outside CI. + +## Architecture + +Three layers, deliberately separated so that *reading* configuration cannot fail: + +1. **`CloudKitConfiguration`** — raw, every field `String?`, no validation. `environment` stays a string so "unspecified" is distinguishable from "explicitly development" and an unrecognized value fails at validation rather than at read time. +2. **`validated()`** — checks presence *before* format, resolves inline PEM over a file path, parses the environment, and returns… +3. **`ValidatedCloudKitConfiguration`** — whose initializer is throwing and runs `KeyIDValidator` (and `PEMValidator` for an inline key). There is therefore **no way to hold this type with credentials that skipped format validation**; that property is what lets callers delete their own hand-rolled checks. Do not add a non-throwing initializer. + +Supporting pieces: `CloudKitConfigurationKeys` (a value type, not a `static` enum, because the container default and env prefix are per-application), `readCloudKitConfiguration(keys:)` on the `ConfigValueReading` protocol, and `ConfigurationSources` for the provider stack. + +## Conventions + +- **Errors carry no prose.** `CloudKitConfigurationError`, `KeyIDValidationFailure` and `PEMValidationFailure` are `Equatable` enums and deliberately do **not** conform to `LocalizedError`. Every consumer already owns an error type with its own wording, remediation advice and key names; package-authored text would contradict all three. `ConfigurationError` is a presentation convenience this package never throws. Keep it that way. +- **`CloudKitConfigurationField`, not key strings.** Consumers spell the same field differently, so errors name a field and `CloudKitConfigurationKeys.subscript(_:)` maps it back to whatever key that application uses. +- **Key bases are dash-case** (`cloudkit.key-id`, never `cloudkit.key_id`). `CLIKeyEncoder` joins components verbatim, so an underscore produces an unusable flag *and* silently defeats secret redaction, since the redaction list matches the generated flag. `secretCommandLineFlags` is derived from `isSecret` so it cannot drift. +- **Swift 6.4** with `ExistentialAny`, `InternalImportsByDefault`, `MemberImportVisibility`, `FullTypedThrows` enabled. Mark every import `internal import` / `public import`; never bare `import`. +- Everything is `Sendable`. Typed throws (`throws(CloudKitConfigurationError)`) are used where the error set is closed. +- SwiftLint runs with `explicit_acl`, `explicit_top_level_acl`, `missing_docs`, `one_declaration_per_file` and `file_name` (severity **error** — the primary type's name must match the filename; extensions use `Type+Feature.swift`). No `!`. +- Every source file carries the MIT header; `Scripts/header.sh` enforces it. + +## The MistKit dependency + +`Package.swift` in the **monorepo** uses `.package(name: "MistKit", path: "../..")`. This is not a preference: a `path:` package takes its identity from the directory name, so mixing it with a sibling that depends on MistKit by `url:` makes SwiftPM resolve two distinct packages and fail with *"multiple similar targets 'MistKit', 'MistKitOpenAPI'"*. Every package in that monorepo must reach MistKit the same way. + +The standalone repository carries the `url:` form instead — and **`git subrepo push` does not know that**. It would copy the `path:` line over and break the standalone repo, so the swap has to be re-applied after every push. That is the same never-merged-overlay discipline `Examples/BushelCloud/Package.swift` documents for its own MistKit line. CI swaps it with `brightdigit/MistKit/.github/actions/setup-mistkit@main`, pinned by `MISTKIT_BRANCH`. **That value must be a branch name, not a tag** — `git ls-remote` matches tags too, and a tag would silently pin an old release. Before cutting a release, the dependency must be a tagged `url:`, or the tag is unusable downstream; `dependency-policy.yml` gates PRs to `main` on exactly that. + +## CI + +Swift 6.4 has no Linux or Windows release toolchain, so `build-ubuntu` runs the single `swiftlang/swift:nightly-6.4.x` matrix entry, `build-windows` is commented out, there is no Android job, and macOS runs on `runs-on: xcode-27`. On that image tvOS must use `"Apple TV 4K (3rd generation)"` — there is no plain `"Apple TV"`. Restore the release lanes once Swift 6.4 ships them. diff --git a/Packages/MistKitConfiguration/LICENSE b/Packages/MistKitConfiguration/LICENSE new file mode 100644 index 000000000..5bf4bad43 --- /dev/null +++ b/Packages/MistKitConfiguration/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 BrightDigit + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Packages/MistKitConfiguration/Makefile b/Packages/MistKitConfiguration/Makefile new file mode 100644 index 000000000..96f245d6f --- /dev/null +++ b/Packages/MistKitConfiguration/Makefile @@ -0,0 +1,22 @@ +.PHONY: help build test lint clean + +help: + @echo "Available targets:" + @echo " build - Build the package" + @echo " test - Run the test suite" + @echo " lint - Run swift-format + swiftlint + periphery via Scripts/lint.sh" + @echo " clean - Clean build artifacts" + @echo " help - Show this help message" + +build: + swift build + +test: + swift test + +lint: + @./Scripts/lint.sh + +clean: + swift package clean + rm -rf .build diff --git a/Packages/MistKitConfiguration/Package.swift b/Packages/MistKitConfiguration/Package.swift new file mode 100644 index 000000000..05f7ef1a5 --- /dev/null +++ b/Packages/MistKitConfiguration/Package.swift @@ -0,0 +1,65 @@ +// swift-tools-version: 6.4 + +// swiftlint:disable explicit_acl explicit_top_level_acl + +import PackageDescription + +let swiftSettings: [SwiftSetting] = [ + .enableUpcomingFeature("InternalImportsByDefault"), +] + +let package = Package( + name: "MistKitConfiguration", + platforms: [ + .macOS(.v15), + .iOS(.v18), + .tvOS(.v18), + .watchOS(.v11), + .visionOS(.v2), + ], + products: [ + .library(name: "MistKitConfiguration", targets: ["MistKitConfiguration"]) + ], + dependencies: [ + // A local path dependency, not a tagged URL, and deliberately so: a `path:` + // package takes its identity from the directory name, so pairing it with a + // sibling that depends on MistKit by `url:` makes SwiftPM resolve two distinct + // packages and fail with "multiple similar targets 'MistKit', 'MistKitOpenAPI'". + // Every package inside this monorepo must therefore reach MistKit the same way. + // + // ⚠️ This line is a monorepo-local overlay that must NEVER reach the standalone + // repository. `git subrepo push Packages/MistKitConfiguration` would carry it over + // and break `brightdigit/MistKitConfiguration`, whose own manifest deliberately + // carries `.package(url: …MistKit.git, from: "1.0.0-beta.4")` — the form that makes a + // tag of this package usable downstream. Re-apply the swap after every push, the same + // never-merged-overlay discipline `Examples/BushelCloud/Package.swift` documents. + .package(name: "MistKit", path: "../.."), + .package( + url: "https://github.com/brightdigit/ConfigKeyKit.git", + from: "1.0.0-beta.3" + ), + .package( + url: "https://github.com/apple/swift-configuration.git", + from: "1.0.0", + traits: ["CommandLineArguments"] + ), + ], + targets: [ + .target( + name: "MistKitConfiguration", + dependencies: [ + .product(name: "MistKit", package: "MistKit"), + .product(name: "ConfigKeyKit", package: "ConfigKeyKit"), + .product(name: "Configuration", package: "swift-configuration"), + ], + swiftSettings: swiftSettings + ), + .testTarget( + name: "MistKitConfigurationTests", + dependencies: ["MistKitConfiguration"], + swiftSettings: swiftSettings + ), + ] +) + +// swiftlint:enable explicit_acl explicit_top_level_acl diff --git a/Packages/MistKitConfiguration/README.md b/Packages/MistKitConfiguration/README.md new file mode 100644 index 000000000..85faee87b --- /dev/null +++ b/Packages/MistKitConfiguration/README.md @@ -0,0 +1,106 @@ +# MistKitConfiguration + +[![SwiftPM](https://img.shields.io/badge/SPM-Linux%20%7C%20iOS%20%7C%20macOS%20%7C%20watchOS%20%7C%20tvOS-success?logo=swift)](https://swift.org) +[![Swift Versions](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FMistKitConfiguration%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/brightdigit/MistKitConfiguration) +[![Platforms](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fbrightdigit%2FMistKitConfiguration%2Fbadge%3Ftype%3Dplatforms)](https://swiftpackageindex.com/brightdigit/MistKitConfiguration) +[![License](https://img.shields.io/github/license/brightdigit/MistKitConfiguration)](LICENSE) +[![GitHub Workflow Status](https://img.shields.io/github/actions/workflow/status/brightdigit/MistKitConfiguration/MistKitConfiguration.yml?label=actions&logo=github&?branch=main)](https://github.com/brightdigit/MistKitConfiguration/actions) +[![Codecov](https://img.shields.io/codecov/c/github/brightdigit/MistKitConfiguration)](https://codecov.io/gh/brightdigit/MistKitConfiguration) +[![Documentation](https://img.shields.io/badge/docc-read_documentation-blue)](https://swiftpackageindex.com/brightdigit/MistKitConfiguration/documentation) + +The CloudKit credential configuration glue shared by [MistKit][mistkit]'s server-side +applications: read a container ID, key ID and private key from the command line or the +environment, validate them before they reach the network, and build a `CloudKitService`. + +MistKit itself stays configuration-framework-free — the dependency arrow points one way, +into MistKit, so adopting this package never changes MistKit's own surface. + +## What's inside + +- **Raw → validated → service.** `CloudKitConfiguration` (all fields optional, reading + never throws) → `validated()` → `ValidatedCloudKitConfiguration` → `makeCloudKitService()`. +- **Format validation you would otherwise discover at request time.** `KeyIDValidator` + (64 hex characters) and `PEMValidator` (header, footer, base64 body), reachable + standalone. +- **Keys and plumbing.** `CloudKitConfigurationKeys` parameterized by container default + and environment prefix, a `readCloudKitConfiguration(keys:)` seam on any + `ConfigValueReading`, and `ConfigurationSources` for the provider stack. + +## Errors are identifiable, not prose + +Nothing in this package conforms to `LocalizedError`. It throws structured, `Equatable` +enums and leaves every user-facing string to you — because only your application knows +which flag or environment variable supplied the value, and what advice to give. + +```swift +do { + let service = try configuration.validated().makeCloudKitService() +} catch let error as CloudKitConfigurationError { + switch error { + case .missing(let field): + throw MyError.missingRequired(keys[field].key(for: .environment) ?? "") + case .invalidKeyID(.incorrectLength(let actual)): + throw MyError.badKeyID("expected \(KeyIDValidator.expectedLength), got \(actual)") + default: + throw MyError.configuration(String(describing: error)) + } +} +``` + +`CloudKitConfigurationField` names the offending field rather than a key string, because +the same field is spelled differently by different applications. + +## Usage + +```swift +import Configuration +import MistKitConfiguration + +let keys = CloudKitConfigurationKeys(defaultContainerID: "iCloud.com.example.MyApp") + +let reader = ConfigurationSources.makeConfigReader( + secretCommandLineFlags: keys.secretCommandLineFlags +) + +let service = try reader + .readCloudKitConfiguration(keys: keys) + .validated() + .makeCloudKitService() +``` + +That resolves `--cloudkit-container-id` / `CLOUDKIT_CONTAINER_ID`, `--cloudkit-key-id` / +`CLOUDKIT_KEY_ID`, `--cloudkit-private-key[-path]` and `--cloudkit-environment`, with the +command line taking precedence over the environment. + +The redaction list is **derived** from each key's `isSecret`, so it cannot drift from the +keys themselves — the drift that previously let a private key passed by flag be logged in +the clear. + +## Used by + +- [BushelCloud][bushelcloud] and [CelestraCloud][celestracloud]. +- [MistDemo][mistdemo], inside [MistKit][mistkit]. + +## Adding to your `Package.swift` + +```swift +.package(url: "https://github.com/brightdigit/MistKitConfiguration.git", from: "1.0.0-beta.1"), +``` + +```swift +.target( + name: "MyApp", + dependencies: [ + .product(name: "MistKitConfiguration", package: "MistKitConfiguration") + ] +), +``` + +## License + +MIT — see [LICENSE](LICENSE). + +[mistkit]: https://github.com/brightdigit/MistKit +[mistdemo]: https://github.com/brightdigit/MistKit/tree/main/Examples/MistDemo +[bushelcloud]: https://github.com/brightdigit/BushelCloud +[celestracloud]: https://github.com/brightdigit/CelestraCloud diff --git a/Packages/MistKitConfiguration/Scripts/cloud-setup.sh b/Packages/MistKitConfiguration/Scripts/cloud-setup.sh new file mode 100755 index 000000000..252cd0db2 --- /dev/null +++ b/Packages/MistKitConfiguration/Scripts/cloud-setup.sh @@ -0,0 +1,187 @@ +#!/bin/bash + +# Setup script for Claude Code on the web (cloud environments). +# +# Paste this into the environment dialog's "Setup script" field at +# claude.ai/code. It is committed here so the content stays reviewable and +# versioned, but the platform reads it from that dialog, not from the repo. +# +# Why here and not in the SessionStart hook: a setup script runs once per +# environment, then Anthropic snapshots the filesystem and reuses that snapshot +# for later sessions, which skip the script entirely. SessionStart hooks re-run +# on every session and get no such caching. The Swift toolchain is a ~1 GB +# download, so it belongs in the snapshot. +# +# .claude/hooks/session-start.sh stays as the fallback: it installs the same +# toolchain when an environment has no setup script configured, and on every +# session it wires PATH into CLAUDE_ENV_FILE (a filesystem snapshot restores +# files, not environment variables). +# +# Requirements this script is written around: +# * Must exit 0 -- a non-zero exit makes the session fail to start. +# * Must finish inside ~5 minutes or the environment cache will not build. +# Measured cold install is ~2 minutes. +# * Runs as root on Ubuntu 24.04, before Claude Code launches. +# * Needs download.swift.org on the environment's allowed-domains list +# (Network access: Custom, with the default package-manager list included). + +# No `set -e`: every failure path has to fall through to `exit 0` so a bad +# install degrades to the SessionStart hook rather than bricking the session. +set -uo pipefail + +# Used only when no .swift-version can be found on disk. Keep in sync with the +# repo's .swift-version. +FALLBACK_SWIFT_VERSION="6.4" + +SWIFTLY_ENV="$HOME/.local/share/swiftly/env.sh" + +log() { + # stderr, not stdout: resolve_swift_version's value is read via command + # substitution, so any stdout chatter would be captured into the version. + echo "[cloud-setup] $*" >&2 +} + +# The setup script may run before the repository is checked out, and the +# checkout path is not contractual, so look in the likely places and fall back +# to the pinned literal above rather than failing. +resolve_swift_version() { + local candidate + for candidate in \ + "${CLAUDE_PROJECT_DIR:-/nonexistent}/.swift-version" \ + "$PWD/.swift-version" \ + /home/user/*/.swift-version \ + /workspace/*/.swift-version \ + /root/*/.swift-version; do + if [ -f "$candidate" ]; then + local version + version="$(tr -d '[:space:]' < "$candidate")" + if [ -n "$version" ]; then + log "Using Swift $version pinned by $candidate" + printf '%s' "$version" + return 0 + fi + fi + done + log "No .swift-version found; falling back to Swift $FALLBACK_SWIFT_VERSION" + printf '%s' "$FALLBACK_SWIFT_VERSION" +} + +# System dependencies for Swift on Ubuntu 24.04 (per swift.org's Linux +# instructions), plus curl for fetching swiftly. Most are already in the base +# image, so only reach for apt when something is genuinely missing: apt-get +# update alone costs ~10s and pulls in unrelated upgrades. +install_system_packages() { + local packages missing pkg + packages=( + binutils + curl + git + gnupg2 + libc6-dev + libcurl4-openssl-dev + libedit2 + libgcc-13-dev + libncurses-dev + libpython3-dev + libsqlite3-0 + libstdc++-13-dev + libxml2-dev + libz3-dev + pkg-config + tzdata + zlib1g-dev + ) + missing=() + for pkg in "${packages[@]}"; do + if [ "$(dpkg-query -W -f='${db:Status-Status}' "$pkg" 2> /dev/null)" != "installed" ]; then + missing+=("$pkg") + fi + done + + if [ "${#missing[@]}" -eq 0 ]; then + log "All system packages already present; skipping apt." + return 0 + fi + + log "Installing missing system packages: ${missing[*]}" + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq || return 1 + apt-get install -y -qq --no-install-recommends "${missing[@]}" || return 1 +} + +install_swiftly() { + local workdir + workdir="$(mktemp -d)" || return 1 + ( + cd "$workdir" || exit 1 + curl -fsSLO "https://download.swift.org/swiftly/linux/swiftly-$(uname -m).tar.gz" || exit 1 + tar zxf "swiftly-$(uname -m).tar.gz" || exit 1 + ./swiftly init -y --skip-install || exit 1 + ) + local status=$? + rm -rf "$workdir" + return "$status" +} + +# Make swift resolvable for plain login shells too. This is a file, so the +# environment snapshot carries it; the SessionStart hook still handles +# CLAUDE_ENV_FILE for Claude Code's own process. +write_profile_entry() { + cat > /etc/profile.d/swiftly.sh <<'PROFILE' +# Added by MistKitConfiguration Scripts/cloud-setup.sh +export SWIFTLY_HOME_DIR="$HOME/.local/share/swiftly" +export SWIFTLY_BIN_DIR="$HOME/.local/share/swiftly/bin" +case ":$PATH:" in + *":$SWIFTLY_BIN_DIR:"*) ;; + *) export PATH="$SWIFTLY_BIN_DIR:$PATH" ;; +esac +PROFILE +} + +main() { + if [ -f "$SWIFTLY_ENV" ]; then + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" + fi + + if command -v swift > /dev/null 2>&1; then + log "Swift already installed: $(swift --version 2>&1 | head -1)" + write_profile_entry + return 0 + fi + + local version + version="$(resolve_swift_version)" + + install_system_packages || { + log "WARNING: system package install failed; continuing anyway." + } + + install_swiftly || { + log "ERROR: swiftly install failed." + return 1 + } + + # shellcheck disable=SC1090 + . "$SWIFTLY_ENV" || return 1 + + if ! swiftly install -y "$version"; then + log "Pinned toolchain $version failed to install; falling back to latest." + swiftly install -y latest || return 1 + swiftly use -y latest || return 1 + else + swiftly use -y "$version" || return 1 + fi + + write_profile_entry + swift --version +} + +if main; then + log "Setup complete." +else + log "Setup did not complete; the SessionStart hook will install Swift instead." +fi + +# Always succeed: a non-zero exit here stops the session from starting. +exit 0 diff --git a/Packages/MistKitConfiguration/Scripts/header.sh b/Packages/MistKitConfiguration/Scripts/header.sh new file mode 100755 index 000000000..809f88acf --- /dev/null +++ b/Packages/MistKitConfiguration/Scripts/header.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +# Function to print usage +usage() { + echo "Usage: $0 -d directory -c creator -o company -p package [-y year]" + echo " -d directory Directory to read from (including subdirectories)" + echo " -c creator Name of the creator" + echo " -o company Name of the company with the copyright" + echo " -p package Package or library name" + echo " -y year Copyright year (optional, defaults to current year)" + exit 1 +} + +# Get the current year if not provided +current_year=$(date +"%Y") + +# Default values +year="$current_year" + +# Parse arguments +while getopts ":d:c:o:p:y:" opt; do + case $opt in + d) directory="$OPTARG" ;; + c) creator="$OPTARG" ;; + o) company="$OPTARG" ;; + p) package="$OPTARG" ;; + y) year="$OPTARG" ;; + *) usage ;; + esac +done + +# Check for mandatory arguments +if [ -z "$directory" ] || [ -z "$creator" ] || [ -z "$company" ] || [ -z "$package" ]; then + usage +fi + +# Define the header template using a heredoc +read -r -d '' header_template <<'EOF' +// +// %s +// %s +// +// Created by %s. +// Copyright © %s %s. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +EOF + +# Loop through each Swift file in the specified directory and subdirectories +find "$directory" -type f -name "*.swift" | while read -r file; do + # Skip files carrying `// swift-format-ignore-file` anywhere in the leading + # comment block. This is the opt-out used by generated files (e.g. + # swift-openapi-generator emits it via `additionalFileComments`) and lets + # them sit anywhere in the tree without needing a path-based exclusion. + if awk ' + /^\/\/[[:space:]]*swift-format-ignore-file[[:space:]]*$/ { found = 1; exit } + /^[[:space:]]*$/ || /^\/\// { next } + { exit } + END { exit !found } + ' "$file"; then + echo "Skipping $file due to swift-format-ignore directive." + continue + fi + + # Create the header with the current filename + # Escape % characters in user-provided values to prevent format specifier injection + filename=$(basename "$file" | sed 's/%/%%/g') + package_safe=$(printf '%s' "$package" | sed 's/%/%%/g') + creator_safe=$(printf '%s' "$creator" | sed 's/%/%%/g') + year_safe=$(printf '%s' "$year" | sed 's/%/%%/g') + company_safe=$(printf '%s' "$company" | sed 's/%/%%/g') + + header=$(printf "$header_template" "$filename" "$package_safe" "$creator_safe" "$year_safe" "$company_safe") + + # Remove all consecutive lines at the beginning which start with "// ", contain only whitespace, or only "//" + awk ' + BEGIN { skip = 1 } + { + if (skip && ($0 ~ /^\/\/ / || $0 ~ /^\/\/$/ || $0 ~ /^$/)) { + next + } + skip = 0 + print + }' "$file" > temp_file + + # Add the header to the cleaned file + (echo "$header"; echo; cat temp_file) > "$file" + + # Remove the temporary file + rm temp_file +done + +echo "Headers added or files skipped appropriately across all Swift files in the directory and subdirectories." diff --git a/Packages/MistKitConfiguration/Scripts/lint.sh b/Packages/MistKitConfiguration/Scripts/lint.sh new file mode 100755 index 000000000..a18db0e93 --- /dev/null +++ b/Packages/MistKitConfiguration/Scripts/lint.sh @@ -0,0 +1,122 @@ +#!/bin/bash + +# Remove set -e to allow script to continue running +# set -e # Exit on any error + +ERRORS=0 + +run_command() { + "$@" || ERRORS=$((ERRORS + 1)) +} + +if [ "$LINT_MODE" = "INSTALL" ]; then + exit +fi + +echo "LintMode: $LINT_MODE" + +# More portable way to get script directory +if [ -z "$SRCROOT" ]; then + SCRIPT_DIR=$(dirname "$(readlink -f "$0")") + PACKAGE_DIR="${SCRIPT_DIR}/.." +else + PACKAGE_DIR="${SRCROOT}" +fi + +# Ensure mise-managed tools are on PATH outside CI (CI uses jdx/mise-action). +# Skipped in Claude Code web sessions: mise resolves its `spm:`/`aqua:` tools +# through api.github.com, which those sessions cannot reach, so evaluating its +# env would only shadow the toolchain's own swift-format with a broken shim. +if command -v mise >/dev/null 2>&1 && [ -z "$CI" ] \ + && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + eval "$(mise -C "$PACKAGE_DIR" env -s bash)" +fi + +# SwiftLint and periphery are not installed in Claude Code web sessions (no +# Linux binaries for periphery, and mise is unreachable per above). swift-format +# ships inside the Swift toolchain, so formatting, the header check and the +# build still run there; run ./Scripts/lint.sh locally for full coverage. +if [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + RUN_SWIFTLINT=0 +else + RUN_SWIFTLINT=1 +fi + +if [ "$LINT_MODE" = "NONE" ]; then + exit +elif [ "$LINT_MODE" = "STRICT" ]; then + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="--strict" +else + SWIFTFORMAT_OPTIONS="--configuration .swift-format" + SWIFTLINT_OPTIONS="" +fi + +pushd "$PACKAGE_DIR" || exit + +if [ -z "$CI" ]; then + run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests + if [ "$RUN_SWIFTLINT" -eq 1 ]; then + run_command swiftlint --fix + fi +fi + +if [ -z "$FORMAT_ONLY" ]; then + run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests + if [ "$RUN_SWIFTLINT" -eq 1 ]; then + run_command swiftlint lint $SWIFTLINT_OPTIONS + else + echo "Skipping SwiftLint (Claude Code web session)." + fi + # Check for compilation errors + run_command swift build --build-tests +fi + +$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "MistKitConfiguration" + +# Generated files now automatically include ignore directives via OpenAPI generator configuration + +# Periphery cannot find the index store on its own: its location depends on the +# build system. swiftbuild (the SwiftPM default since Swift 6.2) writes +# `.build/out`, the native build system writes +# `.build//debug/index/store`, and older toolchains wrote +# `.build/debug/index/store`. Resolve it here and hand it over explicitly; +# `--index-store-path` implies `--skip-build`, which is what we want because +# `swift build --build-tests` already ran above. Skipped in CI (periphery has +# never run there) and in Claude Code web sessions (no Linux binaries; mise +# unreachable per above). +periphery_index_store() { + local candidate + for candidate in "$PACKAGE_DIR"/.build/*/debug/index/store \ + "$PACKAGE_DIR"/.build/debug/index/store \ + "$PACKAGE_DIR"/.build/out; do + if [ -d "$candidate/v5/units" ]; then + printf '%s\n' "$candidate" + return 0 + fi + done + return 1 +} + +if [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + if INDEX_STORE_PATH=$(periphery_index_store); then + run_command periphery scan $PERIPHERY_OPTIONS \ + --index-store-path "$INDEX_STORE_PATH" --skip-build \ + --disable-update-check + else + echo "Skipping periphery scan (no index store under .build; run swift build first)." + fi +else + echo "Skipping periphery scan (CI or Claude Code web session)." +fi + +popd + +# Exit with error code if any errors occurred +if [ $ERRORS -gt 0 ]; then + echo "Linting completed with $ERRORS error(s)" + exit 1 +else + echo "Linting completed successfully" + exit 0 +fi diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfiguration.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfiguration.swift new file mode 100644 index 000000000..2ec46c07b --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfiguration.swift @@ -0,0 +1,118 @@ +// +// CloudKitConfiguration.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKit + +/// Raw CloudKit server-to-server settings, exactly as supplied. +/// +/// Every field is optional and unparsed: this type is the output of *reading* a +/// configuration source and performs no validation, which is what lets reading itself be +/// non-throwing and composable into any application's own loader. Call ``validated()`` to +/// resolve and check it. +/// +/// `environment` stays a `String?` rather than a `MistKit.Environment` so that +/// "unspecified" remains distinguishable from "explicitly development", and so an +/// unrecognized value fails at validation rather than at read time. +public struct CloudKitConfiguration: Sendable { + /// CloudKit container identifier, e.g. `iCloud.com.example.App`. + public var containerID: String? + /// Server-to-server key ID from the CloudKit Dashboard. + public var keyID: String? + /// Path to a PEM-encoded private key file. + public var privateKeyPath: String? + /// Inline PEM private key, for environments where writing a file is inconvenient. + public var privateKey: String? + /// Unparsed CloudKit environment name; `nil` means unspecified. + public var environment: String? + + /// Creates a raw configuration. + /// + /// - Parameters: + /// - containerID: CloudKit container identifier. + /// - keyID: Server-to-server key ID. + /// - privateKeyPath: Path to a PEM private key file. + /// - privateKey: Inline PEM private key. + /// - environment: Unparsed environment name. + public init( + containerID: String? = nil, + keyID: String? = nil, + privateKeyPath: String? = nil, + privateKey: String? = nil, + environment: String? = nil + ) { + self.containerID = containerID + self.keyID = keyID + self.privateKeyPath = privateKeyPath + self.privateKey = privateKey + self.environment = environment + } + + /// Resolves and checks every field, failing closed on anything missing, empty, or + /// unparseable. + /// + /// Presence is checked before format, so a configuration with both a malformed key ID + /// and no private key reports ``CloudKitConfigurationError/missing(_:)`` first. + /// An inline ``privateKey`` takes precedence over ``privateKeyPath``. + /// + /// - Returns: A validated configuration. + /// - Throws: ``CloudKitConfigurationError``. + public func validated() throws(CloudKitConfigurationError) -> ValidatedCloudKitConfiguration { + guard let containerID, !containerID.isEmpty else { + throw .missing(.containerID) + } + guard let keyID, !keyID.isEmpty else { + throw .missing(.keyID) + } + + let trimmedKey = privateKey?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let trimmedPath = privateKeyPath?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let material: PrivateKeyMaterial + if !trimmedKey.isEmpty { + material = .raw(trimmedKey) + } else if !trimmedPath.isEmpty { + material = .file(path: trimmedPath) + } else { + throw .missing(.privateKey) + } + + let rawEnvironment = (environment ?? MistKit.Environment.development.rawValue) + .trimmingCharacters(in: .whitespacesAndNewlines) + guard let parsed = MistKit.Environment(caseInsensitive: rawEnvironment) else { + throw .unrecognizedEnvironment(environment ?? "") + } + + return try ValidatedCloudKitConfiguration( + containerID: containerID, + keyID: keyID, + privateKey: material, + environment: parsed + ) + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationError.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationError.swift new file mode 100644 index 000000000..59b32f26b --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationError.swift @@ -0,0 +1,69 @@ +// +// CloudKitConfigurationError.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A CloudKit configuration value that is missing or malformed. +/// +/// Deliberately **not** `LocalizedError`: every consumer of this package already owns an +/// error type with its own wording, its own remediation advice, and its own key names. +/// Presenting package-authored prose to a user would contradict all three. Switch over +/// these cases and map them onto your own error instead. +/// +/// ```swift +/// do { +/// let validated = try configuration.validated() +/// } catch let error as CloudKitConfigurationError { +/// throw MyError(describing: error) +/// } +/// ``` +public enum CloudKitConfigurationError: Error, Equatable, Sendable { + /// A required field was absent or empty. + case missing(CloudKitConfigurationField) + /// The key ID was present but malformed. + case invalidKeyID(KeyIDValidationFailure) + /// The inline private key was present but not well-formed PEM. + case invalidPrivateKey(PEMValidationFailure) + /// The environment string matched neither `development` nor `production`. + case unrecognizedEnvironment(String) +} + +extension CloudKitConfigurationError: CustomStringConvertible { + /// A debugging description. **Not** intended for end users — map to your own error. + public var description: String { + switch self { + case .missing(let field): + return "missing(\(field))" + case .invalidKeyID(let failure): + return "invalidKeyID(\(failure))" + case .invalidPrivateKey(let failure): + return "invalidPrivateKey(\(failure))" + case .unrecognizedEnvironment(let raw): + return "unrecognizedEnvironment(\(raw))" + } + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationField.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationField.swift new file mode 100644 index 000000000..a84e96e84 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationField.swift @@ -0,0 +1,49 @@ +// +// CloudKitConfigurationField.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// A field of ``CloudKitConfiguration``, named independently of any key spelling. +/// +/// Errors identify the offending field with this enum rather than a key string because +/// consuming applications spell the same field differently — CelestraCloud reads +/// `cloudkit.key-id` while another host might read `key.id` — so a string baked into the +/// package would be wrong for every consumer but one. Map a field to whatever name your +/// application presents, or to the key itself via +/// ``CloudKitConfigurationKeys/subscript(_:)``. +public enum CloudKitConfigurationField: Equatable, Sendable, CaseIterable { + /// The CloudKit container identifier. + case containerID + /// The server-to-server key ID. + case keyID + /// The inline PEM private key. + case privateKey + /// The path to a PEM private key file. + case privateKeyPath + /// The CloudKit environment. + case environment +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationKeys.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationKeys.swift new file mode 100644 index 000000000..ba9d242e6 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/CloudKitConfigurationKeys.swift @@ -0,0 +1,123 @@ +// +// CloudKitConfigurationKeys.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit + +/// The five CloudKit configuration keys, parameterized per application. +/// +/// A value type rather than a `static` enum because the container default and the +/// environment prefix differ per application, and a `static` member cannot take +/// arguments. +/// +/// Bases are **dash-case** (`cloudkit.key-id`, never `cloudkit.key_id`): swift-configuration's +/// `CLIKeyEncoder` joins key components verbatim, so an underscore survives into an +/// unusable flag *and* silently defeats secret redaction, because the redaction list is +/// matched against the generated flag. Build keys only through this type. +public struct CloudKitConfigurationKeys: Sendable { + /// `cloudkit.container-id`, defaulting to the application's own container. + public let containerID: ConfigKey + /// `cloudkit.key-id` — secret. + public let keyID: OptionalConfigKey + /// `cloudkit.private-key-path` — secret. + public let privateKeyPath: OptionalConfigKey + /// `cloudkit.private-key` — secret. + public let privateKey: OptionalConfigKey + /// `cloudkit.environment`. + public let environment: OptionalConfigKey + + /// The command-line flags whose values must be redacted from logs. + /// + /// Derived from each key's `isSecret` rather than hand-listed, so the list cannot drift + /// from the keys themselves — the drift that previously let a private key passed by + /// flag be logged in the clear. Splice in application-specific flags with + /// `union(_:)` before handing the result to + /// `CommandLineArgumentsProvider(secretsSpecifier: .specific(_:))`. + public var secretCommandLineFlags: Set { + let all: [any ConfigurationKey] = [ + containerID, keyID, privateKeyPath, privateKey, environment, + ] + return Set(all.filter(\.isSecret).compactMap(Self.commandLineFlag(for:))) + } + + /// Creates the key group. + /// + /// - Parameters: + /// - defaultContainerID: Container used when neither the command line nor the + /// environment supplies one. + /// - envPrefix: Prefix applied to environment-variable names only, e.g. `"BUSHEL"` + /// yields `BUSHEL_CLOUDKIT_KEY_ID`. Command-line flags are unaffected. Defaults to + /// `nil`, which is what every current consumer uses. + public init(defaultContainerID: String, envPrefix: String? = nil) { + self.containerID = ConfigKey( + "cloudkit.container-id", + envPrefix: envPrefix, + default: defaultContainerID + ) + self.keyID = OptionalConfigKey( + "cloudkit.key-id", + envPrefix: envPrefix, + isSecret: true + ) + self.privateKeyPath = OptionalConfigKey( + "cloudkit.private-key-path", + envPrefix: envPrefix, + isSecret: true + ) + self.privateKey = OptionalConfigKey( + "cloudkit.private-key", + envPrefix: envPrefix, + isSecret: true + ) + self.environment = OptionalConfigKey( + "cloudkit.environment", + envPrefix: envPrefix + ) + } + + private static func commandLineFlag(for key: any ConfigurationKey) -> String? { + guard let base = key.key(for: .commandLine) else { + return nil + } + return "--" + base.split(separator: ".").joined(separator: "-") + } + + /// Looks up the key backing a given field. + /// + /// Lets an application turn a ``CloudKitConfigurationError`` into a message naming the + /// flag or variable *it* uses, without hard-coding key strings. + public subscript(field: CloudKitConfigurationField) -> any ConfigurationKey { + switch field { + case .containerID: return containerID + case .keyID: return keyID + case .privateKey: return privateKey + case .privateKeyPath: return privateKeyPath + case .environment: return environment + } + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigReader+ConfigValueReading.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigReader+ConfigValueReading.swift new file mode 100644 index 000000000..00f216bfe --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigReader+ConfigValueReading.swift @@ -0,0 +1,45 @@ +// +// ConfigReader+ConfigValueReading.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit +public import Configuration + +/// Bridges swift-configuration's `ConfigReader` to ConfigKeyKit's ``ConfigValueReading``, +/// which supplies the CLI → ENV → default resolution for every `ConfigKey` / +/// `OptionalConfigKey` overload. +/// +/// Shipping the conformance here means consuming applications must **not** declare their +/// own — two modules declaring the same retroactive conformance is a duplicate-conformance +/// error. +extension ConfigReader: @retroactive ConfigValueReading { + /// Wraps a resolved per-source key string in swift-configuration's own key type. + public func makeConfigKey(_ string: String) -> Configuration.ConfigKey { + .init(string) + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigValueReading+CloudKit.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigValueReading+CloudKit.swift new file mode 100644 index 000000000..7b0d45f09 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigValueReading+CloudKit.swift @@ -0,0 +1,55 @@ +// +// ConfigValueReading+CloudKit.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import ConfigKeyKit + +extension ConfigValueReading { + /// Reads the five CloudKit values from this reader, applying CLI → ENV → default + /// precedence. + /// + /// Reading never fails: an unset value stays `nil`, and an unrecognized environment + /// string is preserved verbatim for ``CloudKitConfiguration/validated()`` to reject. + /// That keeps this composable into an application's own loader without infecting it + /// with a throwing read. + /// + /// - Parameter keys: The key group to read, built with the application's own container + /// default and environment prefix. + /// - Returns: The raw, unvalidated configuration. + public func readCloudKitConfiguration( + keys: CloudKitConfigurationKeys + ) -> CloudKitConfiguration { + CloudKitConfiguration( + containerID: read(keys.containerID), + keyID: read(keys.keyID), + privateKeyPath: read(keys.privateKeyPath), + privateKey: read(keys.privateKey), + environment: read(keys.environment) + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationError.swift similarity index 75% rename from Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift rename to Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationError.swift index 394e90404..a688b4b51 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/EnhancedConfigurationError.swift +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationError.swift @@ -1,6 +1,6 @@ // -// EnhancedConfigurationError.swift -// CelestraCloud +// ConfigurationError.swift +// MistKitConfiguration // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -29,8 +29,13 @@ public import Foundation -/// Enhanced configuration error with detailed context -public struct EnhancedConfigurationError: LocalizedError { +/// A missing or malformed configuration value, naming the key at fault. +/// +/// A presentation type: this package **never throws it**. It is offered so applications +/// that want a ready-made `LocalizedError` can map ``CloudKitConfigurationError`` onto +/// one shape rather than each inventing their own. An application with richer needs — +/// remediation text, a closed set of domain cases — should use its own type instead. +public struct ConfigurationError: LocalizedError, Sendable { /// The error message describing what went wrong. public let message: String @@ -40,13 +45,13 @@ public struct EnhancedConfigurationError: LocalizedError { /// A localized description of the error. public var errorDescription: String? { var parts = [message] - if let key = key { + if let key { parts.append("(key: \(key))") } return parts.joined(separator: " ") } - /// Creates a new enhanced configuration error. + /// Creates a new configuration error. /// /// - Parameters: /// - message: The error message describing what went wrong. diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationSources.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationSources.swift new file mode 100644 index 000000000..940daf9d2 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ConfigurationSources.swift @@ -0,0 +1,79 @@ +// +// ConfigurationSources.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Configuration + +/// The provider stack this package expects: command-line arguments first, then +/// environment variables. +/// +/// Captures the one piece of loader wiring that is genuinely shared — provider order plus +/// the redaction list — without owning the application-shaped parts. Each application +/// keeps its own loader and its own root configuration type. +public enum ConfigurationSources { + /// Builds a reader over the current process's arguments and environment. + /// + /// - Parameter secretCommandLineFlags: Flags whose values must be redacted; pass + /// ``CloudKitConfigurationKeys/secretCommandLineFlags``, unioned with any of your own. + /// - Returns: A reader over the command line and the process environment. + public static func makeConfigReader( + secretCommandLineFlags: Set + ) -> ConfigReader { + ConfigReader(providers: [ + CommandLineArgumentsProvider( + secretsSpecifier: .specific(secretCommandLineFlags) + ), + EnvironmentVariablesProvider(), + ]) + } + + /// Builds a reader over injected arguments and environment. + /// + /// Use from tests: driving the real providers means key normalization and value + /// coercion behave exactly as they do in production, which an in-memory double does not + /// guarantee. + /// + /// - Parameters: + /// - secretCommandLineFlags: Flags whose values must be redacted. + /// - arguments: A full argument vector, including the executable name at index 0. + /// - environmentVariables: The simulated environment. + /// - Returns: A reader over the injected arguments and environment. + public static func makeConfigReader( + secretCommandLineFlags: Set, + arguments: [String], + environmentVariables: [String: String] + ) -> ConfigReader { + ConfigReader(providers: [ + CommandLineArgumentsProvider( + arguments: arguments, + secretsSpecifier: .specific(secretCommandLineFlags) + ), + EnvironmentVariablesProvider(environmentVariables: environmentVariables), + ]) + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidationFailure.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidationFailure.swift new file mode 100644 index 000000000..20a4bbb0f --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidationFailure.swift @@ -0,0 +1,44 @@ +// +// KeyIDValidationFailure.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Why a CloudKit server-to-server key ID was rejected. +/// +/// Carries structured facts only — no prose. The consuming application decides how to +/// phrase the failure, because only it knows which environment variable or flag supplied +/// the value. +public enum KeyIDValidationFailure: Error, Equatable, Sendable { + /// The key ID was empty, or contained only whitespace. + case empty + /// The key ID had leading or trailing whitespace, commonly a stray newline from a copy. + case surroundingWhitespace + /// The key ID was not ``KeyIDValidator/expectedLength`` characters long. + case incorrectLength(actual: Int) + /// The key ID contained characters outside `0-9`, `a-f`, `A-F`. + case nonHexCharacters +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidator.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidator.swift new file mode 100644 index 000000000..e9e626c89 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/KeyIDValidator.swift @@ -0,0 +1,66 @@ +// +// KeyIDValidator.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Validates the format of a CloudKit Server-to-Server key ID. +/// +/// CloudKit server-to-server keys are SHA-256 fingerprints of the public key: 64 hex +/// characters. Checking locally turns an opaque signing failure at request time into a +/// precise, switchable ``KeyIDValidationFailure`` at configuration time. +public enum KeyIDValidator { + /// The exact number of characters a CloudKit server-to-server key ID has. + public static let expectedLength = 64 + + private static let hexDigits = Set("0123456789abcdefABCDEF") + + /// Validates that a key ID has the expected CloudKit server-to-server format. + /// + /// Upper- and lower-case hex are both accepted, to be lenient about a copy from the + /// CloudKit Dashboard. + /// + /// - Parameter keyID: The key ID to validate. + /// - Throws: ``KeyIDValidationFailure`` describing the first problem found. + public static func validate(_ keyID: String) throws(KeyIDValidationFailure) { + let trimmed = keyID.trimmingCharacters(in: .whitespacesAndNewlines) + + guard !trimmed.isEmpty else { + throw .empty + } + guard trimmed == keyID else { + throw .surroundingWhitespace + } + guard trimmed.count == expectedLength else { + throw .incorrectLength(actual: trimmed.count) + } + guard trimmed.allSatisfy(hexDigits.contains) else { + throw .nonHexCharacters + } + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidationFailure.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidationFailure.swift new file mode 100644 index 000000000..b6af5373f --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidationFailure.swift @@ -0,0 +1,42 @@ +// +// PEMValidationFailure.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Why a PEM-encoded private key was rejected. +/// +/// Carries structured facts only — no prose; see ``KeyIDValidationFailure`` for why. +public enum PEMValidationFailure: Error, Equatable, Sendable { + /// No `-----BEGIN PRIVATE KEY-----` header was present. + case missingHeader + /// No `-----END PRIVATE KEY-----` footer was present, usually a truncated copy. + case missingFooter + /// The header and footer were present but enclosed no key data. + case emptyContent + /// The enclosed content was not valid base64. + case invalidBase64 +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidator.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidator.swift new file mode 100644 index 000000000..085365a3e --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/PEMValidator.swift @@ -0,0 +1,64 @@ +// +// PEMValidator.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +/// Validates the structure and encoding of a PEM-encoded private key. +/// +/// Catches the common copy/paste failures — truncation, a missing marker, a binary-mangled +/// body — before the key ever reaches MistKit's signing path. +public enum PEMValidator { + /// Validates that a PEM string is well formed. + /// + /// Checks, in order: a `BEGIN … PRIVATE KEY` header, an `END … PRIVATE KEY` footer, + /// non-empty content between them, and that the content is valid base64. + /// + /// - Parameter pemString: The PEM-formatted private key. + /// - Throws: ``PEMValidationFailure`` describing the first problem found. + public static func validate(_ pemString: String) throws(PEMValidationFailure) { + let trimmed = pemString.trimmingCharacters(in: .whitespacesAndNewlines) + + guard trimmed.contains("-----BEGIN"), trimmed.contains("PRIVATE KEY-----") else { + throw .missingHeader + } + guard trimmed.contains("-----END"), trimmed.contains("PRIVATE KEY-----") else { + throw .missingFooter + } + + let contentLines = trimmed.components(separatedBy: .newlines).filter { line in + !line.contains("BEGIN") && !line.contains("END") && !line.isEmpty + } + guard !contentLines.isEmpty else { + throw .emptyContent + } + guard Data(base64Encoded: contentLines.joined()) != nil else { + throw .invalidBase64 + } + } +} diff --git a/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ValidatedCloudKitConfiguration.swift b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ValidatedCloudKitConfiguration.swift new file mode 100644 index 000000000..9708469f5 --- /dev/null +++ b/Packages/MistKitConfiguration/Sources/MistKitConfiguration/ValidatedCloudKitConfiguration.swift @@ -0,0 +1,101 @@ +// +// ValidatedCloudKitConfiguration.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +public import MistKit + +/// CloudKit credentials that have passed presence *and* format validation. +/// +/// The initializer is throwing and runs ``KeyIDValidator`` — and ``PEMValidator`` for an +/// inline key — so there is no way to hold a value of this type whose credentials skipped +/// format checking. That property is what lets callers drop their own hand-rolled +/// validation before constructing a service. +public struct ValidatedCloudKitConfiguration: Sendable { + /// The CloudKit container identifier. + public let containerID: String + /// The server-to-server key ID. + public let keyID: String + /// The resolved signing key, inline or a path to a `.pem` file. + public let privateKey: PrivateKeyMaterial + /// The CloudKit environment. + public let environment: MistKit.Environment + + /// Creates a validated configuration from already-resolved values. + /// + /// - Parameters: + /// - containerID: The CloudKit container identifier. + /// - keyID: The server-to-server key ID; must be 64 hex characters. + /// - privateKey: The signing key; inline PEM is validated, a file path is not read. + /// - environment: The CloudKit environment. + /// - Throws: ``CloudKitConfigurationError/invalidKeyID(_:)`` or + /// ``CloudKitConfigurationError/invalidPrivateKey(_:)``. + public init( + containerID: String, + keyID: String, + privateKey: PrivateKeyMaterial, + environment: MistKit.Environment + ) throws(CloudKitConfigurationError) { + do { + try KeyIDValidator.validate(keyID) + } catch { + throw .invalidKeyID(error) + } + if case .raw(let pem) = privateKey { + do { + try PEMValidator.validate(pem) + } catch { + throw .invalidPrivateKey(error) + } + } + + self.containerID = containerID + self.keyID = keyID + self.privateKey = privateKey + self.environment = environment + } +} + +extension ValidatedCloudKitConfiguration { + /// Builds a `CloudKitService` signing with these server-to-server credentials. + /// + /// `PrivateKeyMaterial` defers reading a `.file(path:)` key until the credentials are + /// consumed, so this performs no file IO. + /// + /// - Returns: A configured service. + /// - Throws: `CredentialsValidationError` if MistKit rejects the credentials. + public func makeCloudKitService() throws -> CloudKitService { + CloudKitService( + containerIdentifier: containerID, + credentials: try Credentials( + serverToServer: ServerToServerCredentials(keyID: keyID, privateKey: privateKey) + ), + environment: environment + ) + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift new file mode 100644 index 000000000..3a0a71157 --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationKeysTests.swift @@ -0,0 +1,103 @@ +// +// CloudKitConfigurationKeysTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import Testing + +@testable import MistKitConfiguration + +@Suite("CloudKitConfigurationKeys") +internal struct CloudKitConfigurationKeysTests { + private static let keys = CloudKitConfigurationKeys( + defaultContainerID: "iCloud.com.test.App" + ) + + @Test("Bases are dash-case and cloudkit-namespaced") + internal func basesAreDashCase() { + let bases = CloudKitConfigurationField.allCases.compactMap { + Self.keys[$0].key(for: .commandLine) + } + #expect(bases.count == CloudKitConfigurationField.allCases.count) + for base in bases { + #expect(!base.contains("_"), "\(base) must not contain an underscore") + #expect(base.hasPrefix("cloudkit."), "\(base) must be cloudkit-namespaced") + } + } + + @Test("Command-line flags are dash-joined") + internal func commandLineFlags() { + #expect(Self.keys.keyID.key(for: .commandLine) == "cloudkit.key-id") + #expect(Self.keys.privateKeyPath.key(for: .commandLine) == "cloudkit.private-key-path") + } + + @Test("Environment names ignore envPrefix when none is given") + internal func environmentNamesWithoutPrefix() { + #expect(Self.keys.keyID.key(for: .environment) == "CLOUDKIT_KEY-ID") + #expect(Self.keys.containerID.key(for: .environment) == "CLOUDKIT_CONTAINER-ID") + } + + @Test("envPrefix applies to the environment only, never the command line") + internal func envPrefixIsEnvironmentOnly() { + let prefixed = CloudKitConfigurationKeys( + defaultContainerID: "iCloud.com.test.App", + envPrefix: "BUSHEL" + ) + #expect(prefixed.keyID.key(for: .environment) == "BUSHEL_CLOUDKIT_KEY-ID") + #expect(prefixed.keyID.key(for: .commandLine) == "cloudkit.key-id") + } + + @Test("The three credential keys are secret; the other two are not") + internal func secrecy() { + #expect(Self.keys.keyID.isSecret) + #expect(Self.keys.privateKey.isSecret) + #expect(Self.keys.privateKeyPath.isSecret) + #expect(!Self.keys.containerID.isSecret) + #expect(!Self.keys.environment.isSecret) + } + + @Test("The redaction list is derived from isSecret, so it cannot drift") + internal func secretFlagsAreDerived() { + #expect( + Self.keys.secretCommandLineFlags == [ + "--cloudkit-key-id", "--cloudkit-private-key", "--cloudkit-private-key-path", + ] + ) + } + + @Test("The container default is the one supplied") + internal func containerDefault() { + #expect(Self.keys.containerID.defaultValue == "iCloud.com.test.App") + } + + @Test("Every field maps to its key") + internal func fieldSubscript() { + #expect(Self.keys[.keyID].key(for: .commandLine) == "cloudkit.key-id") + #expect(Self.keys[.environment].key(for: .commandLine) == "cloudkit.environment") + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift new file mode 100644 index 000000000..329df99e4 --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationReadingTests.swift @@ -0,0 +1,120 @@ +// +// CloudKitConfigurationReadingTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import ConfigKeyKit +internal import Configuration +internal import MistKit +internal import Testing + +@testable import MistKitConfiguration + +@Suite("readCloudKitConfiguration") +internal struct CloudKitConfigurationReadingTests { + private static let keys = CloudKitConfigurationKeys( + defaultContainerID: "iCloud.com.test.Default" + ) + + /// Builds a reader over the **real** providers with injected inputs, so key + /// normalization and value coercion behave exactly as they do in production. An + /// in-memory double matches keys literally and serves only the type it stored, which is + /// precisely where such doubles drift from the real stack. + private static func reader( + arguments: [String] = [], + environment: [String: String] = [:] + ) -> ConfigReader { + ConfigurationSources.makeConfigReader( + secretCommandLineFlags: keys.secretCommandLineFlags, + arguments: ["app"] + arguments, + environmentVariables: environment + ) + } + + @Test("Falls back to the container default when nothing supplies one") + internal func containerDefault() { + let config = Self.reader().readCloudKitConfiguration(keys: Self.keys) + #expect(config.containerID == "iCloud.com.test.Default") + #expect(config.keyID == nil) + #expect(config.environment == nil) + } + + @Test("Reads from the environment") + internal func readsEnvironment() { + let reader = Self.reader(environment: [ + "CLOUDKIT_CONTAINER_ID": "iCloud.com.test.FromEnv", + "CLOUDKIT_KEY_ID": TestFixtures.validKeyID, + "CLOUDKIT_ENVIRONMENT": "production", + ]) + let config = reader.readCloudKitConfiguration(keys: Self.keys) + + #expect(config.containerID == "iCloud.com.test.FromEnv") + #expect(config.keyID == TestFixtures.validKeyID) + #expect(config.environment == "production") + } + + @Test("Command line takes precedence over the environment") + internal func commandLineWins() { + let reader = Self.reader( + arguments: ["--cloudkit-container-id", "iCloud.com.test.FromCLI"], + environment: ["CLOUDKIT_CONTAINER_ID": "iCloud.com.test.FromEnv"] + ) + let config = reader.readCloudKitConfiguration(keys: Self.keys) + + #expect(config.containerID == "iCloud.com.test.FromCLI") + } + + @Test("Reading never throws; an unparseable environment surfaces at validation") + internal func readingNeverThrows() { + let reader = Self.reader(environment: [ + "CLOUDKIT_KEY_ID": TestFixtures.validKeyID, + "CLOUDKIT_PRIVATE_KEY_PATH": "/tmp/key.pem", + "CLOUDKIT_ENVIRONMENT": "staging", + ]) + let config = reader.readCloudKitConfiguration(keys: Self.keys) + + #expect(config.environment == "staging") + #expect(throws: CloudKitConfigurationError.unrecognizedEnvironment("staging")) { + try config.validated() + } + } + + @Test("A full command line validates end to end") + internal func endToEnd() throws { + let reader = Self.reader(arguments: [ + "--cloudkit-container-id", "iCloud.com.test.App", + "--cloudkit-key-id", TestFixtures.validKeyID, + "--cloudkit-private-key-path", "/tmp/key.pem", + "--cloudkit-environment", "production", + ]) + let config = reader.readCloudKitConfiguration(keys: Self.keys) + + let validated = try config.validated() + #expect(validated.containerID == "iCloud.com.test.App") + #expect(validated.environment == .production) + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift new file mode 100644 index 000000000..b6ab78fab --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/CloudKitConfigurationTests.swift @@ -0,0 +1,130 @@ +// +// CloudKitConfigurationTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKit +internal import Testing + +@testable import MistKitConfiguration + +@Suite("CloudKitConfiguration.validated") +internal struct CloudKitConfigurationTests { + private static func complete( + containerID: String? = "iCloud.com.test.App", + keyID: String? = TestFixtures.validKeyID, + privateKeyPath: String? = "/tmp/key.pem", + privateKey: String? = nil, + environment: String? = nil + ) -> CloudKitConfiguration { + CloudKitConfiguration( + containerID: containerID, + keyID: keyID, + privateKeyPath: privateKeyPath, + privateKey: privateKey, + environment: environment + ) + } + + @Test("Validates a complete configuration") + internal func validatesComplete() throws { + let validated = try Self.complete().validated() + #expect(validated.containerID == "iCloud.com.test.App") + #expect(validated.keyID == TestFixtures.validKeyID) + #expect(validated.environment == .development) + } + + @Test("Missing or empty required fields report the field") + internal func reportsMissingFields() { + #expect(throws: CloudKitConfigurationError.missing(.containerID)) { + try Self.complete(containerID: nil).validated() + } + #expect(throws: CloudKitConfigurationError.missing(.containerID)) { + try Self.complete(containerID: "").validated() + } + #expect(throws: CloudKitConfigurationError.missing(.keyID)) { + try Self.complete(keyID: nil).validated() + } + #expect(throws: CloudKitConfigurationError.missing(.privateKey)) { + try Self.complete(privateKeyPath: nil).validated() + } + } + + @Test("An inline private key wins over a path") + internal func inlineKeyWinsOverPath() throws { + let validated = try Self.complete(privateKey: TestFixtures.validPEM).validated() + guard case .raw = validated.privateKey else { + Issue.record("expected inline PEM to win, got \(validated.privateKey)") + return + } + } + + @Test("Whitespace-only private-key values count as absent") + internal func whitespaceIsAbsent() { + #expect(throws: CloudKitConfigurationError.missing(.privateKey)) { + try Self.complete(privateKeyPath: " ", privateKey: " \n ").validated() + } + } + + @Test("Environment parses case-insensitively and defaults to development") + internal func parsesEnvironment() throws { + #expect(try Self.complete(environment: "production").validated().environment == .production) + #expect(try Self.complete(environment: "PRODUCTION").validated().environment == .production) + #expect(try Self.complete(environment: nil).validated().environment == .development) + } + + @Test("An unrecognized environment is reported verbatim") + internal func rejectsUnknownEnvironment() { + #expect(throws: CloudKitConfigurationError.unrecognizedEnvironment("staging")) { + try Self.complete(environment: "staging").validated() + } + } + + @Test("Presence is checked before format") + internal func presencePrecedesFormat() { + // Both a malformed key ID and no private key: the missing field is reported first. + #expect(throws: CloudKitConfigurationError.missing(.privateKey)) { + try Self.complete(keyID: "not-a-key", privateKeyPath: nil).validated() + } + } + + @Test("A malformed key ID surfaces the specific validation failure") + internal func surfacesKeyIDFailure() { + #expect( + throws: CloudKitConfigurationError.invalidKeyID(.incorrectLength(actual: 9)) + ) { + try Self.complete(keyID: "not-a-key").validated() + } + } + + @Test("A malformed inline PEM surfaces the specific validation failure") + internal func surfacesPEMFailure() { + #expect(throws: CloudKitConfigurationError.invalidPrivateKey(.missingHeader)) { + try Self.complete(privateKey: "just some text").validated() + } + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift new file mode 100644 index 000000000..3499445cf --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/ConfigurationSourcesTests.swift @@ -0,0 +1,65 @@ +// +// ConfigurationSourcesTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Configuration +internal import Testing + +@testable import MistKitConfiguration + +@Suite("ConfigurationSources") +internal struct ConfigurationSourcesTests { + private static let keys = CloudKitConfigurationKeys( + defaultContainerID: "iCloud.com.test.App" + ) + + @Test("Command-line arguments outrank environment variables") + internal func providerOrder() { + let reader = ConfigurationSources.makeConfigReader( + secretCommandLineFlags: Self.keys.secretCommandLineFlags, + arguments: ["app", "--cloudkit-environment", "production"], + environmentVariables: ["CLOUDKIT_ENVIRONMENT": "development"] + ) + #expect(reader.readCloudKitConfiguration(keys: Self.keys).environment == "production") + } + + /// Regression test for the redaction bug this package's derived flag list prevents: a + /// snake_case key base generated `--cloudkit-key_id`, which never matched the + /// hand-written `--cloudkit-key-id` in the secrets list, so a private key passed by flag + /// was logged in the clear. + @Test("A private key passed by flag is redacted from the provider's description") + internal func privateKeyIsRedacted() { + let secret = "-----BEGIN PRIVATE KEY-----SUPERSECRET-----END PRIVATE KEY-----" + let provider = CommandLineArgumentsProvider( + arguments: ["app", "--cloudkit-private-key", secret], + secretsSpecifier: .specific(Self.keys.secretCommandLineFlags) + ) + let described = String(describing: provider) + #expect(!described.contains("SUPERSECRET"), "the private key must not appear: \(described)") + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift new file mode 100644 index 000000000..a1d355281 --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/KeyIDValidatorTests.swift @@ -0,0 +1,77 @@ +// +// KeyIDValidatorTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Testing + +@testable import MistKitConfiguration + +@Suite("KeyIDValidator") +internal struct KeyIDValidatorTests { + @Test("Accepts a 64-character hex key ID in either case") + internal func acceptsValidKeyID() throws { + try KeyIDValidator.validate(TestFixtures.validKeyID) + try KeyIDValidator.validate(TestFixtures.validKeyID.uppercased()) + } + + @Test("Rejects an empty or whitespace-only key ID") + internal func rejectsEmpty() { + #expect(throws: KeyIDValidationFailure.empty) { try KeyIDValidator.validate("") } + #expect(throws: KeyIDValidationFailure.empty) { try KeyIDValidator.validate(" ") } + } + + @Test("Rejects surrounding whitespace, the classic copy/paste newline") + internal func rejectsSurroundingWhitespace() { + #expect(throws: KeyIDValidationFailure.surroundingWhitespace) { + try KeyIDValidator.validate(" \(TestFixtures.validKeyID)\n") + } + } + + @Test("Reports the actual length when it is wrong") + internal func reportsIncorrectLength() { + #expect(throws: KeyIDValidationFailure.incorrectLength(actual: 3)) { + try KeyIDValidator.validate("abc") + } + #expect(throws: KeyIDValidationFailure.incorrectLength(actual: 65)) { + try KeyIDValidator.validate(TestFixtures.validKeyID + "a") + } + } + + @Test("Rejects non-hex characters") + internal func rejectsNonHex() { + let sameLengthNonHex = String(repeating: "z", count: KeyIDValidator.expectedLength) + #expect(throws: KeyIDValidationFailure.nonHexCharacters) { + try KeyIDValidator.validate(sameLengthNonHex) + } + } + + @Test("Expected length is CloudKit's 64-character fingerprint") + internal func expectedLength() { + #expect(KeyIDValidator.expectedLength == 64) + } +} diff --git a/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift new file mode 100644 index 000000000..2d3615eb4 --- /dev/null +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/PEMValidatorTests.swift @@ -0,0 +1,65 @@ +// +// PEMValidatorTests.swift +// MistKitConfiguration +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKitConfiguration + +@Suite("PEMValidator") +internal struct PEMValidatorTests { + @Test("Accepts a well-formed PEM") + internal func acceptsValidPEM() throws { + try PEMValidator.validate(TestFixtures.validPEM) + } + + @Test("Rejects a missing header") + internal func rejectsMissingHeader() { + let pem = "\(Data(repeating: 0x41, count: 48).base64EncodedString())\n-----END PRIVATE KEY-----" + #expect(throws: PEMValidationFailure.missingHeader) { try PEMValidator.validate(pem) } + } + + @Test("Rejects a truncated key with no footer") + internal func rejectsMissingFooter() { + let pem = "-----BEGIN PRIVATE KEY-----\nQUFB" + #expect(throws: PEMValidationFailure.missingFooter) { try PEMValidator.validate(pem) } + } + + @Test("Rejects headers enclosing no key data") + internal func rejectsEmptyContent() { + let pem = "-----BEGIN PRIVATE KEY-----\n-----END PRIVATE KEY-----" + #expect(throws: PEMValidationFailure.emptyContent) { try PEMValidator.validate(pem) } + } + + @Test("Rejects content that is not base64") + internal func rejectsInvalidBase64() { + let pem = "-----BEGIN PRIVATE KEY-----\n!!!not base64!!!\n-----END PRIVATE KEY-----" + #expect(throws: PEMValidationFailure.invalidBase64) { try PEMValidator.validate(pem) } + } +} diff --git a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/TestFixtures.swift similarity index 59% rename from Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift rename to Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/TestFixtures.swift index 6cf6299d6..4edc57638 100644 --- a/Examples/BushelCloud/Sources/BushelCloudKit/CloudKit/CloudKitAuthMethod.swift +++ b/Packages/MistKitConfiguration/Tests/MistKitConfigurationTests/TestFixtures.swift @@ -1,6 +1,6 @@ // -// CloudKitAuthMethod.swift -// BushelCloud +// TestFixtures.swift +// MistKitConfiguration // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -29,25 +29,15 @@ internal import Foundation -/// Authentication method for CloudKit Server-to-Server -/// -/// Provides type-safe authentication credential handling with two patterns: -/// - `.pemString`: For CI/CD environments (GitHub Actions secrets) -/// - `.pemFile`: For local development (file on disk) -public enum CloudKitAuthMethod: Sendable { - /// PEM content provided as string (CI/CD pattern) - /// - /// **Usage**: Pass PEM content from environment variables or secrets - /// ```swift - /// let method = .pemString(pemContentFromEnvironment) - /// ``` - case pemString(String) +/// Shared credential fixtures. +internal enum TestFixtures { + /// A syntactically valid 64-character hex key ID. + internal static let validKeyID = String(repeating: "a1b2c3d4", count: 8) - /// PEM content loaded from file path (local development pattern) - /// - /// **Usage**: Pass path to .pem file on disk - /// ```swift - /// let method = .pemFile(path: "~/.cloudkit/bushel-private-key.pem") - /// ``` - case pemFile(path: String) + /// A structurally valid PEM private key with base64-decodable content. + internal static let validPEM = """ + -----BEGIN PRIVATE KEY----- + \(Data(repeating: 0x41, count: 48).base64EncodedString()) + -----END PRIVATE KEY----- + """ } diff --git a/Packages/MistKitConfiguration/codecov.yml b/Packages/MistKitConfiguration/codecov.yml new file mode 100644 index 000000000..d07d53eef --- /dev/null +++ b/Packages/MistKitConfiguration/codecov.yml @@ -0,0 +1,9 @@ +coverage: + status: + patch: + default: + target: auto + threshold: 2% + +ignore: + - "Tests" diff --git a/Packages/MistKitConfiguration/mise.toml b/Packages/MistKitConfiguration/mise.toml new file mode 100644 index 000000000..6df20abb4 --- /dev/null +++ b/Packages/MistKitConfiguration/mise.toml @@ -0,0 +1,7 @@ +[settings] +experimental = true + +[tools] +"spm:swiftlang/swift-format" = "602.0.0" +"aqua:realm/SwiftLint" = "0.62.2" +"spm:peripheryapp/periphery" = "3.7.4" diff --git a/README.md b/README.md index ca1e6c8e5..99741419d 100644 --- a/README.md +++ b/README.md @@ -553,12 +553,22 @@ MistKit is released under the MIT License. See [LICENSE](LICENSE) for details. - [x] [Fetching Database Changes (changes/database)](https://github.com/brightdigit/MistKit/issues/46) ✅ - [x] [Fetching Record Zone Changes (changes/zone)](https://github.com/brightdigit/MistKit/issues/47) ✅ - [x] [Clarify change-tracking endpoint coverage](https://github.com/brightdigit/MistKit/issues/401) ✅ *(deprecates `zones/changes` in favor of `changes/database`)* +- [x] [Add custom CloudKit zone support for queries](https://github.com/brightdigit/MistKit/issues/146) ✅ + +### v1.0.0-beta.5 + +- [x] [Zone payloads: `deleted`, `zoneType`, and `ownerRecordName` decoding](https://github.com/brightdigit/MistKit/issues/444) ✅ +- [x] [Zone-aware writes (CRUD + assets)](https://github.com/brightdigit/MistKit/issues/454) ✅ +- [x] [Consume web auth token rotation (`X-Apple-CloudKit-Web-Auth-Token`)](https://github.com/brightdigit/MistKit/issues/462) ✅ +- [x] [Verify downloaded asset bytes against `Asset.fileChecksum`](https://github.com/brightdigit/MistKit/issues/466) ✅ +- [x] [Change `FieldValue.bytes` from `String` to `Data`](https://github.com/brightdigit/MistKit/issues/467) ✅ +- [x] [Add `VALIDATE` to `Reference.Action`](https://github.com/brightdigit/MistKit/issues/464) ✅ +- [x] [MistDemo web UI: `zoneName`/`zoneOwner` inputs on the query panel](https://github.com/brightdigit/MistKit/issues/438) ✅ ### Backlog / Post-beta - [ ] [Discovering All User Identities (GET users/discover)](https://github.com/brightdigit/MistKit/issues/28) - [ ] [Fetching Contacts (users/lookup/contacts)](https://github.com/brightdigit/MistKit/issues/33) -- [ ] [Feature: Add custom CloudKit zone support for queries](https://github.com/brightdigit/MistKit/issues/146) ### v1.0.0 diff --git a/ReleaseNotes.md b/ReleaseNotes.md index 3dae4c762..562ac15d1 100644 --- a/ReleaseNotes.md +++ b/ReleaseNotes.md @@ -1,3 +1,17 @@ +## Unreleased + +* Represent `FieldValue.bytes` as `Data` instead of a base64 `String` (#467) +* Verify downloaded asset bytes against `fileChecksum`, and add `Asset.download(using:)` that refuses unverified data (#466) +* Add `VALIDATE` to `Reference.Action` for CloudKit Web Services reference dictionaries (#464) +* Consume rotated web auth tokens from the `X-Apple-CloudKit-Web-Auth-Token` response header via a new `TokenManager.didReceiveRotatedWebAuthToken(_:)` requirement, defaulted to a no-op so existing conformances keep compiling (#462, #463) +* Model the zone payload fields confirmed live in #444: `ZoneInfo.zoneType` (a closed `ZoneType` enum that throws `ConversionError.unrecognizedZoneType` on unknown wire values) and `ZoneInfo.deleted`, so change feeds surface tombstones (#444) +* Fix `ownerRecordName` never decoding on zone payloads — `ZoneID` read the wire key as `ownerName`, so shared-zone owners were always `nil` (#444) +* Add an optional `zoneID:` parameter to `createRecord`, `updateRecord`, `deleteRecord`, and `uploadAssets`, so writes and asset uploads can target custom and shared zones (#454) +* Add `CloudKitError.missingAssetDownloadURL`, `.missingAssetChecksum`, and `.assetChecksumMismatch` for the asset download path (#466) +* Extract the shared CloudKit credential configuration glue into a separate `MistKitConfiguration` package and converge the examples on typed configuration keys (#455) +* MistDemo: expose `zoneName`/`zoneOwner` on the web query panel and add zone-aware writes plus a live shared-zone round-trip phase (#438, #453, #454) +* Add a repeatable release runbook, and address code review fixes, coverage, and lint tooling (#460, #461) + ## 1.0.0-beta.4 ### Change Tracking diff --git a/Scripts/lint.sh b/Scripts/lint.sh index 6246245c3..16e060c4d 100755 --- a/Scripts/lint.sh +++ b/Scripts/lint.sh @@ -3,17 +3,180 @@ # Remove set -e to allow script to continue running # set -e # Exit on any error +# Report mode (read-only, structured output for humans and agents): +# LINT_REPORT=1 human summary on stderr + JSON between delimiter markers +# LINT_REPORT=json JSON report only (delimiters on stdout) +# +# Exit code is non-zero when any pipeline step fails. Each lint tool runs with +# --strict so warnings/findings fail their step. summary.totalFindings in the +# JSON counts individual findings across tools (must be 0 for a clean run). + ERRORS=0 +FAILED_STEP_NAMES=() +LINT_REPORT_MODE=0 +LINT_REPORT_OUTPUT="" +REPORT_DIR="" +MANIFEST_PATH="" run_command() { - "$@" || ERRORS=$((ERRORS + 1)) + "$@" || ERRORS=$((ERRORS + 1)) +} + +record_failed_step() { + local step="$1" + FAILED_STEP_NAMES+=("$step") +} + +log_status() { + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + echo "$@" >&2 + else + echo "$@" + fi +} + +run_report_step() { + local step="$1" + shift + local exit_code=0 + + "$@" + exit_code=$? + + if [ "$exit_code" -ne 0 ]; then + ERRORS=$((ERRORS + 1)) + record_failed_step "$step" + fi + + printf '%s' "$exit_code" >"$REPORT_DIR/${step}.exit" + return "$exit_code" +} + +init_report_mode() { + case "${LINT_REPORT:-}" in + 1 | yes | true | TRUE | YES) + LINT_REPORT_OUTPUT=both + LINT_REPORT_MODE=1 + ;; + json | JSON) + LINT_REPORT_OUTPUT=json + LINT_REPORT_MODE=1 + ;; + *) + LINT_REPORT_OUTPUT="" + LINT_REPORT_MODE=0 + ;; + esac + + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + REPORT_DIR=$(mktemp -d "${TMPDIR:-/tmp}/mistkit-lint-report.XXXXXX") + MANIFEST_PATH="$REPORT_DIR/manifest.json" + fi +} + +cleanup_report_mode() { + if [ -n "$REPORT_DIR" ] && [ -d "$REPORT_DIR" ]; then + rm -rf "$REPORT_DIR" + fi +} + +write_manifest() { + local swiftlint_skipped="${1:-0}" + local swiftlint_skip_reason="${2:-}" + local periphery_skipped="${3:-0}" + local periphery_skip_reason="${4:-}" + local swift_build_skipped="${5:-0}" + local swift_build_skip_reason="${6:-}" + local failed_steps_csv="" + + if [ "${#FAILED_STEP_NAMES[@]}" -gt 0 ]; then + local IFS=, + failed_steps_csv="${FAILED_STEP_NAMES[*]}" + fi + + REPORT_DIR="$REPORT_DIR" \ + MANIFEST_PATH="$MANIFEST_PATH" \ + LINT_REPORT_OUTPUT="$LINT_REPORT_OUTPUT" \ + FAILED_STEPS_CSV="$failed_steps_csv" \ + SWIFTLINT_SKIPPED="$swiftlint_skipped" \ + SWIFTLINT_SKIP_REASON="$swiftlint_skip_reason" \ + PERIPHERY_SKIPPED="$periphery_skipped" \ + PERIPHERY_SKIP_REASON="$periphery_skip_reason" \ + SWIFT_BUILD_SKIPPED="$swift_build_skipped" \ + SWIFT_BUILD_SKIP_REASON="$swift_build_skip_reason" \ + python3 - <<'PY' +import json +import os +from pathlib import Path + +report_dir = Path(os.environ["REPORT_DIR"]) +manifest_path = Path(os.environ["MANIFEST_PATH"]) +failed_steps = [ + step for step in os.environ.get("FAILED_STEPS_CSV", "").split(",") if step +] + + +def read_exit(step: str) -> int | None: + exit_path = report_dir / f"{step}.exit" + if not exit_path.is_file(): + return None + return int(exit_path.read_text(encoding="utf-8")) + + +manifest = { + "reportDir": str(report_dir), + "outputFormat": os.environ["LINT_REPORT_OUTPUT"], + "failedSteps": failed_steps, + "steps": { + "swift-format": {"exitCode": read_exit("swift-format")}, + "swiftlint": { + "skipped": os.environ.get("SWIFTLINT_SKIPPED") == "1", + "skipReason": os.environ.get("SWIFTLINT_SKIP_REASON") or None, + "exitCode": read_exit("swiftlint"), + }, + "swift-build": { + "skipped": os.environ.get("SWIFT_BUILD_SKIPPED") == "1", + "skipReason": os.environ.get("SWIFT_BUILD_SKIP_REASON") or None, + "exitCode": read_exit("swift-build"), + }, + "periphery": { + "skipped": os.environ.get("PERIPHERY_SKIPPED") == "1", + "skipReason": os.environ.get("PERIPHERY_SKIP_REASON") or None, + "exitCode": read_exit("periphery"), + }, + }, +} +manifest_path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8") +PY +} + +emit_lint_report() { + local swiftlint_skipped="$1" + local swiftlint_skip_reason="$2" + local periphery_skipped="$3" + local periphery_skip_reason="$4" + local swift_build_skipped="$5" + local swift_build_skip_reason="$6" + + write_manifest \ + "$swiftlint_skipped" "$swiftlint_skip_reason" \ + "$periphery_skipped" "$periphery_skip_reason" \ + "$swift_build_skipped" "$swift_build_skip_reason" + + python3 "$PACKAGE_DIR/.claude/skills/fix-lint/scripts/compile-lint-report.py" "$MANIFEST_PATH" } if [ "$LINT_MODE" = "INSTALL" ]; then exit fi -echo "LintMode: $LINT_MODE" +init_report_mode +trap cleanup_report_mode EXIT + +echo "LintMode: $LINT_MODE" >&2 +if [ "$LINT_REPORT_MODE" -eq 1 ]; then + echo "LintReport: $LINT_REPORT_OUTPUT (read-only)" >&2 +fi # More portable way to get script directory if [ -z "$SRCROOT" ]; then @@ -44,35 +207,69 @@ fi if [ "$LINT_MODE" = "NONE" ]; then exit -elif [ "$LINT_MODE" = "STRICT" ]; then - SWIFTFORMAT_OPTIONS="--configuration .swift-format" - SWIFTLINT_OPTIONS="--strict" -else - SWIFTFORMAT_OPTIONS="--configuration .swift-format" - SWIFTLINT_OPTIONS="" fi -pushd "$PACKAGE_DIR" || exit +SWIFTFORMAT_FORMAT_OPTIONS="--configuration .swift-format" +SWIFTFORMAT_LINT_OPTIONS="--configuration .swift-format --strict" +SWIFTLINT_OPTIONS="--strict" +PERIPHERY_OPTIONS="--strict" -if [ -z "$CI" ]; then - run_command swift-format format $SWIFTFORMAT_OPTIONS --recursive --parallel --in-place Sources Tests +SWIFTLINT_SKIP_REASON="" +PERIPHERY_SKIP_REASON="" +SWIFT_BUILD_SKIP_REASON="" + +pushd "$PACKAGE_DIR" >/dev/null || exit + +if [ -z "$CI" ] && [ "$LINT_REPORT_MODE" -eq 0 ]; then + run_command swift-format format $SWIFTFORMAT_FORMAT_OPTIONS --recursive --parallel --in-place Sources Tests if [ "$RUN_SWIFTLINT" -eq 1 ]; then run_command swiftlint --fix fi fi -if [ -z "$FORMAT_ONLY" ]; then - run_command swift-format lint --configuration .swift-format --recursive --parallel $SWIFTFORMAT_OPTIONS Sources Tests +if [ -z "$FORMAT_ONLY" ] || [ "$LINT_REPORT_MODE" -eq 1 ]; then + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + run_report_step swift-format \ + swift-format lint --recursive --parallel \ + $SWIFTFORMAT_LINT_OPTIONS Sources Tests \ + >"$REPORT_DIR/swift-format.log" 2>&1 + else + run_command swift-format lint --recursive --parallel \ + $SWIFTFORMAT_LINT_OPTIONS Sources Tests + fi + if [ "$RUN_SWIFTLINT" -eq 1 ]; then - run_command swiftlint lint $SWIFTLINT_OPTIONS + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + run_report_step swiftlint \ + swiftlint lint --quiet --reporter json $SWIFTLINT_OPTIONS \ + >"$REPORT_DIR/swiftlint.json" 2>"$REPORT_DIR/swiftlint.stderr" + else + run_command swiftlint lint $SWIFTLINT_OPTIONS + fi else - echo "Skipping SwiftLint (Claude Code web session)." + SWIFTLINT_SKIP_REASON="Claude Code web session" + if [ "$LINT_REPORT_MODE" -eq 0 ]; then + echo "Skipping SwiftLint (Claude Code web session)." + fi + fi + + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + run_report_step swift-build \ + swift build --build-tests \ + >"$REPORT_DIR/swift-build.log" 2>&1 + else + run_command swift build --build-tests fi - # Check for compilation errors - run_command swift build --build-tests fi -$PACKAGE_DIR/Scripts/header.sh -d $PACKAGE_DIR/Sources -c "Leo Dion" -o "BrightDigit" -p "MistKit" +if [ "$LINT_REPORT_MODE" -eq 1 ]; then + if ! "$PACKAGE_DIR/Scripts/header.sh" -d "$PACKAGE_DIR/Sources" -c "Leo Dion" -o "BrightDigit" -p "MistKit" >&2; then + ERRORS=$((ERRORS + 1)) + record_failed_step header + fi +else + "$PACKAGE_DIR/Scripts/header.sh" -d "$PACKAGE_DIR/Sources" -c "Leo Dion" -o "BrightDigit" -p "MistKit" +fi # Generated files now automatically include ignore directives via OpenAPI generator configuration @@ -98,25 +295,60 @@ periphery_index_store() { return 1 } -if [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then +if { [ -z "$FORMAT_ONLY" ] || [ "$LINT_REPORT_MODE" -eq 1 ]; } \ + && [ -z "$CI" ] && [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then if INDEX_STORE_PATH=$(periphery_index_store); then - run_command periphery scan $PERIPHERY_OPTIONS \ - --index-store-path "$INDEX_STORE_PATH" --skip-build \ - --disable-update-check + if [ "$LINT_REPORT_MODE" -eq 1 ]; then + run_report_step periphery \ + periphery scan $PERIPHERY_OPTIONS \ + --index-store-path "$INDEX_STORE_PATH" --skip-build \ + --disable-update-check --format json --quiet \ + >"$REPORT_DIR/periphery.json" 2>"$REPORT_DIR/periphery.stderr" + else + run_command periphery scan $PERIPHERY_OPTIONS \ + --index-store-path "$INDEX_STORE_PATH" --skip-build \ + --disable-update-check + fi else - echo "Skipping periphery scan (no index store under .build; run swift build first)." + PERIPHERY_SKIP_REASON="no index store under .build; run swift build first" + if [ "$LINT_REPORT_MODE" -eq 0 ]; then + echo "Skipping periphery scan ($PERIPHERY_SKIP_REASON)." + fi fi else - echo "Skipping periphery scan (CI or Claude Code web session)." + if [ -n "$CI" ]; then + PERIPHERY_SKIP_REASON="CI" + elif [ "${CLAUDE_CODE_REMOTE:-}" = "true" ]; then + PERIPHERY_SKIP_REASON="Claude Code web session" + fi + if [ "$LINT_REPORT_MODE" -eq 0 ]; then + echo "Skipping periphery scan (${PERIPHERY_SKIP_REASON:-CI or Claude Code web session})." + fi +fi + +if [ "$LINT_REPORT_MODE" -eq 1 ]; then + swiftlint_skipped=0 + if [ "$RUN_SWIFTLINT" -eq 0 ]; then + swiftlint_skipped=1 + fi + periphery_skipped=0 + if [ -n "$PERIPHERY_SKIP_REASON" ]; then + periphery_skipped=1 + fi + swift_build_skipped=0 + emit_lint_report \ + "$swiftlint_skipped" "$SWIFTLINT_SKIP_REASON" \ + "$periphery_skipped" "$PERIPHERY_SKIP_REASON" \ + "$swift_build_skipped" "$SWIFT_BUILD_SKIP_REASON" fi -popd +popd >/dev/null # Exit with error code if any errors occurred if [ $ERRORS -gt 0 ]; then - echo "Linting completed with $ERRORS error(s)" + log_status "Linting completed with $ERRORS error(s)" exit 1 else - echo "Linting completed successfully" + log_status "Linting completed successfully" exit 0 fi diff --git a/Scripts/release.sh b/Scripts/release.sh new file mode 100755 index 000000000..62aa66617 --- /dev/null +++ b/Scripts/release.sh @@ -0,0 +1,632 @@ +#!/bin/bash + +# MistKit release tooling. +# +# Naming convention (deliberate, asserted throughout): +# release branch = v e.g. v1.0.0-beta.5 +# release tag = e.g. 1.0.0-beta.5 +# The `v` is stripped in exactly one place — tag_for() — so the rest of the +# system never re-derives it. +# +# Most subcommands are read-only checks that accumulate into ERRORS and exit +# non-zero, following Scripts/lint.sh rather than blanket `set -e`. + +REPO="brightdigit/MistKit" +REPO_URL="https://github.com/${REPO}.git" + +# Workflows that gate a release. Explicitly listed rather than "all green": +# Claude Code Review is advisory and is routinely red, so an all-green rule +# would make preflight unpassable and get bypassed. No env-var override — +# a release gate is policy, not configuration. +REQUIRED_WORKFLOWS=("MistKit" "MistDemo Integration" "Examples") + +# Example workflows carrying a MISTKIT_BRANCH pin. Both are git subrepos. +# When Packages/MistKitConfiguration lands (#407), add its workflow here and +# to the git subrepo push hints in cmd_pins. +PIN_FILES=( + "Examples/BushelCloud/.github/workflows/BushelCloud.yml" + "Examples/CelestraCloud/.github/workflows/CelestraCloud.yml" +) + +ERRORS=0 +DRY_RUN=false +QUIET_TRAILER=false + +pass() { echo "✅ $*"; } +warn() { echo "⚠️ $*"; } +fail() { echo "❌ $*"; ERRORS=$((ERRORS + 1)); } +info() { echo "→ $*"; } + +die() { + echo "❌ $*" >&2 + exit 1 +} + +# Run a mutating command, or print it under --dry-run. +run() { + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] $*" + else + "$@" || fail "command failed: $*" + fi +} + +repo_root() { + git rev-parse --show-toplevel 2>/dev/null +} + +# The single place `v` is stripped. +tag_for() { + echo "${1#v}" +} + +branch_for() { + echo "v${1#v}" +} + +current_branch() { + git rev-parse --abbrev-ref HEAD 2>/dev/null +} + +# Newest release already recorded in ReleaseNotes.md, e.g. 1.0.0-beta.4. +previous_tag() { + sed -n 's/^## \(.*\)$/\1/p' ReleaseNotes.md | head -1 +} + +# The release immediately before $1 — skips $1 when notes-draft prepended it. +prior_released_tag() { + local excluding="$1" + sed -n 's/^## \(.*\)$/\1/p' ReleaseNotes.md \ + | awk -v skip="$excluding" '$0 != skip { print; exit }' +} + +validate_release_tag() { + local tag="$1" + if [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?$ ]]; then + return 0 + fi + die "tag '$tag' does not match the release pattern (expected e.g. 1.0.0-beta.5)" +} + +# Print the ReleaseNotes.md section for a tag (heading included). +notes_section() { + awk -v tag="## $1" ' + $0 == tag { found = 1; print; next } + found && /^## / { exit } + found { print } + ' ReleaseNotes.md +} + +# Same, but from a given git tree-ish rather than the working tree. +notes_section_at() { + git show "$1:ReleaseNotes.md" 2>/dev/null | awk -v tag="## $2" ' + $0 == tag { found = 1; print; next } + found && /^## / { exit } + found { print } + ' +} + +usage() { + cat <<'EOF' +usage: ./Scripts/release.sh [args] + + preflight [branch] read-only gate: worktree, CI, build/test/lint + notes-draft [branch] write the ReleaseNotes.md section (--stdout to preview) + check [branch] validate the prepared tree before the release PR + pins [--expect-branch | --expect-tag | --roll-to ] + publish create the GitHub pre-release from ReleaseNotes.md + verify-tag [--at ] assert a tag (or a candidate commit) is releasable + +Common flags: --dry-run, --skip-local (preflight), --stdout (notes-draft) + +Branch defaults to the current branch. Tags are derived by stripping `v`. +EOF +} + +# ---------------------------------------------------------------- preflight + +cmd_preflight() { + local branch="${1:-$(current_branch)}" + local skip_local="$2" + local tag + tag=$(tag_for "$branch") + + echo "🔍 Preflight for $branch (tag: $tag)" + echo + + # Branch shape. The `v` prefix is the convention, so assert it rather than + # silently accepting a tag-shaped value. + if [[ "$branch" =~ ^v[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?$ ]]; then + pass "branch name '$branch' matches the release pattern" + else + fail "branch '$branch' is not a release branch (expected v, e.g. v1.0.0-beta.5)" + fi + + # Right worktree. Never suggest `git stash` — the stack is shared across + # every worktree in this layout. + if [ "$(current_branch)" = "$branch" ]; then + pass "running in the '$branch' worktree" + else + fail "current branch is '$(current_branch)', not '$branch'" + info "worktrees:" + git worktree list | sed 's/^/ /' + fi + + if [ -z "$(git status --porcelain)" ]; then + pass "working tree is clean" + else + fail "working tree is dirty (commit first — do NOT git stash in this repo)" + fi + + # The tag must not exist yet, locally or on the remote. + if git rev-parse -q --verify "refs/tags/$tag" >/dev/null; then + fail "tag '$tag' already exists locally" + elif [ -n "$(git ls-remote --tags origin "$tag" 2>/dev/null)" ]; then + fail "tag '$tag' already exists on origin" + else + pass "tag '$tag' does not exist yet" + fi + + # Notes for this release must NOT be written yet at preflight time; they + # are authored in the notes phase. Warn rather than fail if already there. + if [ -n "$(notes_section "$tag")" ]; then + warn "ReleaseNotes.md already has a '## $tag' section (re-running preflight?)" + else + info "ReleaseNotes.md has no '## $tag' section yet — author it in the notes phase" + fi + + # CI on the branch tip, limited to the gating workflows. + local sha + sha=$(git rev-parse HEAD) + if command -v gh >/dev/null 2>&1; then + local runs + runs=$(gh run list --branch "$branch" --commit "$sha" \ + --json workflowName,conclusion,status 2>/dev/null) + if [ -z "$runs" ] || [ "$runs" = "[]" ]; then + fail "no CI runs found for $branch @ ${sha:0:7}" + else + local wf conclusion + for wf in "${REQUIRED_WORKFLOWS[@]}"; do + conclusion=$(echo "$runs" | jq -r --arg w "$wf" \ + '[.[] | select(.workflowName == $w)] | first | .conclusion // "missing"') + case "$conclusion" in + success) pass "CI '$wf' is green" ;; + missing) fail "CI '$wf' has no run for this commit" ;; + *) fail "CI '$wf' is '$conclusion'" ;; + esac + done + fi + else + fail "gh CLI not found; cannot verify CI" + fi + + # Pins must point at THIS branch before the release merge, so example CI + # actually compiles the code being released. + check_pins --expect-branch "$branch" + + # Open milestone issues are a warning, not a gate. + if command -v gh >/dev/null 2>&1; then + local open + open=$(gh issue list --milestone "$branch" --state open \ + --json number,title 2>/dev/null) + if [ -n "$open" ] && [ "$open" != "[]" ]; then + warn "milestone '$branch' still has open issues:" + echo "$open" | jq -r '.[] | " #\(.number) \(.title)"' + else + pass "no open issues in milestone '$branch'" + fi + fi + + # Local build/test/lint. Default is to run them. + if [ "$skip_local" = "--skip-local" ]; then + warn "skipping local build/test/lint (--skip-local)" + else + info "running swift build" + swift build >/dev/null 2>&1 && pass "swift build" || fail "swift build" + info "running swift test" + swift test >/dev/null 2>&1 && pass "swift test" || fail "swift test" + info "running Scripts/lint.sh" + ./Scripts/lint.sh >/dev/null 2>&1 && pass "lint" || fail "lint" + fi +} + +# --------------------------------------------------------------------- pins + +pin_value() { + sed -n 's/^[[:space:]]*MISTKIT_BRANCH:[[:space:]]*\(.*\)$/\1/p' "$1" | head -1 +} + +# Assert each pin is the expected ref AND resolves as the right KIND of ref. +# setup-mistkit resolves MISTKIT_BRANCH with `git ls-remote`, which matches +# tags as well as branches — so a tag pins silently and greens example CI +# without ever compiling the branch. That is the check gh cannot do for us. +check_pins() { + local mode="$1" expected="$2" + local file value + + for file in "${PIN_FILES[@]}"; do + [ -f "$file" ] || { fail "missing $file"; continue; } + value=$(pin_value "$file") + local name="${file#Examples/}" + name="${name%%/*}" + + if [ -z "$value" ]; then + fail "$name: no MISTKIT_BRANCH found" + continue + fi + + if [ "$value" != "$expected" ]; then + fail "$name: MISTKIT_BRANCH is '$value', expected '$expected'" + continue + fi + + case "$mode" in + --expect-branch) + if [ -n "$(git ls-remote --heads "$REPO_URL" "$value" 2>/dev/null)" ]; then + pass "$name: pinned to branch '$value'" + else + fail "$name: '$value' does not resolve as a BRANCH (a tag here would green CI without testing the branch)" + fi + ;; + --expect-tag) + if [ -n "$(git ls-remote --tags "$REPO_URL" "$value" 2>/dev/null)" ]; then + pass "$name: pinned to tag '$value'" + else + fail "$name: '$value' does not resolve as a TAG" + fi + ;; + esac + done +} + +cmd_pins() { + local mode="$1" ref="$2" + + case "$mode" in + --expect-branch|--expect-tag) + [ -n "$ref" ] || die "$mode needs a ref" + echo "🔍 Checking MISTKIT_BRANCH pins against '$ref'" + check_pins "$mode" "$ref" + ;; + --roll-to) + [ -n "$ref" ] || die "--roll-to needs a ref" + echo "🔄 Rolling MISTKIT_BRANCH pins to '$ref'" + local file + for file in "${PIN_FILES[@]}"; do + [ -f "$file" ] || { fail "missing $file"; continue; } + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] $file: $(pin_value "$file") → $ref" + else + # BSD/GNU sed compatible: write to a temp file. + sed "s|^\([[:space:]]*MISTKIT_BRANCH:[[:space:]]*\).*$|\1$ref|" \ + "$file" > "$file.tmp" && mv "$file.tmp" "$file" + pass "$file → $ref" + fi + done + echo + # These files live in git subrepos; pushing them is a separate, + # deliberate step the human runs. + info "Examples are git subrepos — after committing, push each:" + echo " git subrepo push Examples/BushelCloud" + echo " git subrepo push Examples/CelestraCloud" + ;; + *) + # Report-only. + echo "🔍 Current MISTKIT_BRANCH pins" + local file + for file in "${PIN_FILES[@]}"; do + [ -f "$file" ] && echo " $file: $(pin_value "$file")" + done + ;; + esac +} + +# -------------------------------------------------------------- notes-draft + +cmd_notes_draft() { + local branch="${1:-$(current_branch)}" + local to_stdout="$2" + local tag prev + tag=$(tag_for "$branch") + prev=$(previous_tag) + + [ -n "$prev" ] || die "could not read the previous tag from ReleaseNotes.md" + command -v gh >/dev/null 2>&1 || die "gh CLI is required" + + if [ -n "$(notes_section "$tag")" ]; then + die "ReleaseNotes.md already has a '## $tag' section; edit it by hand" + fi + + echo "📝 Drafting notes for $tag (since $prev)" >&2 + + local body + body=$(gh api "repos/${REPO}/releases/generate-notes" \ + -f tag_name="$tag" \ + -f target_commitish="$branch" \ + -f previous_tag_name="$prev" \ + --jq .body 2>/dev/null) || die "generate-notes failed" + + # Keep the flat bullet list only: drop GitHub's own headings and its + # trailing compare line, then re-add ours in the house format. + local section + section=$(printf '## %s\n\n%s\n\n**Full Changelog**: https://github.com/%s/compare/%s...%s\n' \ + "$tag" \ + "$(echo "$body" | grep '^\* ' )" \ + "$REPO" "$prev" "$tag") + + if [ "$to_stdout" = "--stdout" ] || [ "$DRY_RUN" = true ]; then + echo "$section" + QUIET_TRAILER=true + else + printf '%s\n\n%s' "$section" "$(cat ReleaseNotes.md)" > ReleaseNotes.md.tmp \ + && mv ReleaseNotes.md.tmp ReleaseNotes.md + pass "wrote '## $tag' to the top of ReleaseNotes.md" + info "edit the bullet wording and add issue refs, then run: check $branch" + fi + + # Raw material for the README roadmap checklist. + local closed + closed=$(gh issue list --milestone "$branch" --state closed \ + --json number,title,url 2>/dev/null) + if [ -n "$closed" ] && [ "$closed" != "[]" ]; then + echo >&2 + echo "README roadmap candidates (closed in milestone $branch):" >&2 + echo "$closed" | jq -r '.[] | "- [x] [\(.title)](\(.url)) ✅"' >&2 + fi +} + +# -------------------------------------------------------------------- check + +cmd_check() { + local branch="${1:-$(current_branch)}" + local tag prev + tag=$(tag_for "$branch") + prev=$(prior_released_tag "$tag") + + echo "🔍 Checking the prepared tree for $tag" + echo + + # The notes section must be present and be the newest one. + local first + first=$(sed -n 's/^## \(.*\)$/\1/p' ReleaseNotes.md | head -1) + if [ "$first" = "$tag" ]; then + pass "ReleaseNotes.md leads with '## $tag'" + else + fail "ReleaseNotes.md leads with '## $first', expected '## $tag'" + fi + + local section + section=$(notes_section "$tag") + if [ -z "$section" ]; then + fail "no '## $tag' section in ReleaseNotes.md" + else + local bullets + bullets=$(echo "$section" | grep -c '^\* ') + if [ "$bullets" -gt 0 ]; then + pass "section has $bullets bullet(s)" + else + fail "section has no '* ' bullets" + fi + + # Notes are a flat bullet list; subsections are no longer used. + if echo "$section" | grep -q '^### '; then + warn "section contains '###' subheadings; releases now use a flat bullet list" + fi + + local expected_compare="**Full Changelog**: https://github.com/${REPO}/compare/" + if echo "$section" | grep -qF "${expected_compare}"; then + if echo "$section" | grep -qF "${expected_compare}...${tag}" \ + || echo "$section" | grep -qE "compare/.+\.\.\.${tag//./\\.}$"; then + pass "Full Changelog line targets $tag" + else + fail "Full Changelog line does not end at '...$tag'" + fi + else + fail "section has no '**Full Changelog**' compare line" + fi + fi + + # README roadmap section for this release. + if grep -q "^### $branch\$" README.md; then + pass "README.md has a '### $branch' roadmap section" + else + fail "README.md has no '### $branch' roadmap section" + fi + + # The SwiftPM snippet should name the currently-released tag; the new one + # does not exist yet at check time. + local snippet + snippet=$(grep -o 'from: "[^"]*"' README.md | head -1 | sed 's/from: "//;s/"//') + if [ -z "$snippet" ]; then + warn "no 'from:' snippet found in README.md" + elif [ -z "$prev" ]; then + fail "could not determine the prior release tag from ReleaseNotes.md" + elif [ "$snippet" = "$prev" ]; then + pass "README 'from:' snippet names the currently released tag ($snippet)" + elif [ "$snippet" = "$tag" ]; then + fail "README 'from:' snippet is '$snippet' but the tag does not exist yet; expected '$prev'" + else + fail "README 'from:' snippet is '$snippet'; expected '$prev' (the currently released tag)" + fi + + check_pins --expect-branch "$branch" +} + +# ------------------------------------------------------------------ publish + +cmd_publish() { + local tag="$1" + [ -n "$tag" ] || die "publish needs a tag" + [ "$tag" = "${tag#v}" ] || die "tags carry no 'v' prefix; use '${tag#v}'" + validate_release_tag "$tag" + + local section + section=$(notes_section "$tag") + [ -n "$section" ] || die "no '## $tag' section in ReleaseNotes.md" + + # The release body is the notes section with the version heading swapped + # for GitHub's conventional one. + local body + body=$(echo "$section" | sed "1s|^## ${tag}$|## What's Changed|") + + local prerelease_flag="" + if [[ "$tag" == *-* ]]; then + prerelease_flag="--prerelease" + else + # A stable release is a policy call, not a default. + read -r -p "Tag '$tag' looks stable. Publish as a full release (not a pre-release)? [y/N] " reply + [[ "$reply" =~ ^[Yy]$ ]] || prerelease_flag="--prerelease" + fi + + if [ "$DRY_RUN" = true ]; then + echo " [dry-run] gh release create $tag --title $tag $prerelease_flag --verify-tag --notes-file -" >&2 + echo "--- body ---" >&2 + echo "$body" + QUIET_TRAILER=true + return + fi + + echo "$body" | gh release create "$tag" \ + --title "$tag" \ + $prerelease_flag \ + --verify-tag \ + --notes-file - \ + && pass "published $tag" \ + || fail "gh release create failed" +} + +# --------------------------------------------------------------- verify-tag + +# Assert a tag — or a candidate commit, via --at — is releasable. Reads the +# TAGGED TREE, which is what catches notes that landed after the tag. +cmd_verify_tag() { + local tag="$1" + local ref="${2:-$1}" + + [ -n "$tag" ] || die "verify-tag needs a tag" + + echo "🔍 Verifying $tag (at ${ref})" + echo + + if [ "$tag" = "${tag#v}" ]; then + pass "tag '$tag' has no 'v' prefix" + else + fail "tag '$tag' must not carry a 'v' prefix (branches do, tags do not)" + fi + + if [[ "$tag" =~ ^[0-9]+\.[0-9]+\.[0-9]+(-[a-z]+\.[0-9]+)?$ ]]; then + pass "tag '$tag' matches the release pattern" + else + fail "tag '$tag' does not match the release pattern (expected e.g. 1.0.0-beta.5)" + fi + + git rev-parse -q --verify "$ref" >/dev/null 2>&1 || die "ref '$ref' not found" + + local head_line + head_line=$(git show "$ref:ReleaseNotes.md" 2>/dev/null | head -1) + if [ "$head_line" = "## $tag" ]; then + pass "ReleaseNotes.md at $ref leads with '## $tag'" + else + fail "ReleaseNotes.md at $ref leads with '$head_line', expected '## $tag'" + fi + + if [ -n "$(notes_section_at "$ref" "$tag")" ]; then + pass "notes section for $tag is present in the tagged tree" + else + fail "no '## $tag' section in the tree at $ref" + fi + + if git show "$ref:README.md" 2>/dev/null | grep -q "^### v${tag}\$"; then + pass "README.md at $ref has the '### v$tag' roadmap section" + else + fail "README.md at $ref has no '### v$tag' roadmap section" + fi + + # Resolve main however it is available: a tag-triggered CI checkout may + # have no origin/main remote-tracking ref, so fetch it if needed. + local main_ref="" + if git rev-parse -q --verify origin/main >/dev/null 2>&1; then + main_ref="origin/main" + elif git rev-parse -q --verify refs/heads/main >/dev/null 2>&1; then + main_ref="refs/heads/main" + elif git fetch --quiet origin main 2>/dev/null && git rev-parse -q --verify FETCH_HEAD >/dev/null 2>&1; then + main_ref="FETCH_HEAD" + fi + + if [ -z "$main_ref" ]; then + fail "could not resolve main to check tag ancestry" + elif git merge-base --is-ancestor "$ref" "$main_ref" 2>/dev/null; then + pass "$ref is an ancestor of main ($main_ref)" + else + fail "$ref is not an ancestor of main ($main_ref)" + fi +} + +# ----------------------------------------------------------------- dispatch + +ROOT=$(repo_root) || die "not in a git worktree" +cd "$ROOT" || die "could not cd to $ROOT" + +COMMAND="$1" +shift 2>/dev/null || true + +ARGS=() +AT_REF="" +SKIP_LOCAL=false +STDOUT=false + +while [ $# -gt 0 ]; do + case "$1" in + --dry-run) DRY_RUN=true ;; + --skip-local) + [ "$COMMAND" = preflight ] || die "--skip-local is only valid for preflight" + SKIP_LOCAL=true + ;; + --stdout) + [ "$COMMAND" = notes-draft ] || die "--stdout is only valid for notes-draft" + STDOUT=true + ;; + --at) + [ "$COMMAND" = verify-tag ] || die "--at is only valid for verify-tag" + shift + [ -n "${1:-}" ] || die "--at requires a ref" + AT_REF="$1" + ;; + *) ARGS+=("$1") ;; + esac + shift +done + +case "$COMMAND" in + preflight) + if [ "$SKIP_LOCAL" = true ]; then + cmd_preflight "${ARGS[0]}" "--skip-local" + else + cmd_preflight "${ARGS[0]}" + fi + ;; + notes-draft) + if [ "$STDOUT" = true ]; then + cmd_notes_draft "${ARGS[0]}" "--stdout" + else + cmd_notes_draft "${ARGS[0]}" + fi + ;; + check) cmd_check "${ARGS[0]}" ;; + pins) cmd_pins "${ARGS[0]}" "${ARGS[1]}" ;; + publish) cmd_publish "${ARGS[0]}" ;; + verify-tag) cmd_verify_tag "${ARGS[0]}" "${AT_REF:-${ARGS[0]}}" ;; + -h|--help|help|"") usage; exit 0 ;; + *) usage; die "unknown command: $COMMAND" ;; +esac + +if [ "${QUIET_TRAILER:-false}" != true ]; then + echo + if [ $ERRORS -gt 0 ]; then + echo "Completed with $ERRORS error(s)" + exit 1 + fi + echo "OK" +fi +[ $ERRORS -gt 0 ] && exit 1 +exit 0 diff --git a/Scripts/update-subrepo.sh b/Scripts/update-subrepo.sh index 2023c3709..652ab2ef1 100755 --- a/Scripts/update-subrepo.sh +++ b/Scripts/update-subrepo.sh @@ -3,7 +3,7 @@ set -e # Generic script to update any example subrepo # Usage: ./Scripts/update-subrepo.sh Examples/BushelCloud -# ./Scripts/update-subrepo.sh Examples/Celestra +# ./Scripts/update-subrepo.sh Examples/CelestraCloud if [ $# -eq 0 ]; then echo "Usage: $0 " @@ -13,6 +13,35 @@ fi SUBREPO_PATH="$1" SUBREPO_NAME=$(basename "$SUBREPO_PATH") +REPO_ROOT="$(git rev-parse --show-toplevel)" + +MISTKIT_URL_DEP='.package(url: "https://github.com/brightdigit/MistKit.git", from: "1.0.0-beta.4")' +MISTKIT_PATH_DEP='.package(name: "MistKit", path: "../..")' + +restore_local_mistkit_path_dep() { + local package_swift="$SUBREPO_PATH/Package.swift" + + if [ ! -f "$package_swift" ]; then + return 0 + fi + + if grep -qF "$MISTKIT_URL_DEP" "$package_swift"; then + echo "🔧 Restoring local MistKit path dependency for monorepo development..." + if [ "$SUBREPO_NAME" = "BushelCloud" ]; then + sed -i '' "s|$MISTKIT_URL_DEP| // Local path: BushelCloud develops as Examples/BushelCloud inside the\\ + // MistKit repo, so ../.. resolves to the parent MistKit checkout. On main\\ + // this is a tagged remote release; this one-line overlay is reapplied when\\ + // the branch is recreated from main (never merged, so it never conflicts).\\ + $MISTKIT_PATH_DEP|" "$package_swift" + else + sed -i '' "s|$MISTKIT_URL_DEP|$MISTKIT_PATH_DEP|" "$package_swift" + fi + elif grep -q '\.package(name: "MistKit", path:' "$package_swift"; then + echo "✓ Local MistKit path dependency already present" + else + echo "✓ No MistKit dependency to restore" + fi +} if [ ! -d "$SUBREPO_PATH" ]; then echo "❌ Error: Directory $SUBREPO_PATH does not exist" @@ -28,23 +57,32 @@ echo "🔄 Updating $SUBREPO_NAME subrepo..." echo "" # Extract current branch from .gitrepo -CURRENT_BRANCH=$(grep -E '^\s*branch\s*=' "$SUBREPO_PATH/.gitrepo" | sed 's/.*=\s*//') +CURRENT_BRANCH=$(grep -E '^\s*branch\s*=' "$SUBREPO_PATH/.gitrepo" | sed 's/.*=\s*//' | tr -d '[:space:]') echo "📍 Current branch: $CURRENT_BRANCH" -# Pull latest from subrepo +# Pull latest from subrepo; retry with --force after upstream squash invalidates .gitrepo commit echo "" echo "📥 Pulling latest from remote..." -git subrepo pull "$SUBREPO_PATH" --branch="$CURRENT_BRANCH" +set +e +PULL_OUTPUT=$(git subrepo pull "$SUBREPO_PATH" --branch="$CURRENT_BRANCH" 2>&1) +PULL_STATUS=$? +set -e -# Handle local MistKit dependencies (for BushelCloud and CelestraCloud) -echo "" -echo "🔄 Checking for local MistKit dependencies..." -if grep -q '\.package(name: "MistKit", path:' "$SUBREPO_PATH/Package.swift"; then - echo "✓ Found local MistKit dependency - preserving for local development" +if [ "$PULL_STATUS" -ne 0 ]; then + if echo "$PULL_OUTPUT" | grep -q 'Local repository does not contain'; then + echo "⚠️ Stale subrepo commit reference detected; force-pulling from $CURRENT_BRANCH..." + git subrepo pull "$SUBREPO_PATH" --force --branch="$CURRENT_BRANCH" --update \ + -m "Re-sync $SUBREPO_NAME subrepo after upstream branch squash" + else + echo "$PULL_OUTPUT" + exit "$PULL_STATUS" + fi else - echo "✓ No local MistKit dependency found" + echo "$PULL_OUTPUT" fi +restore_local_mistkit_path_dep + # Resolve dependencies echo "" echo "📦 Resolving Swift package dependencies..." @@ -57,13 +95,13 @@ echo "🔨 Building to verify changes..." swift build # Go back to project root -cd - > /dev/null +cd "$REPO_ROOT" echo "" echo "✅ Update complete!" echo "" echo "📊 Subrepo status:" -cat "$SUBREPO_PATH/.gitrepo" | grep -E "commit|branch|remote" +grep -E "commit|branch|remote" "$SUBREPO_PATH/.gitrepo" echo "" echo "🎯 Next steps:" diff --git a/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift b/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift index f54be88fb..0a7ba196a 100644 --- a/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift +++ b/Sources/MistKit/Authentication/AdaptiveTokenManager+Transitions.swift @@ -61,4 +61,27 @@ extension AdaptiveTokenManager { return authenticator } + + /// Adopts a rotated web auth token from a CloudKit response header. + public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + guard webAuthToken != nil else { + return + } + + let authenticator = try WebAuthTokenAuthenticator( + apiToken: apiToken, + webAuthToken: token + ) + self.webAuthToken = token + + if let storage = storage { + do { + try await storage.store(authenticator, identifier: apiToken) + } catch { + Logger(subsystem: .auth).warning( + "Failed to store credentials after token rotation: \(error.localizedDescription)" + ) + } + } + } } diff --git a/Sources/MistKit/Authentication/AuthenticationMiddleware.swift b/Sources/MistKit/Authentication/AuthenticationMiddleware.swift index 3ffe84010..acbbc200a 100644 --- a/Sources/MistKit/Authentication/AuthenticationMiddleware.swift +++ b/Sources/MistKit/Authentication/AuthenticationMiddleware.swift @@ -29,6 +29,7 @@ internal import Foundation internal import HTTPTypes +internal import Logging internal import OpenAPIRuntime /// Authentication middleware that delegates request mutation to whichever @@ -50,6 +51,16 @@ internal struct AuthenticationMiddleware: ClientMiddleware { var modifiedRequest = request var modifiedBody = body try await authenticator.authenticate(request: &modifiedRequest, body: &modifiedBody) - return try await next(modifiedRequest, modifiedBody, baseURL) + let (response, responseBody) = try await next(modifiedRequest, modifiedBody, baseURL) + if let rotated = response.headerFields[.cloudKitWebAuthToken] { + do { + try await tokenManager.didReceiveRotatedWebAuthToken(rotated) + } catch { + let message = "Failed to consume rotated web auth token: \(error.localizedDescription)" + Logger(subsystem: .auth).warning("\(message)") + RotatedWebAuthTokenFailureReporter.assertionHandler(message) + } + } + return (response, responseBody) } } diff --git a/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift b/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift index 90506ddfb..65770e6cc 100644 --- a/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift +++ b/Sources/MistKit/Authentication/HTTPField.Name+CloudKit.swift @@ -49,6 +49,11 @@ extension HTTPField.Name { "X-Apple-CloudKit-Request-SignatureV1" ) + /// Rotated web authentication token returned on every CloudKit response. + internal static let cloudKitWebAuthToken = Self.knownFieldName( + "X-Apple-CloudKit-Web-Auth-Token" + ) + private static func knownFieldName(_ name: String) -> HTTPField.Name { guard let fieldName = HTTPField.Name(name) else { preconditionFailure("Invalid HTTP field name: \(name)") diff --git a/Sources/MistKit/Authentication/RotatedWebAuthTokenFailureReporter.swift b/Sources/MistKit/Authentication/RotatedWebAuthTokenFailureReporter.swift new file mode 100644 index 000000000..23bc49680 --- /dev/null +++ b/Sources/MistKit/Authentication/RotatedWebAuthTokenFailureReporter.swift @@ -0,0 +1,35 @@ +// +// RotatedWebAuthTokenFailureReporter.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// Trap hook when rotated web auth token adoption fails in middleware. +internal enum RotatedWebAuthTokenFailureReporter { + @TaskLocal internal static var assertionHandler: @Sendable (String) -> Void = { message in + assertionFailure(message) + } +} diff --git a/Sources/MistKit/Authentication/TokenManager+Rotation.swift b/Sources/MistKit/Authentication/TokenManager+Rotation.swift new file mode 100644 index 000000000..367346019 --- /dev/null +++ b/Sources/MistKit/Authentication/TokenManager+Rotation.swift @@ -0,0 +1,37 @@ +// +// TokenManager+Rotation.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +extension TokenManager { + /// Adopts a rotated web auth token from the `X-Apple-CloudKit-Web-Auth-Token` + /// response header. Default implementation is a no-op for managers that do + /// not use web authentication. + public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) {} +} diff --git a/Sources/MistKit/Authentication/TokenManager.swift b/Sources/MistKit/Authentication/TokenManager.swift index 067133522..7a8802f70 100644 --- a/Sources/MistKit/Authentication/TokenManager.swift +++ b/Sources/MistKit/Authentication/TokenManager.swift @@ -46,4 +46,8 @@ public protocol TokenManager: Sendable { /// Returns the authenticator that should be used for the next request, /// or `nil` if no credentials are available. func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? + + /// Adopts a rotated web auth token from the `X-Apple-CloudKit-Web-Auth-Token` + /// response header. + func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) } diff --git a/Sources/MistKit/Authentication/WebAuthTokenManager.swift b/Sources/MistKit/Authentication/WebAuthTokenManager.swift index 474563e6e..1277d8ca7 100644 --- a/Sources/MistKit/Authentication/WebAuthTokenManager.swift +++ b/Sources/MistKit/Authentication/WebAuthTokenManager.swift @@ -31,9 +31,9 @@ internal import Foundation /// Token manager for web authentication with API token + web auth token. /// Provides user-specific access to CloudKit Web Services. -public final class WebAuthTokenManager: TokenManager, Sendable { +public actor WebAuthTokenManager: TokenManager { internal let apiToken: String - internal let webAuthToken: String + internal var webAuthToken: String // MARK: - TokenManager Protocol @@ -66,4 +66,10 @@ public final class WebAuthTokenManager: TokenManager, Sendable { public func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: webAuthToken) } + + /// Adopts a rotated web auth token from a CloudKit response header. + public func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + _ = try WebAuthTokenAuthenticator(apiToken: apiToken, webAuthToken: token) + self.webAuthToken = token + } } diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift index a09cde342..4afe3ec29 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ErrorDescription.swift @@ -82,6 +82,8 @@ extension CloudKitError { let location = path.map { "from '\($0)'" } ?? "from inline material" return "Failed to load CloudKit private key \(location): \(underlying.localizedDescription)" + case .missingAssetDownloadURL: + return "Asset downloadURL is missing or is not a valid URL" case .accessDenied, .atomicFailure, .authenticationFailed, .authenticationRequired, .badRequest, .conflict, .exists, .internalServerError, .notFound, .quotaExceeded, .throttled, .tryAgainLater, .validatingReferenceError, .zoneNotFound, diff --git a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift index 7e76abb14..95febdff4 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError+ServerErrorCode.swift @@ -86,7 +86,8 @@ extension CloudKitError { .incompleteResponse, .conversionFailed, .recordOperationFailed, .zoneOperationFailed, .subscriptionOperationFailed, .subscriptionLikelyDuplicate, .underlyingError, .decodingError, .networkError, .unsupportedOperationType, .paginationLimitExceeded, - .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey: + .zonePaginationLimitExceeded, .missingCredentials, .invalidPrivateKey, + .missingAssetDownloadURL: return nil } } diff --git a/Sources/MistKit/CloudKitService/CloudKitError.swift b/Sources/MistKit/CloudKitService/CloudKitError.swift index 37a229299..f97e20c66 100644 --- a/Sources/MistKit/CloudKitService/CloudKitError.swift +++ b/Sources/MistKit/CloudKitService/CloudKitError.swift @@ -135,6 +135,8 @@ public enum CloudKitError: LocalizedError, Sendable { reason: String ) case invalidPrivateKey(path: String?, underlying: any Error) + /// `Asset.download(using:)` had no usable `downloadURL`. + case missingAssetDownloadURL /// HTTP status code if this error originated from an HTTP response, otherwise nil. /// diff --git a/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift b/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift index 99111feea..34bb054b1 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+AssetOperations.swift @@ -54,6 +54,8 @@ extension CloudKitService { /// - recordType: The type of record that will use this asset /// - fieldName: The name of the asset field /// - recordName: Optional unique record name + /// - zoneID: Optional zone ID (defaults to default zone). Must match the + /// zone used on the subsequent create/update that attaches the asset. /// - uploader: Optional custom upload handler /// - database: The CloudKit database scope to upload to (`.public`, `.private`, `.shared`) /// - Returns: AssetUploadReceipt containing the upload result @@ -76,6 +78,7 @@ extension CloudKitService { recordType: String, fieldName: String, recordName: String? = nil, + zoneID: ZoneID? = nil, using uploader: AssetUploader? = nil, database: Database ) async throws(CloudKitError) -> AssetUploadReceipt { @@ -84,6 +87,7 @@ extension CloudKitService { recordType: recordType, fieldName: fieldName, recordName: recordName, + zoneID: zoneID, database: database ) diff --git a/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift b/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift index 0e918d583..298120d4b 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService+RecordWriteConvenience.swift @@ -35,6 +35,8 @@ extension CloudKitService { /// - recordType: The type of record to create /// - recordName: Optional unique record name /// - fields: Dictionary of field names to FieldValue + /// - zoneID: Optional target zone (defaults to the request's zone / + /// `_defaultZone` when omitted) /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) /// - Returns: RecordInfo for the created record /// - Throws: CloudKitError if the operation fails @@ -51,6 +53,7 @@ extension CloudKitService { recordType: String, recordName: String? = nil, fields: [String: FieldValue], + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.create( @@ -59,7 +62,9 @@ extension CloudKitService { fields: fields ) - let results = try await modifyRecords([operation], database: database) + let results = try await modifyRecords( + [operation], zoneID: zoneID, database: database + ) guard let result = results.first else { throw CloudKitError.invalidResponse } @@ -72,6 +77,8 @@ extension CloudKitService { /// - recordName: The unique record name /// - fields: Dictionary of field names to FieldValue /// - recordChangeTag: Optional change tag for optimistic locking + /// - zoneID: Optional target zone (defaults to the request's zone / + /// `_defaultZone` when omitted) /// - database: The CloudKit database scope to write to (`.public`, `.private`, `.shared`) /// - Returns: RecordInfo for the updated record /// - Throws: CloudKitError if the operation fails @@ -91,6 +98,7 @@ extension CloudKitService { recordName: String, fields: [String: FieldValue], recordChangeTag: String? = nil, + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) -> RecordInfo { let operation = RecordOperation.update( @@ -100,7 +108,9 @@ extension CloudKitService { recordChangeTag: recordChangeTag ) - let results = try await modifyRecords([operation], database: database) + let results = try await modifyRecords( + [operation], zoneID: zoneID, database: database + ) guard let result = results.first else { throw CloudKitError.invalidResponse } @@ -112,12 +122,15 @@ extension CloudKitService { /// - recordType: The type of record to delete /// - recordName: The unique record name /// - recordChangeTag: Optional change tag for optimistic locking + /// - zoneID: Optional target zone (defaults to the request's zone / + /// `_defaultZone` when omitted) /// - database: The CloudKit database scope to delete from (`.public`, `.private`, `.shared`) /// - Throws: CloudKitError if the operation fails public func deleteRecord( recordType: String, recordName: String, recordChangeTag: String? = nil, + zoneID: ZoneID? = nil, database: Database ) async throws(CloudKitError) { let operation = RecordOperation.delete( @@ -126,7 +139,9 @@ extension CloudKitService { recordChangeTag: recordChangeTag ) - let results = try await modifyRecords([operation], database: database) + let results = try await modifyRecords( + [operation], zoneID: zoneID, database: database + ) for result in results { // `get()` rethrows a per-record failure as `recordOperationFailed`. _ = try result.get() diff --git a/Sources/MistKit/CloudKitService/CloudKitService.swift b/Sources/MistKit/CloudKitService/CloudKitService.swift index 390c55fa5..b9dde402a 100644 --- a/Sources/MistKit/CloudKitService/CloudKitService.swift +++ b/Sources/MistKit/CloudKitService/CloudKitService.swift @@ -54,8 +54,10 @@ internal import OpenAPIRuntime /// `fetchCaller` via web-auth from one fully-populated `Credentials`. public struct CloudKitService: Sendable { // swift-format-ignore: NeverForceUnwrap + // swiftlint:disable force_unwrapping /// The base URL for CloudKit Web Services. public static let baseURL = URL(string: "https://api.apple-cloudkit.com")! + // swiftlint:enable force_unwrapping /// CloudKit's maximum number of items (records, lookups, or operations) /// accepted or returned per batch request. The auto-chunking convenience diff --git a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md index 1c5f24860..ae437f85f 100644 --- a/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md +++ b/Sources/MistKit/Documentation.docc/ConfiguringMistKit.md @@ -1,6 +1,6 @@ # Configuring MistKit -There is no single `MistKitConfiguration` type — configuration is what you pass to ``CloudKitService``: a container identifier, an ``Environment``, ``Credentials``, and (optionally) a custom transport. +MistKit itself has no configuration package dependency — you pass a container identifier, an ``Environment``, ``Credentials``, and (optionally) a custom transport to ``CloudKitService``. For reading CloudKit credentials from CLI / environment / `.env`, validating them, and building a service, use the separate [MistKitConfiguration](https://github.com/brightdigit/MistKitConfiguration) package (`CloudKitConfigurationKeys` → `validated()` → `makeCloudKitService()`). ## Overview @@ -14,6 +14,18 @@ let service = CloudKitService( ) ``` +Or, with MistKitConfiguration: + +```swift +let keys = CloudKitConfigurationKeys(defaultContainerID: "iCloud.com.example.MyApp") +let service = try ConfigurationSources.makeConfigReader( + secretCommandLineFlags: keys.secretCommandLineFlags +) +.readCloudKitConfiguration(keys: keys) +.validated() +.makeCloudKitService() +``` + Everything else — which ``Database`` to use, which signing method on the public database, which token to refresh — is decided per call. This article covers the construction-time inputs (container, environment, transport, logging). For credentials and per-call database selection, see . ## Container identifier diff --git a/Sources/MistKit/Models/ConversionError.swift b/Sources/MistKit/Models/ConversionError.swift index 5ff567ce1..303c8b32f 100644 --- a/Sources/MistKit/Models/ConversionError.swift +++ b/Sources/MistKit/Models/ConversionError.swift @@ -43,8 +43,9 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { /// A field value's structure matched no known `FieldValue` case. case unmappableFieldValue(fieldName: String, value: String, type: String?) /// A response declared a scalar `type` that the field's value cannot satisfy - /// (e.g. a `TIMESTAMP` tag over a string value). Such a response is internally - /// inconsistent and cannot be faithfully represented. + /// (e.g. a `TIMESTAMP` tag over a string value, or a `BYTES` tag over a string + /// that is not valid base64). Such a response is internally inconsistent and + /// cannot be faithfully represented. case typeValueMismatch(fieldName: String, declaredType: String, value: String) /// A list element matched no known `FieldValue` case. case unmappableListItem(fieldName: String, item: String) @@ -62,6 +63,8 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { case zoneMissingID /// A zone response was missing its `zoneName`. case zoneMissingName + /// A zone response carried an unrecognized `zoneType` wire value. + case unrecognizedZoneType(String) /// A user response was missing its `userRecordName`. case userMissingRecordName /// A subscription response was missing its `subscriptionID`. @@ -113,6 +116,8 @@ public enum ConversionError: LocalizedError, Sendable, Equatable { return "Zone entry missing zoneID" case .zoneMissingName: return "Zone entry missing zoneName" + case .unrecognizedZoneType(let wireValue): + return "Zone entry has unrecognized zoneType '\(wireValue)'" case .userMissingRecordName: return "UserResponse missing userRecordName" case .subscriptionMissingID: diff --git a/Sources/MistKit/Models/FieldValues/Asset+Download.swift b/Sources/MistKit/Models/FieldValues/Asset+Download.swift new file mode 100644 index 000000000..c8b232a24 --- /dev/null +++ b/Sources/MistKit/Models/FieldValues/Asset+Download.swift @@ -0,0 +1,97 @@ +// +// Asset+Download.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +public import Foundation + +#if canImport(FoundationNetworking) + public import FoundationNetworking +#endif + +#if !os(WASI) + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + extension Asset { + private static func requireSuccess(_ response: URLResponse) throws { + let statusCode = (response as? HTTPURLResponse)?.statusCode + guard let statusCode else { + throw CloudKitError.invalidResponse + } + guard (200...299).contains(statusCode) else { + throw CloudKitError.httpError(statusCode: statusCode) + } + } + + /// Downloads this asset's bytes. + /// + /// The bytes are **not** verified against ``fileChecksum``. That value is + /// an opaque, server-minted identifier read out of the CDN upload receipt + /// — Apple documents it only as a signature and specifies no algorithm, so + /// it cannot be recomputed from the plaintext. Check ``size`` if you need a + /// client-side guard against a truncated download. CDN `wrappingKey` + /// encryption is out of scope. + /// + /// - Parameter session: Session used for the GET. Defaults to `.shared`, + /// matching CDN asset uploads (a connection pool separate from the + /// CloudKit API transport). + /// - Returns: The response body. + /// - Throws: ``CloudKitError/missingAssetDownloadURL`` when ``downloadURL`` + /// is missing or not a valid URL; ``CloudKitError/httpError(statusCode:)`` + /// on a non-success HTTP status. + public func download(using session: URLSession = .shared) async throws -> Data { + try await download { url in + try await session.data(from: url) + } + } + + /// Testable download path that takes a fetch closure instead of a session. + /// + /// - Parameter fetching: Performs the GET for the resolved download URL. + /// - Returns: The response body. + /// - Throws: ``CloudKitError/missingAssetDownloadURL`` when ``downloadURL`` + /// is missing or not a valid URL; ``CloudKitError/httpError(statusCode:)`` + /// on a non-success HTTP status; or any error thrown by `fetching`. + internal func download( + fetching: (URL) async throws -> (Data, URLResponse) + ) async throws -> Data { + let url = try resolvedDownloadURL() + let (data, response) = try await fetching(url) + try Self.requireSuccess(response) + return data + } + + private func resolvedDownloadURL() throws -> URL { + guard let downloadURL, !downloadURL.isEmpty else { + throw CloudKitError.missingAssetDownloadURL + } + guard let url = URL(string: downloadURL), url.host != nil else { + throw CloudKitError.missingAssetDownloadURL + } + return url + } + } +#endif diff --git a/Sources/MistKit/Models/FieldValues/Asset.swift b/Sources/MistKit/Models/FieldValues/Asset.swift index e88f822b8..2dbb536ca 100644 --- a/Sources/MistKit/Models/FieldValues/Asset.swift +++ b/Sources/MistKit/Models/FieldValues/Asset.swift @@ -29,7 +29,14 @@ /// Asset dictionary as defined in CloudKit Web Services public struct Asset: Codable, Equatable, Sendable { - /// The file checksum + /// Opaque, server-minted checksum identifying the stored file. + /// + /// Apple documents this only as a signature and specifies no algorithm. It is + /// produced by the CDN upload receipt, not computed by the client, and is not + /// a digest of the plaintext bytes — observed values are a `0x01` version + /// byte plus a 20-byte digest, and it doubles as the content address in + /// ``downloadURL``. Treat it as an identity/caching token; it cannot be + /// recomputed to validate downloaded bytes. Use ``size`` for that. public let fileChecksum: String? /// The file size in bytes public let size: Int64? diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift index d2af06a45..be35b038e 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Codable.swift @@ -113,8 +113,10 @@ extension FieldValue { /// `false` when `self` is a complex case that this method did not handle. private func encodeScalar(to container: inout any SingleValueEncodingContainer) throws -> Bool { switch self { - case .string(let val), .bytes(let val): + case .string(let val): try container.encode(val) + case .bytes(let val): + try container.encode(val.base64EncodedString()) case .int64(let val): try container.encode(val) case .double(let val): diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift index 8f54fc936..7d08dced6 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+List.swift @@ -49,7 +49,7 @@ extension FieldValue { listItem: Components.Schemas.ListValuePayload, fieldName: String ) throws(ConversionError) { - if let simpleValue = Self.makeSimpleListItem(from: listItem) { + if let simpleValue = try Self.makeSimpleListItem(from: listItem, fieldName: fieldName) { self = simpleValue } else if let complexValue = try Self.makeComplexListItem(from: listItem, fieldName: fieldName) { @@ -85,7 +85,9 @@ extension FieldValue { case .DoubleValue(let doubleValue): self = .double(doubleValue) case .BytesValue(let bytesValue): - self = .bytes(bytesValue) + self = .bytes( + try Self.dataFromBase64(bytesValue, fieldName: fieldName, declaredType: "BYTES") + ) default: let failure = ConversionError.unmappableNestedListItem( fieldName: fieldName, @@ -96,8 +98,9 @@ extension FieldValue { } private static func makeSimpleListItem( - from listItem: Components.Schemas.ListValuePayload - ) -> FieldValue? { + from listItem: Components.Schemas.ListValuePayload, + fieldName: String + ) throws(ConversionError) -> FieldValue? { if case .StringValue(let strVal) = listItem { return .string(strVal) } @@ -108,7 +111,7 @@ extension FieldValue { return .double(dblVal) } if case .BytesValue(let bytesVal) = listItem { - return .bytes(bytesVal) + return .bytes(try dataFromBase64(bytesVal, fieldName: fieldName, declaredType: "BYTES")) } if case .DateValue(let dateVal) = listItem { return .date(Date(timeIntervalSince1970: dateVal / 1_000)) diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift index f8d2b024f..5699195f2 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components+Scalar.swift @@ -49,13 +49,17 @@ extension FieldValue { /// The `FieldValue` first-match-wins inference produces for this payload. Lossy for the /// ambiguous scalars: a base64 BYTES reads back as `.string`, and a whole-number - /// TIMESTAMP reads back as `.int64`. + /// TIMESTAMP reads back as `.int64`. `BytesValue` stays a wire `String`; decode to + /// `Data` here, falling back to `.string` if the payload is not valid base64. fileprivate var inferred: FieldValue { switch self { case .string(let strVal): return .string(strVal) case .bytes(let bytesVal): - return .bytes(bytesVal) + if let data = Data(base64Encoded: bytesVal) { + return .bytes(data) + } + return .string(bytesVal) case .int64(let intVal): return .int64(Int(intVal)) case .double(let dblVal): @@ -84,7 +88,9 @@ extension FieldValue { /// `BytesValue` both arrive as JSON strings. fileprivate var text: String? { switch self { - case .string(let strVal), .bytes(let strVal): + case .string(let strVal): + return strVal + case .bytes(let strVal): return strVal case .int64, .double, .date: return nil @@ -160,7 +166,8 @@ extension FieldValue { _ = try requireNumeric(value, fieldName: fieldName, declaredType: declared) return nil case .text(.bytes): - return .bytes(try requireString(value, fieldName: fieldName, declaredType: declared)) + let string = try requireString(value, fieldName: fieldName, declaredType: declared) + return .bytes(try dataFromBase64(string, fieldName: fieldName, declaredType: declared)) case .text(.string): // Validate the category, then defer to inference, which already produces `.string`. _ = try requireString(value, fieldName: fieldName, declaredType: declared) diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift index ad318b3a2..091c0e35a 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Components.swift @@ -102,6 +102,8 @@ extension FieldValue { action = .deleteSelf case .NONE: action = Reference.Action.none + case .VALIDATE: + action = .validate case nil: action = nil } @@ -184,4 +186,25 @@ extension FieldValue { ) try failure.reportAndThrow() } + + /// Decode a wire base64 string into `Data`, throwing + /// ``ConversionError/typeValueMismatch`` when the payload is not valid base64. + /// + /// `value` is the unwrapped string from ``requireString``, not the `oneOf` + /// wrapper, so callers can recover the raw payload from the error. + internal static func dataFromBase64( + _ string: String, + fieldName: String, + declaredType: String + ) throws(ConversionError) -> Data { + guard let data = Data(base64Encoded: string) else { + let failure = ConversionError.typeValueMismatch( + fieldName: fieldName, + declaredType: declaredType, + value: string + ) + try failure.reportAndThrow() + } + return data + } } diff --git a/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift b/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift index 50b54c0ed..fe09a28f9 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue+Convenience.swift @@ -85,10 +85,27 @@ extension FieldValue { return nil } - /// Extract base64-encoded bytes if this is a .bytes case + /// Extract base64-encoded bytes if this is a `.bytes` case. /// - /// - Returns: The base64 string, or nil if this is not a .bytes case + /// - Returns: The payload as a base64 string, or nil if this is not a `.bytes` case public var bytesValue: String? { + if case .bytes(let value) = self { + return value.base64EncodedString() + } + return nil + } + + /// Extract the binary payload if this is a `.bytes` case. + /// + /// Matches `.bytes` only. An untagged CloudKit `BYTES` response is claimed by + /// first-match-wins inference as `.string`, so `dataValue` returns `nil` for + /// it; the base64 text remains available via ``stringValue``. This accessor + /// does not attempt `Data(base64Encoded:)` on a `.string` payload: base64 has + /// no false-positive signal, so ordinary strings such as `"Chen"` or `"test"` + /// would decode as plausible-looking garbage. + /// + /// - Returns: The `Data` payload, or nil if this is not a `.bytes` case + public var dataValue: Data? { if case .bytes(let value) = self { return value } diff --git a/Sources/MistKit/Models/FieldValues/FieldValue.swift b/Sources/MistKit/Models/FieldValues/FieldValue.swift index 8fadacf66..1e11ad304 100644 --- a/Sources/MistKit/Models/FieldValues/FieldValue.swift +++ b/Sources/MistKit/Models/FieldValues/FieldValue.swift @@ -34,7 +34,7 @@ public enum FieldValue: Codable, Equatable, Sendable { case string(String) case int64(Int) case double(Double) - case bytes(String) // Base64-encoded string + case bytes(Data) // Binary data; base64-encoded on the wire case date(Date) // Date/time value case location(Location) case reference(Reference) diff --git a/Sources/MistKit/Models/FieldValues/Reference.swift b/Sources/MistKit/Models/FieldValues/Reference.swift index 648ee9248..7732296c8 100644 --- a/Sources/MistKit/Models/FieldValues/Reference.swift +++ b/Sources/MistKit/Models/FieldValues/Reference.swift @@ -29,15 +29,19 @@ /// Reference dictionary as defined in CloudKit Web Services public struct Reference: Codable, Equatable, Sendable { - /// Reference action types supported by CloudKit + /// Reference action types supported by CloudKit Web Services. + /// Native `CKRecord.ReferenceAction` only has `none` and `deleteSelf`; + /// `validate` is a Web Services case that verifies the target record exists + /// before creating the reference (create fails if missing). public enum Action: String, Codable, Sendable { case deleteSelf = "DELETE_SELF" case none = "NONE" + case validate = "VALIDATE" } /// The record name being referenced public let recordName: String - /// The action to take (DELETE_SELF, NONE, or nil) + /// The action to take (`NONE`, `DELETE_SELF`, `VALIDATE`, or nil) public let action: Action? /// Initialize a reference value diff --git a/Sources/MistKit/Models/Sharing/CreatedShare.swift b/Sources/MistKit/Models/Sharing/CreatedShare.swift index 423050d1d..5d06b7252 100644 --- a/Sources/MistKit/Models/Sharing/CreatedShare.swift +++ b/Sources/MistKit/Models/Sharing/CreatedShare.swift @@ -36,10 +36,12 @@ public import Foundation /// rather than on ``RecordInfo``, which models a plain record. public struct CreatedShare: Sendable { // swift-format-ignore: NeverForceUnwrap + // swiftlint:disable force_unwrapping /// Base URL for iCloud share invite links (`https://www.icloud.com/share`). /// /// Append a ``ShortGUID`` path component to build a full invite URL. public static let shareURLBase = URL(string: "https://www.icloud.com/share")! + // swiftlint:enable force_unwrapping /// The short GUID CloudKit assigned to the share (and shared root). public let shortGUID: ShortGUID diff --git a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift index 11b306696..cf9ab420b 100644 --- a/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift +++ b/Sources/MistKit/Models/Sharing/ShareRecordInfo.swift @@ -85,7 +85,11 @@ public struct ShareRecordInfo: Codable, Sendable { self.containerIdentifier = schema.containerIdentifier self.databaseScope = schema.databaseScope.map(ShareDatabaseScope.init(from:)) self.environment = schema.environment.map(Environment.init(from:)) - self.zoneID = schema.zoneID.map(ZoneID.init(from:)) + if let schemaZoneID = schema.zoneID { + self.zoneID = try ZoneID(from: schemaZoneID) + } else { + self.zoneID = nil + } self.rootRecordName = schema.rootRecordName if let rootRecord = schema.rootRecord { self.rootRecord = try RecordInfo(from: rootRecord) diff --git a/Sources/MistKit/Models/Sharing/ShareTargetReference.swift b/Sources/MistKit/Models/Sharing/ShareTargetReference.swift index 3aeda54c0..7fa94096c 100644 --- a/Sources/MistKit/Models/Sharing/ShareTargetReference.swift +++ b/Sources/MistKit/Models/Sharing/ShareTargetReference.swift @@ -45,11 +45,6 @@ public struct ShareTargetReference: Codable, Sendable, Equatable, Hashable { self.recordName = recordName self.recordChangeTag = recordChangeTag } - - internal init(from schema: Components.Schemas.ShareTargetReference) { - self.recordName = schema.recordName - self.recordChangeTag = schema.recordChangeTag - } } extension Components.Schemas.ShareTargetReference { diff --git a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift index 08481608a..a18b5afed 100644 --- a/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift +++ b/Sources/MistKit/Models/Subscriptions/SubscriptionInfo+Schema.swift @@ -138,6 +138,6 @@ extension SubscriptionInfo { guard let zoneName = payload.zoneName else { try ConversionError.zoneMissingName.reportAndThrow() } - return .zone(ZoneID(zoneName: zoneName, ownerName: payload.ownerName)) + return .zone(ZoneID(zoneName: zoneName, ownerName: payload.ownerRecordName)) } } diff --git a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift index 4acd0d764..17008eab5 100644 --- a/Sources/MistKit/Models/Zones/ZoneChangeResult.swift +++ b/Sources/MistKit/Models/Zones/ZoneChangeResult.swift @@ -45,7 +45,12 @@ extension OperationResult where Success == ZoneInfo, Target == ZoneTarget { case .ZoneFetchFailure(let failure): self = .failure(try ZoneOperationFailure(from: failure)) case .DatabaseChangedZone(let zone): - self = .success(try ZoneInfo(fromZoneID: zone.zoneID)) + self = .success( + try ZoneInfo( + fromZoneID: zone.zoneID, + deleted: zone.deleted + ) + ) } } diff --git a/Sources/MistKit/Models/Zones/ZoneID.swift b/Sources/MistKit/Models/Zones/ZoneID.swift index 9b5033339..692273e9b 100644 --- a/Sources/MistKit/Models/Zones/ZoneID.swift +++ b/Sources/MistKit/Models/Zones/ZoneID.swift @@ -42,24 +42,31 @@ public struct ZoneID: Codable, Sendable, Equatable, Hashable { public let zoneName: String /// The owner's record name (optional, nil for current user) public let ownerName: String? + /// The zone's type. + /// + /// `nil` when the server omits the key. + public let zoneType: ZoneType? /// Initialize a zone identifier /// - Parameters: /// - zoneName: The zone name /// - ownerName: Optional owner record name (nil = current user) - public init(zoneName: String, ownerName: String? = nil) { + /// - zoneType: Optional zone type from the wire payload + public init(zoneName: String, ownerName: String? = nil, zoneType: ZoneType? = nil) { self.zoneName = zoneName self.ownerName = ownerName + self.zoneType = zoneType } /// Lift a zone identifier from the wire schema. /// /// A missing `zoneName` falls back to ``defaultZone``'s name — share /// results may omit it when CloudKit only returns an owner. - internal init(from schema: Components.Schemas.ZoneID) { + internal init(from schema: Components.Schemas.ZoneID) throws(ConversionError) { self.init( zoneName: schema.zoneName ?? ZoneID.defaultZone.zoneName, - ownerName: schema.ownerName + ownerName: schema.ownerRecordName, + zoneType: try ZoneType.fromWire(schema.zoneType) ) } } @@ -69,7 +76,8 @@ extension Components.Schemas.ZoneID { internal init(from zoneID: ZoneID) { self.init( zoneName: zoneID.zoneName, - ownerName: zoneID.ownerName + ownerRecordName: zoneID.ownerName, + zoneType: zoneID.zoneType?.rawValue ) } } diff --git a/Sources/MistKit/Models/Zones/ZoneInfo.swift b/Sources/MistKit/Models/Zones/ZoneInfo.swift index 189344131..e2ffddb6f 100644 --- a/Sources/MistKit/Models/Zones/ZoneInfo.swift +++ b/Sources/MistKit/Models/Zones/ZoneInfo.swift @@ -39,6 +39,10 @@ public struct ZoneInfo: Codable, Sendable { /// Note: always empty — CloudKit Web Services zone responses do not include /// capabilities in the current OpenAPI schema. public let capabilities: [String] + /// The zone's type. + /// + /// `nil` when the server omits the key. + public let zoneType: ZoneType? /// The current point in the zone's change history. /// /// Present on zone responses that carry Apple's "Zone Dictionary" payload; @@ -49,20 +53,31 @@ public struct ZoneInfo: Codable, Sendable { /// `nil` when the server omits the key — deliberately *not* defaulted to /// `false`, so "absent" stays distinguishable from "explicitly not atomic". public let atomic: Bool? + /// When `true`, this is a tombstone entry from a zone change feed — + /// the zone was deleted and should be removed from local storage. + /// + /// `nil` when the server omits the key — deliberately *not* defaulted to + /// `false`, so list/lookup responses stay distinguishable from change-feed + /// tombstones. + public let deleted: Bool? /// Initialize zone information public init( zoneName: String, ownerRecordName: String?, capabilities: [String], + zoneType: ZoneType? = nil, syncToken: String? = nil, - atomic: Bool? = nil + atomic: Bool? = nil, + deleted: Bool? = nil ) { self.zoneName = zoneName self.ownerRecordName = ownerRecordName self.capabilities = capabilities + self.zoneType = zoneType self.syncToken = syncToken self.atomic = atomic + self.deleted = deleted } /// Convert a CloudKit zone payload's `zoneID` into a `ZoneInfo`. @@ -81,7 +96,8 @@ public struct ZoneInfo: Codable, Sendable { internal init( fromZoneID zoneID: Components.Schemas.ZoneID?, syncToken: String? = nil, - atomic: Bool? = nil + atomic: Bool? = nil, + deleted: Bool? = nil ) throws(ConversionError) { guard let zoneID else { try ConversionError.zoneMissingID.reportAndThrow() @@ -91,20 +107,23 @@ public struct ZoneInfo: Codable, Sendable { } self.init( zoneName: zoneName, - ownerRecordName: zoneID.ownerName, + ownerRecordName: zoneID.ownerRecordName, capabilities: [], + zoneType: try ZoneType.fromWire(zoneID.zoneType), syncToken: syncToken, - atomic: atomic + atomic: atomic, + deleted: deleted ) } /// Convert a CloudKit `Zone` payload into a `ZoneInfo`, carrying the - /// zone-level metadata (`syncToken`, `atomic`) alongside the identity. + /// zone-level metadata (`syncToken`, `atomic`, `deleted`) alongside the identity. internal init(from zone: Components.Schemas.Zone) throws(ConversionError) { try self.init( fromZoneID: zone.zoneID, syncToken: zone.syncToken, - atomic: zone.atomic + atomic: zone.atomic, + deleted: zone.deleted ) } } diff --git a/Sources/MistKit/Models/Zones/ZoneType.swift b/Sources/MistKit/Models/Zones/ZoneType.swift new file mode 100644 index 000000000..497543ff0 --- /dev/null +++ b/Sources/MistKit/Models/Zones/ZoneType.swift @@ -0,0 +1,57 @@ +// +// ZoneType.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +/// The zone's type as reported on the CloudKit Web Services wire. +/// +/// Live responses use exactly two values: the database default zone +/// (``defaultZone``) and every other zone (``regularCustom``). The key is +/// optional — callers may omit it on requests. +public enum ZoneType: String, Codable, Sendable, Equatable, Hashable, CaseIterable { + /// `_defaultZone` in public or private databases. + case defaultZone = "DEFAULT_ZONE" + /// User-created custom zones, Core Data mirroring zones, and shared-database zones. + case regularCustom = "REGULAR_CUSTOM_ZONE" +} + +// MARK: - Internal Conversion +extension ZoneType { + /// Maps an optional wire string to a domain value. + /// + /// `nil` stays `nil`. Any other unrecognized string throws + /// ``ConversionError/unrecognizedZoneType(_:)``. + internal static func fromWire(_ wire: String?) throws(ConversionError) -> ZoneType? { + guard let wire else { + return nil + } + guard let value = ZoneType(rawValue: wire) else { + throw ConversionError.unrecognizedZoneType(wire) + } + return value + } +} diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift index 342d7edd4..d80ca1c26 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.FieldValueRequest.swift @@ -56,7 +56,7 @@ extension Components.Schemas.FieldValueRequest { self.init(value: .DoubleValue(value), _type: .DOUBLE) case .bytes(let value): // A base64 string is otherwise indistinguishable from a STRING. - self.init(value: .BytesValue(value), _type: .BYTES) + self.init(value: .BytesValue(value.base64EncodedString()), _type: .BYTES) case .date(let value): // Tag TIMESTAMP (else inferred as INT64/DOUBLE) and round to whole milliseconds: // CloudKit rejects a fractional TIMESTAMP value (e.g. 1747999812347.89) with @@ -101,6 +101,8 @@ extension Components.Schemas.FieldValueRequest { action = .DELETE_SELF case .some(.none): action = .NONE + case .some(.validate): + action = .VALIDATE case nil: action = nil } diff --git a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift index 1fca78713..95f0c3156 100644 --- a/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift +++ b/Sources/MistKit/OpenAPI/Components/Components.Schemas.ListValuePayload.swift @@ -51,7 +51,7 @@ extension Components.Schemas.ListValuePayload { return .DoubleValue(value) } if case .bytes(let value) = fieldValue { - return .BytesValue(value) + return .BytesValue(value.base64EncodedString()) } if case .date(let value) = fieldValue { return .DateValue(value.timeIntervalSince1970 * 1_000) @@ -99,6 +99,8 @@ extension Components.Schemas.ListValuePayload { action = .DELETE_SELF case .some(.none): action = .NONE + case .some(.validate): + action = .VALIDATE case nil: action = nil } diff --git a/Sources/MistKitOpenAPI/Types.swift b/Sources/MistKitOpenAPI/Types.swift index d18e7bf7e..de76f3900 100644 --- a/Sources/MistKitOpenAPI/Types.swift +++ b/Sources/MistKitOpenAPI/Types.swift @@ -765,23 +765,35 @@ public enum Components { public struct ZoneID: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/ZoneID/zoneName`. public var zoneName: Swift.String? - /// - Remark: Generated from `#/components/schemas/ZoneID/ownerName`. - public var ownerName: Swift.String? + /// The zone owner's user record name. Use this key to identify a zone owned by another user (e.g. a shared zone). + /// + /// + /// - Remark: Generated from `#/components/schemas/ZoneID/ownerRecordName`. + public var ownerRecordName: Swift.String? + /// The zone's type. Live responses carry values such as `REGULAR_CUSTOM_ZONE` and `DEFAULT_ZONE`. + /// + /// + /// - Remark: Generated from `#/components/schemas/ZoneID/zoneType`. + public var zoneType: Swift.String? /// Creates a new `ZoneID`. /// /// - Parameters: /// - zoneName: - /// - ownerName: + /// - ownerRecordName: The zone owner's user record name. Use this key to identify a zone owned by another user (e.g. a shared zone). + /// - zoneType: The zone's type. Live responses carry values such as `REGULAR_CUSTOM_ZONE` and `DEFAULT_ZONE`. public init( zoneName: Swift.String? = nil, - ownerName: Swift.String? = nil + ownerRecordName: Swift.String? = nil, + zoneType: Swift.String? = nil ) { self.zoneName = zoneName - self.ownerName = ownerName + self.ownerRecordName = ownerRecordName + self.zoneType = zoneType } public enum CodingKeys: String, CodingKey { case zoneName - case ownerName + case ownerRecordName + case zoneType } } /// - Remark: Generated from `#/components/schemas/Filter`. @@ -1641,14 +1653,17 @@ public enum Components { /// /// - Remark: Generated from `#/components/schemas/ReferenceValue/recordName`. public var recordName: Swift.String - /// Action to perform on the referenced record + /// Action to perform on the referenced record. NONE performs no action; DELETE_SELF deletes this record when the referenced record is deleted; VALIDATE verifies the target record exists before creating the reference (create fails if missing). VALIDATE is a CloudKit Web Services value; native CKRecord.ReferenceAction has only none and deleteSelf. + /// /// /// - Remark: Generated from `#/components/schemas/ReferenceValue/action`. @frozen public enum actionPayload: String, Codable, Hashable, Sendable, CaseIterable { case NONE = "NONE" case DELETE_SELF = "DELETE_SELF" + case VALIDATE = "VALIDATE" } - /// Action to perform on the referenced record + /// Action to perform on the referenced record. NONE performs no action; DELETE_SELF deletes this record when the referenced record is deleted; VALIDATE verifies the target record exists before creating the reference (create fails if missing). VALIDATE is a CloudKit Web Services value; native CKRecord.ReferenceAction has only none and deleteSelf. + /// /// /// - Remark: Generated from `#/components/schemas/ReferenceValue/action`. public var action: Components.Schemas.ReferenceValue.actionPayload? @@ -1656,7 +1671,7 @@ public enum Components { /// /// - Parameters: /// - recordName: The record name being referenced - /// - action: Action to perform on the referenced record + /// - action: Action to perform on the referenced record. NONE performs no action; DELETE_SELF deletes this record when the referenced record is deleted; VALIDATE verifies the target record exists before creating the reference (create fails if missing). VALIDATE is a CloudKit Web Services value; native CKRecord.ReferenceAction has only none and deleteSelf. public init( recordName: Swift.String, action: Components.Schemas.ReferenceValue.actionPayload? = nil @@ -2254,7 +2269,7 @@ public enum Components { case moreComing } } - /// A record zone as returned by the zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`). Matches the "Zone Dictionary" in Apple's archived CloudKit Web Services Reference, which documents exactly three keys: `zoneID`, `syncToken`, and `atomic`. `isEager` is deliberately absent — it appears in no primary Apple source (see issue #386). + /// A record zone as returned by the zone endpoints (`zones/list`, `zones/lookup`, `zones/modify`, `zones/changes`). The archived "Zone Dictionary" documents `zoneID`, `syncToken`, and `atomic`; live change feeds also carry `deleted` (issue #444). `isEager` is deliberately absent — it appears in no primary Apple source (see issue #386). /// /// /// - Remark: Generated from `#/components/schemas/Zone`. @@ -2270,25 +2285,34 @@ public enum Components { /// /// - Remark: Generated from `#/components/schemas/Zone/atomic`. public var atomic: Swift.Bool? + /// When `true`, the zone was deleted. Present on change-feed responses (`zones/changes`); absent on list/lookup/modify success payloads. + /// + /// + /// - Remark: Generated from `#/components/schemas/Zone/deleted`. + public var deleted: Swift.Bool? /// Creates a new `Zone`. /// /// - Parameters: /// - zoneID: /// - syncToken: The current point in the zone's change history. /// - atomic: A Boolean value indicating whether this zone supports atomic operations. + /// - deleted: When `true`, the zone was deleted. Present on change-feed responses (`zones/changes`); absent on list/lookup/modify success payloads. public init( zoneID: Components.Schemas.ZoneID? = nil, syncToken: Swift.String? = nil, - atomic: Swift.Bool? = nil + atomic: Swift.Bool? = nil, + deleted: Swift.Bool? = nil ) { self.zoneID = zoneID self.syncToken = syncToken self.atomic = atomic + self.deleted = deleted } public enum CodingKeys: String, CodingKey { case zoneID case syncToken case atomic + case deleted } } /// - Remark: Generated from `#/components/schemas/ZonesListResponse`. @@ -2495,21 +2519,33 @@ public enum Components { case moreComing } } - /// A zone that changed, as returned by `changes/database`. + /// A zone that changed, as returned by `changes/database`. Carries the same tombstone shape as `zones/changes` — `deleted: true` when the zone was removed (issue #444). + /// /// /// - Remark: Generated from `#/components/schemas/DatabaseChangedZone`. public struct DatabaseChangedZone: Codable, Hashable, Sendable { /// - Remark: Generated from `#/components/schemas/DatabaseChangedZone/zoneID`. public var zoneID: Components.Schemas.ZoneID? + /// When `true`, the zone was deleted and should be removed from local storage. + /// + /// + /// - Remark: Generated from `#/components/schemas/DatabaseChangedZone/deleted`. + public var deleted: Swift.Bool? /// Creates a new `DatabaseChangedZone`. /// /// - Parameters: /// - zoneID: - public init(zoneID: Components.Schemas.ZoneID? = nil) { + /// - deleted: When `true`, the zone was deleted and should be removed from local storage. + public init( + zoneID: Components.Schemas.ZoneID? = nil, + deleted: Swift.Bool? = nil + ) { self.zoneID = zoneID + self.deleted = deleted } public enum CodingKeys: String, CodingKey { case zoneID + case deleted } } /// Per-zone error returned inline in the `zones` array of a 200 zone diff --git a/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+TokenRotation.swift b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+TokenRotation.swift new file mode 100644 index 000000000..5c356b5ba --- /dev/null +++ b/Tests/MistKitTests/Authentication/Middleware/AuthenticationMiddlewareTests+TokenRotation.swift @@ -0,0 +1,224 @@ +internal import Foundation +internal import HTTPTypes +internal import OpenAPIRuntime +internal import Testing + +@testable import MistKit + +extension AuthenticationMiddlewareTests { + @Suite("Token Rotation", .disabled(if: Platform.isWindowsSwift62)) + internal struct TokenRotation { + private static let validAPIToken = TestConstants.apiToken + private static let validWebAuthToken = TestConstants.webAuthToken + private static let rotatedWebAuthToken = + "rotatedwebauthtokenabcdef0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "abcdefghijklmnopqrstuvwxyz0123456789AB==" + private static let testOperationID = TestConstants.operationID + + private static func makeRequest() -> HTTPRequest { + HTTPRequest( + method: .get, + scheme: "https", + authority: "api.apple-cloudkit.com", + path: "/database/1/iCloud.com.example.app/private/records/query" + ) + } + + private static func responseWithRotatedToken(_ token: String) -> HTTPResponse { + var response = HTTPResponse(status: .ok) + response.headerFields[.cloudKitWebAuthToken] = token + return response + } + + @Test("Middleware forwards rotated token to token manager") + internal func middlewareForwardsRotatedToken() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let mockManager = MockTokenManagerWithRotation() + let middleware = AuthenticationMiddleware(tokenManager: mockManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil) + } + ) + + let received = await mockManager.receivedRotatedTokens + #expect(received == [Self.rotatedWebAuthToken]) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("Middleware skips rotation hook when header is absent") + internal func middlewareSkipsRotationWithoutHeader() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let mockManager = MockTokenManagerWithRotation() + let middleware = AuthenticationMiddleware(tokenManager: mockManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (HTTPResponse(status: .ok), nil) + } + ) + + let received = await mockManager.receivedRotatedTokens + #expect(received.isEmpty) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("Middleware returns response when rotation adoption fails") + internal func middlewareReturnsResponseWhenRotationFails() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let mockManager = MockTokenManagerWithRotationFailure() + let middleware = AuthenticationMiddleware(tokenManager: mockManager) + + let (response, _) = try await RotatedWebAuthTokenFailureReporter.$assertionHandler + .withValue( + { _ in }, + operation: { + try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken("short"), nil) + } + ) + } + ) + + #expect(response.status == .ok) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("WebAuthTokenManager adopts rotated token from middleware") + internal func webAuthTokenManagerAdoptsRotatedToken() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = WebAuthTokenManager( + apiToken: Self.validAPIToken, + webAuthToken: Self.validWebAuthToken + ) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil) + } + ) + + #expect(await tokenManager.webAuthToken == Self.rotatedWebAuthToken) + let authenticator = try await tokenManager.currentAuthenticator() + let web = try #require(authenticator as? WebAuthTokenAuthenticator) + #expect(web.webAuthToken == Self.rotatedWebAuthToken) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("AdaptiveTokenManager adopts rotated token from middleware") + internal func adaptiveTokenManagerAdoptsRotatedToken() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = AdaptiveTokenManager(apiToken: Self.validAPIToken) + try await tokenManager.upgradeToWebAuthentication(webAuthToken: Self.validWebAuthToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil) + } + ) + + #expect(await tokenManager.webAuthToken == Self.rotatedWebAuthToken) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("APITokenManager ignores rotated token via default implementation") + internal func apiTokenManagerIgnoresRotation() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = APITokenManager(apiToken: Self.validAPIToken) + try await tokenManager.didReceiveRotatedWebAuthToken(Self.rotatedWebAuthToken) + + let authenticator = try await tokenManager.currentAuthenticator() + #expect(authenticator is APITokenAuthenticator) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("AdaptiveTokenManager ignores rotation before web auth upgrade") + internal func adaptiveTokenManagerIgnoresRotationWithoutWebAuth() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = AdaptiveTokenManager(apiToken: Self.validAPIToken) + try await tokenManager.didReceiveRotatedWebAuthToken(Self.rotatedWebAuthToken) + + #expect(await tokenManager.webAuthToken == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("AdaptiveTokenManager ignores rotation from middleware before web auth upgrade") + internal func adaptiveTokenManagerIgnoresMiddlewareRotationWithoutWebAuth() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let tokenManager = AdaptiveTokenManager(apiToken: Self.validAPIToken) + let middleware = AuthenticationMiddleware(tokenManager: tokenManager) + + _ = try await middleware.intercept( + Self.makeRequest(), + body: nil as HTTPBody?, + baseURL: CloudKitService.baseURL, + operationID: Self.testOperationID, + next: { _, _, _ in + (Self.responseWithRotatedToken(Self.rotatedWebAuthToken), nil) + } + ) + + #expect(await tokenManager.webAuthToken == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("AdaptiveTokenManager persists rotated token to storage") + internal func adaptiveTokenManagerPersistsRotatedTokenToStorage() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let storage = InMemoryTokenStorage() + let tokenManager = AdaptiveTokenManager( + apiToken: Self.validAPIToken, + storage: storage + ) + try await tokenManager.upgradeToWebAuthentication(webAuthToken: Self.validWebAuthToken) + try await tokenManager.didReceiveRotatedWebAuthToken(Self.rotatedWebAuthToken) + + let stored = try await storage.retrieve(identifier: Self.validAPIToken) + let web = try #require(stored as? WebAuthTokenAuthenticator) + #expect(web.webAuthToken == Self.rotatedWebAuthToken) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + } +} diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift index 50958e94a..e34a54cea 100644 --- a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Basic.swift @@ -22,26 +22,26 @@ extension WebAuthTokenManagerTests { /// Tests WebAuthTokenManager initialization with valid tokens @Test("WebAuthTokenManager initialization with valid tokens") - internal func initializationWithValidTokens() { + internal func initializationWithValidTokens() async { let manager = WebAuthTokenManager( apiToken: Self.validAPIToken, webAuthToken: Self.validWebAuthToken ) - #expect(manager.apiToken == Self.validAPIToken) - #expect(manager.webAuthToken == Self.validWebAuthToken) + #expect(await manager.apiToken == Self.validAPIToken) + #expect(await manager.webAuthToken == Self.validWebAuthToken) } /// Tests WebAuthTokenManager initialization with storage @Test("WebAuthTokenManager initialization with storage") - internal func initializationWithStorage() { + internal func initializationWithStorage() async { let manager = WebAuthTokenManager( apiToken: Self.validAPIToken, webAuthToken: Self.validWebAuthToken ) - #expect(manager.apiToken == Self.validAPIToken) - #expect(manager.webAuthToken == Self.validWebAuthToken) + #expect(await manager.apiToken == Self.validAPIToken) + #expect(await manager.webAuthToken == Self.validWebAuthToken) } // MARK: - TokenManager Protocol Tests diff --git a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift index c433f3ae0..4838b3615 100644 --- a/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift +++ b/Tests/MistKitTests/Authentication/WebAuth/WebAuthTokenManagerTests+Performance.swift @@ -25,8 +25,11 @@ extension WebAuthTokenManagerTests { let startTime = Date() + // WASM cooperative executor traps under heavy actor churn; keep a smoke count. + let iterations = Platform.isWasm ? 5 : 1_000 + // Perform many operations - for _ in 0..<1_000 { + for _ in 0.. [String: Any] { [ - "zoneID": ["zoneName": zoneName, "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": zoneName, "ownerRecordName": "_defaultOwner"], "serverErrorCode": serverErrorCode, "reason": reason, ] diff --git a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+PaginationLimits.swift b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+PaginationLimits.swift index 46eb20866..a0e75c5b6 100644 --- a/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+PaginationLimits.swift +++ b/Tests/MistKitTests/CloudKitService/FetchRecordZoneChanges/CloudKitServiceTests.FetchRecordZoneChanges+PaginationLimits.swift @@ -113,7 +113,7 @@ extension CloudKitServiceTests.FetchRecordZoneChanges { database: .private ) Issue.record("expected .paginationLimitExceeded") - } catch let error as CloudKitError { + } catch { guard case .paginationLimitExceeded(let maxPages, let records) = error else { Issue.record("expected .paginationLimitExceeded, got \(error)") return diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+DeprecatedAPI.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+DeprecatedAPI.swift new file mode 100644 index 000000000..31447b72e --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+DeprecatedAPI.swift @@ -0,0 +1,83 @@ +// +// CloudKitServiceTests.FetchZoneChanges+DeprecatedAPI.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation + +@testable import MistKit + +/// Non-deprecated protocol surface so FetchZoneChanges tests can call the +/// Apple-deprecated `zones/changes` wrappers without emitting +/// `DeprecatedDeclaration` warnings (Swift Testing forbids `@available` on +/// `@Suite`/`@Test`, so call-site silencing isn't available there). +internal protocol FetchZoneChangesAPI: Sendable { + func fetchZoneChanges( + syncToken: String?, + database: Database + ) async throws(CloudKitError) -> ZoneChangesResult + + func fetchAllZoneChanges( + syncToken: String?, + maxPages: Int, + database: Database + ) async throws(CloudKitError) -> (zones: [ZoneInfo], syncToken: String?) +} + +extension CloudKitService: FetchZoneChangesAPI {} + +extension CloudKitServiceTests.FetchZoneChanges { + /// Invokes ``CloudKitService/fetchZoneChanges(syncToken:database:)`` via + /// ``FetchZoneChangesAPI`` so the deprecated concrete symbol isn't named at + /// the test call site. + internal static func fetchZoneChanges( + _ service: CloudKitService, + syncToken: String? = nil, + database: Database = .private + ) async throws(CloudKitError) -> ZoneChangesResult { + try await (service as any FetchZoneChangesAPI).fetchZoneChanges( + syncToken: syncToken, + database: database + ) + } + + /// Invokes ``CloudKitService/fetchAllZoneChanges(syncToken:maxPages:database:)`` + /// via ``FetchZoneChangesAPI`` so the deprecated concrete symbol isn't named + /// at the test call site. + internal static func fetchAllZoneChanges( + _ service: CloudKitService, + syncToken: String? = nil, + maxPages: Int = 1_000, + database: Database = .private + ) async throws(CloudKitError) -> (zones: [ZoneInfo], syncToken: String?) { + try await (service as any FetchZoneChangesAPI).fetchAllZoneChanges( + syncToken: syncToken, + maxPages: maxPages, + database: database + ) + } +} diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift index 611f3b959..188cab0fd 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+ErrorHandling.swift @@ -51,7 +51,10 @@ extension CloudKitServiceTests.FetchZoneChanges { let service = try CloudKitServiceTests.makeService(provider: provider) await #expect { - _ = try await service.fetchZoneChanges(syncToken: "garbage-token") + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + syncToken: "garbage-token" + ) } throws: { error in guard let ckError = error as? CloudKitError, case .badRequest(let reason) = ckError @@ -78,7 +81,10 @@ extension CloudKitServiceTests.FetchZoneChanges { let service = try CloudKitServiceTests.makeService(provider: provider) await #expect { - _ = try await service.fetchZoneChanges(syncToken: "expired-token") + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + syncToken: "expired-token" + ) } throws: { error in guard let ckError = error as? CloudKitError, case .badRequest(let reason) = ckError @@ -98,7 +104,7 @@ extension CloudKitServiceTests.FetchZoneChanges { ) await #expect { - _ = try await service.fetchZoneChanges() + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges(service) } throws: { error in guard let ckError = error as? CloudKitError, case .networkError(let urlError) = ckError diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift index cab4cd154..81581969d 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Helpers.swift @@ -95,7 +95,7 @@ extension ResponseConfig { zones.append([ "zoneID": [ "zoneName": "test-zone-\(index)", - "ownerName": "_defaultOwner", + "ownerRecordName": "_defaultOwner", ] ]) } @@ -143,7 +143,7 @@ extension ResponseConfig { { "zoneID": { "zoneName": "valid-zone", - "ownerName": "_defaultOwner" + "ownerRecordName": "_defaultOwner" } }, {} diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift index c76b52d37..03e685341 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+SuccessCases.swift @@ -46,7 +46,10 @@ extension CloudKitServiceTests.FetchZoneChanges { syncToken: "zone-token-xyz" ) - let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) #expect(result.zones.count == 2) #expect(result.syncToken == "zone-token-xyz") @@ -62,7 +65,10 @@ extension CloudKitServiceTests.FetchZoneChanges { zoneCount: 1 ) - let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) #expect(result.zones.first?.zoneName == "test-zone-0") } @@ -77,7 +83,10 @@ extension CloudKitServiceTests.FetchZoneChanges { zoneCount: 0 ) - let result = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) #expect(result.zones.isEmpty) #expect(result.syncToken != nil) @@ -94,7 +103,8 @@ extension CloudKitServiceTests.FetchZoneChanges { syncToken: "new-token" ) - let result = try await service.fetchZoneChanges( + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, syncToken: "previous-token", database: .public(.prefers(.serverToServer)) ) @@ -124,7 +134,10 @@ extension CloudKitServiceTests.FetchZoneChanges { { _, _, _ in }, operation: { await #expect(throws: CloudKitError.self) { - _ = try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) } } ) diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift index 32ac39e9a..aa54b91e9 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+Validation.swift @@ -44,7 +44,10 @@ extension CloudKitServiceTests.FetchZoneChanges { let service = try await CloudKitServiceTests.FetchZoneChanges.makeAuthErrorService() await #expect(throws: CloudKitError.self) { - try await service.fetchZoneChanges(database: .public(.prefers(.serverToServer))) + try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .public(.prefers(.serverToServer)) + ) } } } diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift index 8d62d86a2..88c51c2df 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges+WireFormat.swift @@ -79,7 +79,11 @@ extension CloudKitServiceTests.FetchZoneChanges { let provider = try ResponseProvider.successfulFetchZoneChanges(zoneCount: 1) let service = try Self.makeService(provider: provider) - _ = try await service.fetchZoneChanges(syncToken: "baseline-token", database: .private) + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + syncToken: "baseline-token", + database: .private + ) let sent = try await Self.sentBodies(provider) #expect(sent.count == 1) @@ -97,7 +101,7 @@ extension CloudKitServiceTests.FetchZoneChanges { // The decoy `syncToken` is what MistKit used to read; the live container // never sends it. let provider = ResponseProvider( - defaultResponse: try .zoneChangesRawResponse( + defaultResponse: .zoneChangesRawResponse( body: """ { "zones": [], @@ -110,7 +114,10 @@ extension CloudKitServiceTests.FetchZoneChanges { ) let service = try Self.makeService(provider: provider) - let result = try await service.fetchZoneChanges(database: .private) + let result = try await CloudKitServiceTests.FetchZoneChanges.fetchZoneChanges( + service, + database: .private + ) #expect(result.syncToken == "real-token") } @@ -136,7 +143,10 @@ extension CloudKitServiceTests.FetchZoneChanges { ) let service = try Self.makeService(provider: provider) - _ = try await service.fetchAllZoneChanges(database: .private) + _ = try await CloudKitServiceTests.FetchZoneChanges.fetchAllZoneChanges( + service, + database: .private + ) let sent = try await Self.sentBodies(provider) #expect(sent.count == 2) diff --git a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.SuccessCases+Pagination.swift b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.SuccessCases+Pagination.swift index 864534d16..22ebad1f9 100644 --- a/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.SuccessCases+Pagination.swift +++ b/Tests/MistKitTests/CloudKitService/FetchZoneChanges/CloudKitServiceTests.FetchZoneChanges.SuccessCases+Pagination.swift @@ -103,7 +103,10 @@ extension CloudKitServiceTests.FetchZoneChanges.SuccessCases { (zoneCount: 3, syncToken: "token-2"), ]) - let (zones, token) = try await service.fetchAllZoneChanges(database: .private) + let (zones, token) = try await CloudKitServiceTests.FetchZoneChanges.fetchAllZoneChanges( + service, + database: .private + ) #expect(zones.count == 3) #expect(token == "token-2") @@ -121,7 +124,10 @@ extension CloudKitServiceTests.FetchZoneChanges.SuccessCases { (zoneCount: 2, syncToken: "token-3"), ]) - let (zones, token) = try await service.fetchAllZoneChanges(database: .private) + let (zones, token) = try await CloudKitServiceTests.FetchZoneChanges.fetchAllZoneChanges( + service, + database: .private + ) #expect(zones.count == 7) #expect(token == "token-3") @@ -137,7 +143,8 @@ extension CloudKitServiceTests.FetchZoneChanges.SuccessCases { syncToken: "stuck-token" ) - let (zones, token) = try await service.fetchAllZoneChanges( + let (zones, token) = try await CloudKitServiceTests.FetchZoneChanges.fetchAllZoneChanges( + service, syncToken: "stuck-token", database: .private ) diff --git a/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift b/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift index 53e622fe4..63553cf44 100644 --- a/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/LookupZones/CloudKitServiceTests.LookupZones+Helpers.swift @@ -53,8 +53,8 @@ extension CloudKitServiceTests.LookupZones { let responseJSON = """ { "zones": [ - { "zoneID": { "zoneName": "valid-zone", "ownerName": "_defaultOwner" } }, - { "zoneID": { "ownerName": "_defaultOwner" } } + { "zoneID": { "zoneName": "valid-zone", "ownerRecordName": "_defaultOwner" } }, + { "zoneID": { "ownerRecordName": "_defaultOwner" } } ] } """ @@ -90,7 +90,7 @@ extension ResponseConfig { zones.append([ "zoneID": [ "zoneName": "test-zone-\(index)", - "ownerName": "_defaultOwner", + "ownerRecordName": "_defaultOwner", ] ]) } diff --git a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift index 03ab4e08b..ce410fe55 100644 --- a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+ErrorHandling.swift @@ -44,9 +44,9 @@ extension CloudKitServiceTests.ModifyZones { return } let service = try Harness.makeService(zones: [ - ["zoneID": ["zoneName": "good-zone", "ownerName": "_defaultOwner"]], + ["zoneID": ["zoneName": "good-zone", "ownerRecordName": "_defaultOwner"]], [ - "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "bad-zone", "ownerRecordName": "_defaultOwner"], "serverErrorCode": "ZONE_NOT_FOUND", "reason": "Zone does not exist", ], @@ -77,7 +77,7 @@ extension CloudKitServiceTests.ModifyZones { } let service = try Harness.makeService(zones: [ [ - "zoneID": ["zoneName": "bad-zone", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "bad-zone", "ownerRecordName": "_defaultOwner"], "serverErrorCode": "ZONE_NOT_FOUND", ] ]) @@ -91,7 +91,7 @@ extension CloudKitServiceTests.ModifyZones { do { _ = try entry.get() Issue.record("expected .zoneOperationFailed") - } catch let error as CloudKitError { + } catch { guard case .zoneOperationFailed(let failure) = error else { Issue.record("expected .zoneOperationFailed, got \(error)") return @@ -108,7 +108,7 @@ extension CloudKitServiceTests.ModifyZones { } let service = try Harness.makeService(zones: [ [ - "zoneID": ["zoneName": "good-zone", "ownerName": "_defaultOwner"], + "zoneID": ["zoneName": "good-zone", "ownerRecordName": "_defaultOwner"], "syncToken": "zone-token", "atomic": true, ] diff --git a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift index b9b6b51ca..93e63a113 100644 --- a/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/ModifyZones/CloudKitServiceTests.ModifyZones+Helpers.swift @@ -81,7 +81,7 @@ extension ResponseConfig { [ "zoneID": [ "zoneName": "modified-zone-\(index)", - "ownerName": "_defaultOwner", + "ownerRecordName": "_defaultOwner", ] ] } diff --git a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift index bc24f40a1..61a38c6f5 100644 --- a/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift +++ b/Tests/MistKitTests/CloudKitService/Query/CloudKitServiceTests.Query+ZoneID.swift @@ -94,7 +94,7 @@ extension CloudKitServiceTests.Query { let body = try await Self.sentBody(for: "queryRecords", from: provider) let zoneID = try #require(body["zoneID"] as? [String: Any]) #expect(zoneID["zoneName"] as? String == "CustomZone") - #expect(zoneID["ownerName"] == nil) + #expect(zoneID["ownerRecordName"] == nil) } @Test("queryRecords() forwards a shared zone's ownerName") @@ -111,7 +111,7 @@ extension CloudKitServiceTests.Query { let body = try await Self.sentBody(for: "queryRecords", from: provider) let zoneID = try #require(body["zoneID"] as? [String: Any]) #expect(zoneID["zoneName"] as? String == "SharedZone") - #expect(zoneID["ownerName"] as? String == "_owner-record-name") + #expect(zoneID["ownerRecordName"] as? String == "_owner-record-name") } @Test("queryRecords() forwards ZoneID.defaultZone explicitly when asked") @@ -156,7 +156,7 @@ extension CloudKitServiceTests.Query { let body = try await Self.sentBody(for: "queryRecords", from: provider, at: index) let zoneID = try #require(body["zoneID"] as? [String: Any]) #expect(zoneID["zoneName"] as? String == "CustomZone") - #expect(zoneID["ownerName"] as? String == "_owner-record-name") + #expect(zoneID["ownerRecordName"] as? String == "_owner-record-name") } } diff --git a/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift new file mode 100644 index 000000000..12d34277f --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/RecordWrite/CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift @@ -0,0 +1,144 @@ +// +// CloudKitServiceTests.RecordWriteConvenience+ZoneID.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.RecordWriteConvenience { + /// Pins zoneID forwarding on the single-record write conveniences (#454). + @Suite("Record Write Convenience ZoneID", .disabled(if: Platform.isWindowsSwift62)) + internal struct ZoneIDForwarding { + private typealias Helper = CloudKitServiceTests.Rereference + + @Test("createRecord forwards zoneID into the modifyRecords request body") + internal func createForwardsZoneID() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-1") + ]) + ]) + + _ = try await service.createRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Hello")], + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) + + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("updateRecord forwards zoneID into the modifyRecords request body") + internal func updateForwardsZoneID() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + ]) + ]) + + _ = try await service.updateRecord( + recordType: "Note", + recordName: "note-1", + fields: ["title": .string("Renamed")], + recordChangeTag: "tag-1", + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) + + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("deleteRecord forwards zoneID into the modifyRecords request body") + internal func deleteForwardsZoneID() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let (service, provider) = try Helper.makeServiceWithProvider(responsesByOperation: [ + "modifyRecords": try Helper.recordsResponse([ + Helper.noteRecord(recordName: "note-1", changeTag: "tag-2") + ]) + ]) + + try await service.deleteRecord( + recordType: "Note", + recordName: "note-1", + recordChangeTag: "tag-1", + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + database: Helper.publicDatabase + ) + + let bodies = await provider.bodies(for: "modifyRecords").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift index 426164928..da13cf416 100644 --- a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+ForwardCompatibility.swift @@ -111,7 +111,7 @@ extension CloudKitServiceTests.ServerErrorCodes { database: .public(.prefers(.serverToServer)) ) Issue.record("expected queryRecords to throw") - } catch let error as CloudKitError { + } catch { #expect( error.serverErrorCode == nil, "an unmodelled code must not be mistaken for a modelled one" diff --git a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift index 6a1f82c7c..381a70ced 100644 --- a/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift +++ b/Tests/MistKitTests/CloudKitService/ServerErrorCodes/CloudKitServiceTests.ServerErrorCodes+Roundtrip.swift @@ -59,7 +59,7 @@ extension CloudKitServiceTests.ServerErrorCodes { database: .public(.prefers(.serverToServer)) ) Issue.record("expected queryRecords to throw for \(expectation.code)") - } catch let error as CloudKitError { + } catch { #expect( CloudKitServiceTests.ServerErrorCodes.caseLabel(of: error) == expectation.caseLabel ) diff --git a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift index cc3ea4705..d31292ec3 100644 --- a/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/Sharing/CloudKitServiceTests.Sharing+Helpers.swift @@ -88,7 +88,7 @@ extension CloudKitServiceTests.Sharing { "containerIdentifier": TestConstants.serviceContainerIdentifier, "databaseScope": "SHARED", "environment": "development", - "zoneID": ["zoneName": "SharedZone", "ownerName": "_owner"], + "zoneID": ["zoneName": "SharedZone", "ownerRecordName": "_owner"], "rootRecordName": "root-\(value)", "share": shareRecord(for: value), "ownerIdentity": [ diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift index 690f194ef..1280d8198 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+FailureCases.swift @@ -117,7 +117,7 @@ extension CloudKitServiceTests.Subscriptions { database: Self.database ) Issue.record("expected createSubscription to throw") - } catch let error as CloudKitError { + } catch { guard case .subscriptionOperationFailed(let failure) = error else { Issue.record("expected .subscriptionOperationFailed, got \(error)") return diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift index 316e9e0bf..f99e9f9ad 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+Helpers.swift @@ -47,7 +47,7 @@ extension CloudKitServiceTests.Subscriptions { { "subscriptionID": "zone-sub", "subscriptionType": "zone", - "zoneID": { "zoneName": "Photos", "ownerName": "_defaultOwner" }, + "zoneID": { "zoneName": "Photos", "ownerRecordName": "_defaultOwner" }, "firesOn": ["create", "update", "delete"] } ] diff --git a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift index a59601f7a..18b460124 100644 --- a/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift +++ b/Tests/MistKitTests/CloudKitService/Subscriptions/CloudKitServiceTests.Subscriptions+LikelyDuplicateCases.swift @@ -74,7 +74,7 @@ extension CloudKitServiceTests.Subscriptions { database: Self.database ) Issue.record("expected createSubscription to throw") - } catch let error as CloudKitError { + } catch { guard case .subscriptionLikelyDuplicate(let failure) = error else { Issue.record("expected .subscriptionLikelyDuplicate, got \(error)") return diff --git a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift index 74f76705c..10e3acf92 100644 --- a/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift +++ b/Tests/MistKitTests/CloudKitService/Tokens/CloudKitServiceTests.Tokens+FailureCases.swift @@ -62,7 +62,7 @@ extension CloudKitServiceTests.Tokens { database: Self.database ) Issue.record("expected createAPNsToken to throw") - } catch let error as CloudKitError { + } catch { guard case .badRequest(let reason) = error else { Issue.record("expected .badRequest, got \(error)") return @@ -88,7 +88,7 @@ extension CloudKitServiceTests.Tokens { database: Self.database ) Issue.record("expected createAPNsToken to throw") - } catch let error as CloudKitError { + } catch { guard case .authenticationFailed = error else { Issue.record("expected .authenticationFailed, got \(error)") return @@ -116,7 +116,7 @@ extension CloudKitServiceTests.Tokens { database: Self.database ) Issue.record("expected registerAPNsToken to throw") - } catch let error as CloudKitError { + } catch { guard case .badRequest = error else { Issue.record("expected .badRequest, got \(error)") return diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift index e76a10e5b..26881607e 100644 --- a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+ErrorHandling.swift @@ -52,7 +52,7 @@ extension CloudKitServiceTests.Upload { database: .public(.prefers(.serverToServer)) ) Issue.record("Expected authentication error") - } catch let error as CloudKitError { + } catch { if case .authenticationFailed(let reason) = error { #expect(error.httpStatusCode == 401, "Should return 401 Unauthorized") #expect(error.serverErrorCode == "AUTHENTICATION_FAILED") @@ -60,8 +60,6 @@ extension CloudKitServiceTests.Upload { } else { Issue.record("Expected authenticationFailed error, got \(error)") } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") } } @@ -82,14 +80,12 @@ extension CloudKitServiceTests.Upload { database: .public(.prefers(.serverToServer)) ) Issue.record("Expected bad request error") - } catch let error as CloudKitError { + } catch { if case .badRequest = error { // expected } else { Issue.record("Expected .badRequest error, got \(error)") } - } catch { - Issue.record("Expected CloudKitError, got \(type(of: error))") } } } diff --git a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift index e7c306923..933d0b51e 100644 --- a/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift +++ b/Tests/MistKitTests/CloudKitService/Upload/CloudKitServiceTests.Upload+SuccessCases.swift @@ -161,5 +161,40 @@ extension CloudKitServiceTests.Upload { let count = await tracker.callCount #expect(count == 1, "Custom uploader should have been called exactly once") } + + @Test("uploadAssets() forwards zoneID into the uploadAssets request body") + internal func uploadAssetsForwardsZoneID() async throws { + guard #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let responseProvider = ResponseProvider( + defaultResponse: .successfulUploadResponse(tokenCount: 1) + ) + let transport = MockTransport(responseProvider: responseProvider) + let service = try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials(apiAuth: APICredentials(apiToken: TestConstants.apiToken)), + transport: transport + ) + + _ = try await service.uploadAssets( + data: Data(count: 256), + recordType: "Note", + fieldName: "image", + zoneID: ZoneID(zoneName: "Articles", ownerName: "_abc123"), + using: CloudKitServiceTests.Upload.makeMockAssetUploader(), + database: .public(.prefers(.serverToServer)) + ) + + let bodies = await responseProvider.bodies(for: "uploadAssets").compactMap { $0 } + let data = try #require(bodies.first) + let body = try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["zoneName"] as? String == "Articles") + #expect(zoneID["ownerRecordName"] as? String == "_abc123") + } } } diff --git a/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift new file mode 100644 index 000000000..391faca4b --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift @@ -0,0 +1,123 @@ +// +// CloudKitServiceTests.ZoneOwnerWireKey+WireFormat.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension CloudKitServiceTests.ZoneOwnerWireKey { + /// Pins the on-the-wire owner key inside `zoneID` objects (issue #444). + /// + /// Apple's Zone ID Dictionary and live responses use `ownerRecordName`, not + /// the mistaken `ownerName` key MistKit previously emitted. + @Suite("ZoneID Wire Format", .disabled(if: Platform.isWindowsSwift62)) + internal struct WireFormat { + private static let database: Database = .private + + private static func makeService( + _ provider: ResponseProvider + ) throws -> CloudKitService { + try CloudKitService( + containerIdentifier: TestConstants.serviceContainerIdentifier, + credentials: Credentials( + apiAuth: APICredentials( + apiToken: TestConstants.apiToken, + webAuthToken: TestConstants.webAuthToken + ) + ), + transport: MockTransport(responseProvider: provider) + ) + } + + private static func sentBody( + for operationID: String, + from provider: ResponseProvider, + at index: Int = 0 + ) async throws -> [String: Any] { + let bodies = await provider.bodies(for: operationID).compactMap { $0 } + let data = try #require(bodies.dropFirst(index).first) + return try #require( + try JSONSerialization.jsonObject(with: data) as? [String: Any] + ) + } + + @Test("queryRecords() encodes ownerRecordName, never ownerName") + internal func queryEncodesOwnerRecordName() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = ResponseProvider.successfulQuery() + let service = try Self.makeService(provider) + + _ = try await service.queryRecords( + MistKit.Query(recordType: "TestRecord"), + zoneID: ZoneID(zoneName: "SharedZone", ownerName: "_owner-record-name"), + database: Self.database + ) + + let body = try await Self.sentBody(for: "queryRecords", from: provider) + let zoneID = try #require(body["zoneID"] as? [String: Any]) + #expect(zoneID["ownerRecordName"] as? String == "_owner-record-name") + #expect(zoneID["ownerName"] == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("modifyZones() encodes ownerRecordName inside each operation's zoneID") + internal func modifyZonesEncodesOwnerRecordName() async throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("CloudKitService is not available on this operating system.") + return + } + let provider = try ResponseProvider.successfulModifyZones(zoneCount: 1) + let service = try Self.makeService(provider) + + _ = try await service.modifyZones( + [.create(ZoneID(zoneName: "Shared", ownerName: "other-user"))], + database: Self.database + ) + + let body = try await Self.sentBody(for: "modifyZones", from: provider) + let operations = try #require(body["operations"] as? [[String: Any]]) + let operation = try #require(operations.first) + let zone = try #require(operation["zone"] as? [String: Any]) + let zoneID = try #require(zone["zoneID"] as? [String: Any]) + #expect(zoneID["ownerRecordName"] as? String == "other-user") + #expect(zoneID["ownerName"] == nil) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + } +} diff --git a/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey.swift b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey.swift new file mode 100644 index 000000000..4c43e5c18 --- /dev/null +++ b/Tests/MistKitTests/CloudKitService/Zones/CloudKitServiceTests.ZoneOwnerWireKey.swift @@ -0,0 +1,32 @@ +// +// CloudKitServiceTests.ZoneOwnerWireKey.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +extension CloudKitServiceTests { + internal enum ZoneOwnerWireKey {} +} diff --git a/Tests/MistKitTests/Helpers/Platform.swift b/Tests/MistKitTests/Helpers/Platform.swift index d64b75b07..d9487b07d 100644 --- a/Tests/MistKitTests/Helpers/Platform.swift +++ b/Tests/MistKitTests/Helpers/Platform.swift @@ -21,4 +21,15 @@ internal enum Platform { return false #endif }() + + /// True only on Windows × Swift 6.2, which silently aborts emitting + /// MistKitTests past a tip-over size (no `error:` / stack dump). + /// Prefer compile-time body `#if !(os(Windows) && compiler(>=6.2) && compiler(<6.3))` + /// (with `Issue.record` in `#else`) so tip-over IR is omitted from emit; this + /// flag pairs with `.disabled(if:)` so Windows 6.2 does not fail at runtime. + #if os(Windows) && compiler(>=6.2) && compiler(<6.3) + internal static let isWindowsSwift62 = true + #else + internal static let isWindowsSwift62 = false + #endif } diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotation.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotation.swift new file mode 100644 index 000000000..458e7e375 --- /dev/null +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotation.swift @@ -0,0 +1,47 @@ +// +// MockTokenManagerWithRotation.swift +// MistKit +// + +internal import Foundation +internal import HTTPTypes +internal import OpenAPIRuntime + +@testable import MistKit + +/// Mock TokenManager that records rotated web auth tokens from responses. +internal final class MockTokenManagerWithRotation: TokenManager { + private actor State { + private var receivedTokens: [String] = [] + + func append(_ token: String) { + receivedTokens.append(token) + } + + func tokens() -> [String] { + receivedTokens + } + } + + private let state = State() + + internal var hasCredentials: Bool { + get async { true } + } + + internal var receivedRotatedTokens: [String] { + get async { await state.tokens() } + } + + internal func validateCredentials() async throws(TokenManagerError) -> Bool { + true + } + + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + try APITokenAuthenticator(token: TestConstants.apiToken) + } + + internal func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + await state.append(token) + } +} diff --git a/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotationFailure.swift b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotationFailure.swift new file mode 100644 index 000000000..89a81b68f --- /dev/null +++ b/Tests/MistKitTests/Mocks/TokenManagers/MockTokenManagerWithRotationFailure.swift @@ -0,0 +1,27 @@ +// +// MockTokenManagerWithRotationFailure.swift +// MistKit +// + +internal import Foundation + +@testable import MistKit + +/// Mock TokenManager that fails when adopting a rotated web auth token. +internal final class MockTokenManagerWithRotationFailure: TokenManager { + internal var hasCredentials: Bool { + get async { true } + } + + internal func validateCredentials() async throws(TokenManagerError) -> Bool { + true + } + + internal func currentAuthenticator() async throws(TokenManagerError) -> (any Authenticator)? { + try APITokenAuthenticator(token: TestConstants.apiToken) + } + + internal func didReceiveRotatedWebAuthToken(_ token: String) async throws(TokenManagerError) { + throw TokenManagerError.invalidCredentials(.webAuthTokenTooShort) + } +} diff --git a/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift b/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift new file mode 100644 index 000000000..1e518a2aa --- /dev/null +++ b/Tests/MistKitTests/Models/FieldValues/AssetDownloadTests.swift @@ -0,0 +1,139 @@ +// +// AssetDownloadTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +#if !os(WASI) + internal import Foundation + internal import Testing + + @testable import MistKit + + #if canImport(FoundationNetworking) + internal import FoundationNetworking + #endif + + @Suite("Asset Download") + internal struct AssetDownloadTests { + private static let plaintext = Data("hello".utf8) + private static let opaqueChecksum = "AUStEc+gPyq1KTFbGO3RbXVpusut" + private static let downloadURLString = "https://cvws.icloud-content.com/asset.bin" + + private static func httpResponse(statusCode: Int, url: URL) throws -> HTTPURLResponse { + guard + let response = HTTPURLResponse( + url: url, + statusCode: statusCode, + httpVersion: nil, + headerFields: nil + ) + else { + throw URLError(.badServerResponse) + } + return response + } + + @Test("download throws httpError on a non-success status") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadThrowsOnHTTPFailure() async throws { + let asset = Asset( + fileChecksum: Self.opaqueChecksum, + downloadURL: Self.downloadURLString + ) + let error = await #expect(throws: CloudKitError.self) { + _ = try await asset.download { url in + (Data(), try Self.httpResponse(statusCode: 404, url: url)) + } + } + guard case .httpError(let statusCode) = error else { + Issue.record("Expected httpError, got \(error)") + return + } + #expect(statusCode == 404) + } + + @Test("download returns bytes without checking the opaque fileChecksum") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadReturnsBytesWithoutChecksumCheck() async throws { + // A real server-minted fileChecksum: a version byte plus a 20-byte + // digest, not derivable from the plaintext. Downloads must not gate on it. + let asset = Asset( + fileChecksum: "AUStEc+gPyq1KTFbGO3RbXVpusut", + downloadURL: Self.downloadURLString + ) + let data = try await asset.download { url in + (Self.plaintext, try Self.httpResponse(statusCode: 200, url: url)) + } + #expect(data == Self.plaintext) + } + + @Test("download returns bytes when the asset has no checksum") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadReturnsBytesWhenChecksumMissing() async throws { + let asset = Asset(downloadURL: Self.downloadURLString) + let data = try await asset.download { url in + (Self.plaintext, try Self.httpResponse(statusCode: 200, url: url)) + } + #expect(data == Self.plaintext) + } + + @Test("download throws missingAssetDownloadURL when the URL is absent") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadThrowsWhenURLMissing() async throws { + let asset = Asset(fileChecksum: Self.opaqueChecksum) + let error = await #expect(throws: CloudKitError.self) { + _ = try await asset.download { _ in + Issue.record("fetch should not run when downloadURL is missing") + throw URLError(.badURL) + } + } + guard case .missingAssetDownloadURL = error else { + Issue.record("Expected missingAssetDownloadURL, got \(error)") + return + } + } + + @Test("download throws missingAssetDownloadURL when the URL is invalid") + @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) + internal func downloadThrowsWhenURLInvalid() async throws { + let asset = Asset( + fileChecksum: Self.opaqueChecksum, + downloadURL: "not a url" + ) + let error = await #expect(throws: CloudKitError.self) { + _ = try await asset.download { _ in + Issue.record("fetch should not run when downloadURL is invalid") + throw URLError(.badURL) + } + } + guard case .missingAssetDownloadURL = error else { + Issue.record("Expected missingAssetDownloadURL, got \(error)") + return + } + } + } +#endif diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift index a303ca419..cf4b1ee0b 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+BasicTypes.swift @@ -91,11 +91,12 @@ extension FieldValueConversionTests { Issue.record("FieldValue is not available on this operating system.") return } - let fieldValue = FieldValue.bytes("base64encodedstring") + let payload = Data("hello".utf8) + let fieldValue = FieldValue.bytes(payload) let components = Components.Schemas.FieldValueRequest(from: fieldValue) if case .BytesValue(let value) = components.value { - #expect(value == "base64encodedstring") + #expect(value == payload.base64EncodedString()) } else { Issue.record("Expected bytesValue") } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift index 4832779ee..b8fec6198 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ComplexTypes.swift @@ -120,6 +120,24 @@ extension FieldValueConversionTests { } } + @Test("Convert reference FieldValue with VALIDATE action to Components.FieldValue") + internal func convertReferenceWithValidateAction() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let reference = Reference(recordName: "test-record-validate", action: .validate) + let fieldValue = FieldValue.reference(reference) + let components = Components.Schemas.FieldValueRequest(from: fieldValue) + + if case .ReferenceValue(let value) = components.value { + #expect(value.recordName == "test-record-validate") + #expect(value.action == .VALIDATE) + } else { + Issue.record("Expected referenceValue") + } + } + @Test("Convert asset FieldValue with all fields to Components.FieldValue") internal func convertAssetWithAllFields() { guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift index 3ca45c807..d3a7fbc1a 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+EdgeCases.swift @@ -14,12 +14,10 @@ extension FieldValueConversionTests { return } let intZero = FieldValue.int64(0) - let intComponents = Components.Schemas.FieldValueRequest(from: intZero) - // #expect(#expect(intComponents.type == .int64) + _ = Components.Schemas.FieldValueRequest(from: intZero) let doubleZero = FieldValue.double(0.0) - let doubleComponents = Components.Schemas.FieldValueRequest(from: doubleZero) - // #expect(#expect(doubleComponents.type == .double) + _ = Components.Schemas.FieldValueRequest(from: doubleZero) } @Test("Convert negative values") @@ -29,12 +27,10 @@ extension FieldValueConversionTests { return } let negativeInt = FieldValue.int64(-100) - let intComponents = Components.Schemas.FieldValueRequest(from: negativeInt) - // #expect(#expect(intComponents.type == .int64) + _ = Components.Schemas.FieldValueRequest(from: negativeInt) let negativeDouble = FieldValue.double(-3.14) - let doubleComponents = Components.Schemas.FieldValueRequest(from: negativeDouble) - // #expect(#expect(doubleComponents.type == .double) + _ = Components.Schemas.FieldValueRequest(from: negativeDouble) } @Test("Convert large numbers") @@ -44,12 +40,10 @@ extension FieldValueConversionTests { return } let largeInt = FieldValue.int64(Int.max) - let intComponents = Components.Schemas.FieldValueRequest(from: largeInt) - // #expect(#expect(intComponents.type == .int64) + _ = Components.Schemas.FieldValueRequest(from: largeInt) let largeDouble = FieldValue.double(Double.greatestFiniteMagnitude) - let doubleComponents = Components.Schemas.FieldValueRequest(from: largeDouble) - // #expect(#expect(doubleComponents.type == .double) + _ = Components.Schemas.FieldValueRequest(from: largeDouble) } @Test("Convert empty string") @@ -59,7 +53,7 @@ extension FieldValueConversionTests { return } let emptyString = FieldValue.string("") - let components = Components.Schemas.FieldValueRequest(from: emptyString) + _ = Components.Schemas.FieldValueRequest(from: emptyString) } @Test("Convert string with special characters") @@ -69,7 +63,7 @@ extension FieldValueConversionTests { return } let specialString = FieldValue.string("Hello\nWorld\t🌍") - let components = Components.Schemas.FieldValueRequest(from: specialString) + _ = Components.Schemas.FieldValueRequest(from: specialString) } } } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift index a3c7d5675..2a8dedd43 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+Lists.swift @@ -98,5 +98,62 @@ extension FieldValueConversionTests { Issue.record("Expected ListValue") } } + + @Test("BYTES list element that is not valid base64 throws typeValueMismatch") + internal func malformedBytesListElementThrows() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect( + throws: ConversionError.typeValueMismatch( + fieldName: "field", + declaredType: "BYTES", + value: "not!valid!" + ) + ) { + _ = try FieldValue(listItem: .BytesValue("not!valid!"), fieldName: "field") + } + } + ) + } + + @Test("Nested BYTES list element that is not valid base64 throws typeValueMismatch") + internal func malformedBytesNestedListElementThrows() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect( + throws: ConversionError.typeValueMismatch( + fieldName: "field", + declaredType: "BYTES", + value: "not!valid!" + ) + ) { + _ = try FieldValue( + nestedListValue: [.BytesValue("not!valid!")], + fieldName: "field" + ) + } + } + ) + } + + @Test("BYTES list element with valid base64 reads as .bytes Data") + internal func validBytesListElement() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + let value = try FieldValue(listItem: .BytesValue("aGVsbG8="), fieldName: "field") + #expect(value == .bytes(Data("hello".utf8))) + } } } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift index dd3dbfc75..7129185f0 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueConversionTests+ResponseTypes.swift @@ -60,7 +60,7 @@ extension FieldValueConversionTests { return } let value = try Self.decode(#"{"value": "aGVsbG8=", "type": "BYTES"}"#) - #expect(value == .bytes("aGVsbG8=")) + #expect(value == .bytes(Data("hello".utf8))) } @Test("Whole-valued DOUBLE with type reads back as .double, not .int64") @@ -169,8 +169,50 @@ extension FieldValueConversionTests { return } #expect(try Self.decode(#"{"value": "plain"}"#) == .string("plain")) + #expect(try Self.decode(#"{"value": "Chen"}"#) == .string("Chen")) #expect(try Self.decode(#"{"value": 42}"#) == .int64(42)) #expect(try Self.decode(#"{"value": 3.5}"#) == .double(3.5)) } + + @Test("Tagged BYTES that is not valid base64 throws typeValueMismatch with the raw string") + internal func taggedMalformedBytesThrows() { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + expectTypeValueMismatch( + #"{"value": "not!valid!", "type": "BYTES"}"#, + value: "not!valid!" + ) + } + + @Test("Untagged malformed base64 infers as .string and does not throw") + internal func inferredUntaggedMalformedBytesIsString() throws { + guard #available(macOS 11.0, iOS 14.0, tvOS 14.0, watchOS 7.0, *) else { + Issue.record("FieldValue is not available on this operating system.") + return + } + #expect(try Self.decode(#"{"value": "not!valid!"}"#) == .string("not!valid!")) + #expect(try Self.decode(#"{"value": "aGVsbG8="}"#) == .string("aGVsbG8=")) + } + + /// Expects decoding `json` to throw `typeValueMismatch` whose `value` is the + /// unwrapped string payload, with the DEBUG assertion trap suppressed. + private func expectTypeValueMismatch(_ json: String, value: String) { + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect( + throws: ConversionError.typeValueMismatch( + fieldName: "field", + declaredType: "BYTES", + value: value + ) + ) { + _ = try Self.decode(json) + } + } + ) + } } } diff --git a/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift b/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift index 83d289ba0..603f5f3f1 100644 --- a/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift +++ b/Tests/MistKitTests/Models/FieldValues/FieldValueTests.swift @@ -135,18 +135,19 @@ internal struct FieldValueTests { #expect(milliseconds == date.timeIntervalSince1970 * 1_000) } - /// Tests that `.bytes` encodes as its raw string payload. + /// Tests that `.bytes` encodes as a base64 string payload. /// - /// `.bytes` shares the scalar arm with `.string`, so it emits the same wire - /// value; it decodes back as `.string` since the decoder has no bytes branch. - @Test("FieldValue bytes encodes as its string payload") + /// Encoding emits `Data.base64EncodedString()`. Decoding still has no bytes + /// branch, so the payload reads back as `.string`. + @Test("FieldValue bytes encodes as its base64 string payload") internal func fieldValueBytesEncodesAsString() throws { - let payload = "YWJjMTIz" + let payload = Data("abc123".utf8) + let encoded = payload.base64EncodedString() let bytesData = try JSONEncoder().encode(FieldValue.bytes(payload)) - let stringData = try JSONEncoder().encode(FieldValue.string(payload)) + let stringData = try JSONEncoder().encode(FieldValue.string(encoded)) #expect(bytesData == stringData) let decoded = try JSONDecoder().decode(FieldValue.self, from: bytesData) - #expect(decoded == .string(payload)) + #expect(decoded == .string(encoded)) } } diff --git a/Tests/MistKitTests/Models/Notifications/CourierTests.swift b/Tests/MistKitTests/Models/Notifications/CourierTests.swift index 8d8c87e7c..17c4a6f94 100644 --- a/Tests/MistKitTests/Models/Notifications/CourierTests.swift +++ b/Tests/MistKitTests/Models/Notifications/CourierTests.swift @@ -35,6 +35,36 @@ @Suite("Courier") internal struct CourierTests { + private actor PollCounter { + private(set) var count = 0 + func increment() -> Int { + count += 1 + return count + } + } + + private actor FirstNotificationGate { + private var notification: CourierNotification? + private var continuation: CheckedContinuation? + + func store(_ notification: CourierNotification) { + self.notification = notification + continuation?.resume() + continuation = nil + } + + func waitForFirst() async { + if notification != nil { + return + } + await withCheckedContinuation { continuation = $0 } + } + + func firstNotification() -> CourierNotification? { + notification + } + } + private static func courierURL() throws -> URL { try #require(URL(string: "https://webcourier.icloud.com/poll")) } @@ -74,5 +104,46 @@ let notification = try #require(result) #expect(notification.reason == .recordCreated) } + + @Test("notifications yields a decoded notification from the stream") + internal func notificationsYieldsDecodedNotification() async throws { + let url = try Self.courierURL() + let body = #"{"ck":{"qry":{"fo":1,"sid":"s","rid":"r"}}}"# + let polls = PollCounter() + let transport: Courier.Transport = { _, _ in + let pollCount = await polls.increment() + if pollCount == 1 { + return (statusCode: 200, data: Data(body.utf8)) + } + // Would hang the test if Courier.notifications keeps polling after cancel. + // nanoseconds API: package deployment target is below iOS 16 / Duration clocks. + try await Task.sleep(nanoseconds: 60_000_000_000) + return (statusCode: 200, data: Data()) + } + + let stream = Courier.notifications(courierURL: url, perPollTimeout: 1, transport: transport) + let gate = FirstNotificationGate() + let consumeTask = Task { + do { + for try await notification in stream { + await gate.store(notification) + } + } catch { + // Expected when cancellation tears down the stream mid-poll. + } + } + defer { consumeTask.cancel() } + + await gate.waitForFirst() + // Cancel while blocked on the next stream element so onTermination stops polling. + consumeTask.cancel() + + let decoded = try #require(await gate.firstNotification()) + #expect(decoded.reason == CourierNotification.Reason.recordCreated) + + try await Task.sleep(nanoseconds: 100_000_000) + let finalCount = await polls.count + #expect(finalCount <= 2) + } } #endif diff --git a/Tests/MistKitTests/Models/Subscriptions/NotificationInfoTests.swift b/Tests/MistKitTests/Models/Subscriptions/NotificationInfoTests.swift new file mode 100644 index 000000000..36e00458c --- /dev/null +++ b/Tests/MistKitTests/Models/Subscriptions/NotificationInfoTests.swift @@ -0,0 +1,77 @@ +// +// NotificationInfoTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import MistKitOpenAPI +internal import Testing + +@testable import MistKit + +@Suite("NotificationInfo Conversion") +internal struct NotificationInfoTests { + @Test("NotificationInfo round-trips through the OpenAPI schema") + internal func roundTrip() { + let info = NotificationInfo( + alertBody: "Hello", + alertLocalizationKey: "GREETING", + alertLocalizationArgs: ["Leo"], + soundName: "default", + shouldBadge: true, + shouldSendContentAvailable: false, + additionalFields: ["title"], + category: "article" + ) + + let schema = info.schema + #expect(schema.alertBody == "Hello") + #expect(schema.alertLocalizationKey == "GREETING") + #expect(schema.alertLocalizationArgs == ["Leo"]) + #expect(schema.soundName == "default") + #expect(schema.shouldBadge == true) + #expect(schema.shouldSendContentAvailable == false) + #expect(schema.additionalFields == ["title"]) + #expect(schema.category == "article") + + let recovered = NotificationInfo(from: schema) + #expect(recovered == info) + } + + @Test("Absent notification flags stay nil through the schema") + internal func absentFlagsStayNil() { + let info = NotificationInfo(alertBody: "Ping") + let schema = info.schema + + #expect(schema.shouldBadge == nil) + #expect(schema.shouldSendContentAvailable == nil) + + let recovered = NotificationInfo(from: schema) + #expect(recovered.shouldBadge == nil) + #expect(recovered.shouldSendContentAvailable == nil) + #expect(recovered.alertBody == "Ping") + } +} diff --git a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift index c1b0e775c..f0c97e91c 100644 --- a/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift +++ b/Tests/MistKitTests/Models/Subscriptions/SubscriptionConversionTests.swift @@ -88,7 +88,7 @@ internal struct SubscriptionConversionTests { let schema = info.schema #expect(schema.subscriptionType == .zone) #expect(schema.zoneID?.zoneName == "Photos") - #expect(schema.zoneID?.ownerName == "_owner") + #expect(schema.zoneID?.ownerRecordName == "_owner") #expect(schema.query == nil) // Zone subscriptions don't carry firesOn — only `.query` does. #expect(schema.firesOn == nil) @@ -143,7 +143,7 @@ internal struct SubscriptionConversionTests { let payload = Components.Schemas.Subscription( subscriptionID: "z", subscriptionType: .zone, - zoneID: Components.Schemas.ZoneID(ownerName: "_owner") + zoneID: Components.Schemas.ZoneID(ownerRecordName: "_owner") ) expectThrow(ConversionError.zoneMissingName) { _ = try SubscriptionInfo(from: payload) diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift index 851abb840..938b23a1a 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+Responses.swift @@ -129,7 +129,7 @@ extension ZoneMetadataTests { { "zones": [ { - "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "zoneID": { "zoneName": "Articles", "ownerRecordName": "_defaultOwner" }, "syncToken": "lookup-token", "atomic": true } diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift index 6ad759f25..98520bd6b 100644 --- a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversion.swift @@ -47,7 +47,7 @@ extension ZoneMetadataTests { let zone = try Self.decodeZone( """ { - "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "zoneID": { "zoneName": "Articles", "ownerRecordName": "_defaultOwner" }, "syncToken": "AQAAAAAAAAAB", "atomic": true } @@ -64,7 +64,7 @@ extension ZoneMetadataTests { let zone = try Self.decodeZone( """ { - "zoneID": { "zoneName": "Articles", "ownerName": "_defaultOwner" }, + "zoneID": { "zoneName": "Articles", "ownerRecordName": "_defaultOwner" }, "syncToken": "AQAAAAAAAAAB", "atomic": true } @@ -77,54 +77,139 @@ extension ZoneMetadataTests { #expect(info.ownerRecordName == "_defaultOwner") #expect(info.syncToken == "AQAAAAAAAAAB") #expect(info.atomic == true) + #expect(info.deleted == nil) } - @Test("Absent metadata stays nil rather than defaulting") - internal func absentMetadataStaysNil() throws { + @Test( + "ZoneInfo decodes live-shaped change-feed payload with ownerRecordName, zoneType, and deleted" + ) + internal func zoneInfoDecodesLiveChangeFeedShape() throws { let zone = try Self.decodeZone( """ - { "zoneID": { "zoneName": "Articles" } } + { + "zoneID": { + "zoneName": "WebChangeTest", + "ownerRecordName": "_aca0fa3547ae9f9cd1f7e25fed948a20", + "zoneType": "REGULAR_CUSTOM_ZONE" + }, + "deleted": true + } """ ) let info = try ZoneInfo(from: zone) - // `atomic` must stay nil so "absent" remains distinguishable from - // an explicit `false`. - #expect(info.syncToken == nil) - #expect(info.atomic == nil) - #expect(info.zoneName == "Articles") + #expect(info.zoneName == "WebChangeTest") + #expect(info.ownerRecordName == "_aca0fa3547ae9f9cd1f7e25fed948a20") + #expect(info.zoneType == .regularCustom) + #expect(info.deleted == true) } - @Test("atomic decodes false without collapsing into nil") - internal func atomicFalseIsPreserved() throws { + @Test("Absent deleted stays nil rather than defaulting to false") + internal func absentDeletedStaysNil() throws { let zone = try Self.decodeZone( """ - { "zoneID": { "zoneName": "Articles" }, "atomic": false } + { + "zoneID": { "zoneName": "Articles", "ownerRecordName": "_defaultOwner" } + } """ ) let info = try ZoneInfo(from: zone) - #expect(try #require(info.atomic) == false) + #expect(info.deleted == nil) + } + + @Test("DatabaseChangedZone tombstone surfaces deleted on ZoneInfo") + internal func databaseChangedZoneDeletedSurfaces() throws { + let item = try JSONDecoder().decode( + Components.Schemas.DatabaseChangesResponse.zonesPayloadPayload.self, + from: Data( + """ + { + "zoneID": { + "zoneName": "WebChangeTest", + "ownerRecordName": "_aca0fa3547ae9f9cd1f7e25fed948a20", + "zoneType": "REGULAR_CUSTOM_ZONE" + }, + "deleted": true + } + """.utf8 + ) + ) + + let result = try ZoneChangeResult(from: item) + let zone = try result.get() + + #expect(zone.zoneName == "WebChangeTest") + #expect(zone.ownerRecordName == "_aca0fa3547ae9f9cd1f7e25fed948a20") + #expect(zone.zoneType == .regularCustom) + #expect(zone.deleted == true) + } + + @Test("unrecognizedZoneType exposes a localized description") + internal func unrecognizedZoneTypeDescription() { + let error = ConversionError.unrecognizedZoneType("SHARED_ZONE") + #expect(error.errorDescription == "Zone entry has unrecognized zoneType 'SHARED_ZONE'") } - @Test("ZoneInfo still throws when the zone payload has no zoneName") - internal func missingZoneNameThrows() throws { + @Test("Unrecognized zoneType throws ConversionError") + internal func unrecognizedZoneTypeThrows() throws { let zone = try Self.decodeZone( """ - { "zoneID": { "ownerName": "_defaultOwner" }, "atomic": true } + { + "zoneID": { + "zoneName": "Articles", + "zoneType": "SHARED_ZONE" + } + } """ ) ConversionFailureReporter.$assertionHandler.withValue( { _, _, _ in }, operation: { - #expect(throws: ConversionError.self) { + #expect(throws: ConversionError.unrecognizedZoneType("SHARED_ZONE")) { _ = try ZoneInfo(from: zone) } } ) } + + @Test("DEFAULT_ZONE decodes to ZoneType.defaultZone") + internal func defaultZoneTypeDecodes() throws { + let zone = try Self.decodeZone( + """ + { + "zoneID": { + "zoneName": "_defaultZone", + "zoneType": "DEFAULT_ZONE" + } + } + """ + ) + + let info = try ZoneInfo(from: zone) + + #expect(info.zoneType == .defaultZone) + } + + @Test("Absent metadata stays nil rather than defaulting") + internal func absentMetadataStaysNil() throws { + let zone = try Self.decodeZone( + """ + { "zoneID": { "zoneName": "Articles" } } + """ + ) + + let info = try ZoneInfo(from: zone) + + // `atomic` must stay nil so "absent" remains distinguishable from + // an explicit `false`. + #expect(info.syncToken == nil) + #expect(info.atomic == nil) + #expect(info.deleted == nil) + #expect(info.zoneName == "Articles") + } } } diff --git a/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift new file mode 100644 index 000000000..aca51a5e5 --- /dev/null +++ b/Tests/MistKitTests/Models/Zones/ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift @@ -0,0 +1,83 @@ +// +// ZoneMetadataTests+ZoneInfoConversionEdgeCases.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit +@testable import MistKitOpenAPI + +extension ZoneMetadataTests { + /// Edge-case decoding for ``ZoneInfo`` conversion. + @Suite("ZoneInfo Conversion Edge Cases", .disabled(if: Platform.isWindowsSwift62)) + internal struct ZoneInfoConversionEdgeCases { + private static func decodeZone(_ json: String) throws -> Components.Schemas.Zone { + try JSONDecoder().decode(Components.Schemas.Zone.self, from: Data(json.utf8)) + } + + @Test("atomic decodes false without collapsing into nil") + internal func atomicFalseIsPreserved() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let zone = try Self.decodeZone( + """ + { "zoneID": { "zoneName": "Articles" }, "atomic": false } + """ + ) + + let info = try ZoneInfo(from: zone) + + #expect(info.atomic == false) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + + @Test("ZoneInfo still throws when the zone payload has no zoneName") + internal func missingZoneNameThrows() throws { + #if !(os(Windows) && compiler(>=6.2) && compiler(<6.3)) + let zone = try Self.decodeZone( + """ + { "zoneID": { "ownerRecordName": "_defaultOwner" }, "atomic": true } + """ + ) + + ConversionFailureReporter.$assertionHandler.withValue( + { _, _, _ in }, + operation: { + #expect(throws: ConversionError.self) { + _ = try ZoneInfo(from: zone) + } + } + ) + #else + Issue.record("Omitted on Windows × Swift 6.2 (MistKitTests emit tip-over).") + #endif + } + } +} diff --git a/Tests/MistKitTests/OpenAPI/CloudKitResponseTypeTests.swift b/Tests/MistKitTests/OpenAPI/CloudKitResponseTypeTests.swift new file mode 100644 index 000000000..86db01660 --- /dev/null +++ b/Tests/MistKitTests/OpenAPI/CloudKitResponseTypeTests.swift @@ -0,0 +1,163 @@ +// +// CloudKitResponseTypeTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import MistKitOpenAPI +internal import OpenAPIRuntime +internal import Testing + +@testable import MistKit + +@Suite("CloudKit Response Mapping") +internal struct CloudKitResponseTypeTests { + private typealias ServerCode = Components.Schemas.ErrorResponse.serverErrorCodePayload + + private static func undocumentedPayload() -> UndocumentedPayload { + UndocumentedPayload(body: HTTPBody(Data())) + } + + private static func sampleFailure( + code: ServerCode = .BAD_REQUEST, + reason: String = "failure" + ) -> Components.Responses.Failure { + Components.Responses.Failure( + body: .json( + .init( + serverErrorCode: code, + reason: reason + ) + ) + ) + } + + private static func assertMapsToStatusCode( + _ output: T, + statusCode: Int + ) { + guard let mapped = output.toCloudKitError() else { + Issue.record("expected non-nil CloudKitError for HTTP \(statusCode)") + return + } + #expect(mapped.httpStatusCode == statusCode) + } + + @Test("listZones maps documented HTTP failures") + internal func listZonesMapsFailures() { + let mappings: [(() -> Operations.listZones.Output, Int)] = [ + ({ .badRequest(Self.sampleFailure(code: .BAD_REQUEST)) }, 400), + ({ .unauthorized(Self.sampleFailure(code: .AUTHENTICATION_FAILED)) }, 401), + ({ .forbidden(Self.sampleFailure(code: .ACCESS_DENIED)) }, 403), + ({ .notFound(Self.sampleFailure(code: .NOT_FOUND)) }, 404), + ({ .conflict(Self.sampleFailure(code: .CONFLICT)) }, 409), + ( + { + .preconditionFailed(Self.sampleFailure(code: .VALIDATING_REFERENCE_ERROR)) + }, 412 + ), + ({ .contentTooLarge(Self.sampleFailure(code: .QUOTA_EXCEEDED)) }, 413), + ( + { + .misdirectedRequest(Self.sampleFailure(code: .AUTHENTICATION_REQUIRED)) + }, 421 + ), + ({ .tooManyRequests(Self.sampleFailure(code: .THROTTLED)) }, 429), + ({ .internalServerError(Self.sampleFailure(code: .INTERNAL_ERROR)) }, 500), + ({ .serviceUnavailable(Self.sampleFailure(code: .TRY_AGAIN_LATER)) }, 503), + ({ .undocumented(statusCode: 418, Self.undocumentedPayload()) }, 418), + ] + for (makeOutput, statusCode) in mappings { + Self.assertMapsToStatusCode(makeOutput(), statusCode: statusCode) + } + } + + @Test("lookupZones maps documented HTTP failures") + internal func lookupZonesMapsFailures() { + Self.assertMapsToStatusCode( + Operations.lookupZones.Output.badRequest(Self.sampleFailure(code: .BAD_REQUEST)), + statusCode: 400 + ) + Self.assertMapsToStatusCode( + Operations.lookupZones.Output.unauthorized(Self.sampleFailure(code: .AUTHENTICATION_FAILED)), + statusCode: 401 + ) + Self.assertMapsToStatusCode( + Operations.lookupZones.Output.undocumented(statusCode: 418, Self.undocumentedPayload()), + statusCode: 418 + ) + } + + @Test("modifyZones maps documented HTTP failures") + internal func modifyZonesMapsFailures() { + Self.assertMapsToStatusCode( + Operations.modifyZones.Output.badRequest(Self.sampleFailure(code: .BAD_REQUEST)), + statusCode: 400 + ) + Self.assertMapsToStatusCode( + Operations.modifyZones.Output.unauthorized(Self.sampleFailure(code: .AUTHENTICATION_FAILED)), + statusCode: 401 + ) + Self.assertMapsToStatusCode( + Operations.modifyZones.Output.undocumented(statusCode: 418, Self.undocumentedPayload()), + statusCode: 418 + ) + } + + @Test("subscription list/lookup/modify outputs map HTTP failures") + internal func subscriptionOutputsMapFailures() { + Self.assertMapsToStatusCode( + Operations.listSubscriptions.Output.unauthorized( + Self.sampleFailure(code: .AUTHENTICATION_FAILED) + ), + statusCode: 401 + ) + Self.assertMapsToStatusCode( + Operations.lookupSubscriptions.Output.badRequest(Self.sampleFailure(code: .BAD_REQUEST)), + statusCode: 400 + ) + Self.assertMapsToStatusCode( + Operations.modifySubscriptions.Output.badRequest(Self.sampleFailure(code: .BAD_REQUEST)), + statusCode: 400 + ) + } + + @Test("fetchRecordChanges maps documented HTTP failures") + internal func fetchRecordChangesMapsFailures() { + Self.assertMapsToStatusCode( + Operations.fetchRecordChanges.Output.tooManyRequests(Self.sampleFailure(code: .THROTTLED)), + statusCode: 429 + ) + Self.assertMapsToStatusCode( + Operations.fetchRecordChanges.Output.undocumented( + statusCode: 418, + Self.undocumentedPayload() + ), + statusCode: 418 + ) + } +} diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift b/Tests/MistKitTests/RecordManagement/AltTestRecord.swift similarity index 58% rename from Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift rename to Tests/MistKitTests/RecordManagement/AltTestRecord.swift index 6ec33a407..d38fdf734 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/CelestraConfig.swift +++ b/Tests/MistKitTests/RecordManagement/AltTestRecord.swift @@ -1,6 +1,6 @@ // -// CelestraConfig.swift -// CelestraCloud +// AltTestRecord.swift +// MistKit // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -28,30 +28,29 @@ // internal import Foundation -public import MistKit -// MARK: - Shared Configuration +@testable import MistKit -/// Shared configuration helper for creating CloudKit service -public enum CelestraConfig { - /// Create CloudKit service from validated configuration - public static func createCloudKitService(from config: ValidatedCloudKitConfiguration) throws - -> CloudKitService - { - // Read private key from file - let privateKeyPEM = try String(contentsOfFile: config.privateKeyPath, encoding: .utf8) +/// Second CloudKit record type for collection-operation tests. +internal struct AltTestRecord: CloudKitRecord { + internal static var cloudKitRecordType: String { "AltTestRecord" } - // Create token manager for server-to-server authentication - let tokenManager = try ServerToServerAuthManager( - keyID: config.keyID, - pemString: privateKeyPEM - ) + internal var recordName: String + internal var title: String - // Create and return CloudKit service - return CloudKitService( - containerIdentifier: config.containerID, - tokenManager: tokenManager, - environment: config.environment - ) + internal static func from(recordInfo: RecordInfo) -> AltTestRecord? { + guard let title = recordInfo.fields["title"]?.stringValue else { + return nil + } + return AltTestRecord(recordName: recordInfo.recordName, title: title) + } + + internal static func formatForDisplay(_ recordInfo: RecordInfo) -> String { + let title = recordInfo.fields["title"]?.stringValue ?? "Unknown" + return " \(recordInfo.recordName): \(title)" + } + + internal func toCloudKitFields() -> [String: FieldValue] { + ["title": .string(title)] } } diff --git a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift index 2f7945d10..9f412ad1d 100644 --- a/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift +++ b/Tests/MistKitTests/RecordManagement/FieldValueConvenienceTests.swift @@ -116,11 +116,11 @@ internal struct FieldValueConvenienceTests { #expect(FieldValue.int64(1_704_067_200).dateValue == nil) } - @Test("bytesValue extracts String from .bytes case") + @Test("bytesValue extracts base64 String from .bytes case") internal func bytesValueExtraction() { - let base64 = "SGVsbG8gV29ybGQ=" - let value = FieldValue.bytes(base64) - #expect(value.bytesValue == base64) + let data = Data("Hello World".utf8) + let value = FieldValue.bytes(data) + #expect(value.bytesValue == data.base64EncodedString()) } @Test("bytesValue returns nil for non-bytes cases") @@ -128,6 +128,20 @@ internal struct FieldValueConvenienceTests { #expect(FieldValue.string("test").bytesValue == nil) } + @Test("dataValue extracts Data from .bytes case") + internal func dataValueExtraction() { + let data = Data("Hello World".utf8) + let value = FieldValue.bytes(data) + #expect(value.dataValue == data) + } + + @Test("dataValue returns nil for non-bytes cases including .string") + internal func dataValueReturnsNilForWrongType() { + #expect(FieldValue.string("test").dataValue == nil) + #expect(FieldValue.string("SGVsbG8gV29ybGQ=").dataValue == nil) + #expect(FieldValue.string("Chen").dataValue == nil) + } + @Test("locationValue extracts Location from .location case") internal func locationValueExtraction() { let location = Location( diff --git a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift b/Tests/MistKitTests/RecordManagement/MockCollectionRecordManagingService.swift similarity index 51% rename from Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift rename to Tests/MistKitTests/RecordManagement/MockCollectionRecordManagingService.swift index 3e395a0b5..f8fa7a122 100644 --- a/Examples/CelestraCloud/Sources/CelestraCloudKit/Configuration/ValidatedCloudKitConfiguration.swift +++ b/Tests/MistKitTests/RecordManagement/MockCollectionRecordManagingService.swift @@ -1,6 +1,6 @@ // -// ValidatedCloudKitConfiguration.swift -// CelestraCloud +// MockCollectionRecordManagingService.swift +// MistKit // // Created by Leo Dion. // Copyright © 2026 BrightDigit. @@ -28,37 +28,31 @@ // internal import Foundation -public import MistKit -/// Validated CloudKit configuration with all required fields -public struct ValidatedCloudKitConfiguration: Sendable { - /// CloudKit container identifier (validated non-empty) - public let containerID: String +@testable import MistKit - /// Server-to-Server authentication key ID (validated non-empty) - public let keyID: String +@available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) +internal actor MockCollectionRecordManagingService: RecordManaging, CloudKitRecordCollection { + internal static let recordTypes = RecordTypeSet(TestRecord.self, AltTestRecord.self) - /// Absolute path to PEM-encoded private key file (validated non-empty) - public let privateKeyPath: String + internal var queryCallCount = 0 + internal var executeCallCount = 0 + internal var lastExecutedOperations: [RecordOperation] = [] + internal var batchSizes: [Int] = [] + internal var recordsByType: [String: [RecordInfo]] = [:] - /// CloudKit environment (development or production) - public let environment: MistKit.Environment + internal func queryAllRecords(recordType: String) async throws -> [RecordInfo] { + queryCallCount += 1 + return recordsByType[recordType] ?? [] + } + + internal func executeBatchOperations(_ operations: [RecordOperation]) async throws { + executeCallCount += 1 + batchSizes.append(operations.count) + lastExecutedOperations.append(contentsOf: operations) + } - /// Initialize validated CloudKit configuration - /// - Parameters: - /// - containerID: CloudKit container identifier - /// - keyID: Server-to-Server authentication key ID - /// - privateKeyPath: Absolute path to PEM-encoded private key file - /// - environment: CloudKit environment - public init( - containerID: String, - keyID: String, - privateKeyPath: String, - environment: MistKit.Environment - ) { - self.containerID = containerID - self.keyID = keyID - self.privateKeyPath = privateKeyPath - self.environment = environment + internal func setRecords(_ records: [RecordInfo], forRecordType recordType: String) { + recordsByType[recordType] = records } } diff --git a/Tests/MistKitTests/RecordManagement/RecordManagingTests+RecordCollection.swift b/Tests/MistKitTests/RecordManagement/RecordManagingTests+RecordCollection.swift new file mode 100644 index 000000000..49d042c7e --- /dev/null +++ b/Tests/MistKitTests/RecordManagement/RecordManagingTests+RecordCollection.swift @@ -0,0 +1,123 @@ +// +// RecordManagingTests+RecordCollection.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Foundation +internal import Testing + +@testable import MistKit + +extension RecordManagingTests { + @Suite("Record Collection Operations") + internal struct RecordCollection { + @Test("syncAllRecords batches each non-empty record type") + internal func syncAllRecordsBatchesPerType() async throws { + guard #available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) else { + Issue.record("Record collection operations are not available on this operating system.") + return + } + + let service = MockCollectionRecordManagingService() + let testRecords = [ + TestRecord( + recordName: "test-1", + name: "One", + count: 1, + isActive: true, + score: nil, + lastUpdated: nil + ) + ] + let altRecords = [ + AltTestRecord(recordName: "alt-1", title: "Alt One"), + AltTestRecord(recordName: "alt-2", title: "Alt Two"), + ] + + try await service.syncAllRecords(testRecords, altRecords) + + let executeCount = await service.executeCallCount + let batchSizes = await service.batchSizes + let operations = await service.lastExecutedOperations + + #expect(executeCount == 2) + #expect(batchSizes == [1, 2]) + #expect(operations.count == 3) + #expect(operations.filter { $0.recordType == "TestRecord" }.count == 1) + #expect(operations.filter { $0.recordType == "AltTestRecord" }.count == 2) + #expect(operations.allSatisfy { $0.operationType == .forceReplace }) + } + + @Test("deleteAllRecords issues delete operations for every managed type") + internal func deleteAllRecordsDeletesAcrossTypes() async throws { + guard #available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) else { + Issue.record("Record collection operations are not available on this operating system.") + return + } + + let service = MockCollectionRecordManagingService() + await service.setRecords( + [RecordInfo(recordName: "test-1", recordType: "TestRecord", fields: [:])], + forRecordType: "TestRecord" + ) + await service.setRecords( + [RecordInfo(recordName: "alt-1", recordType: "AltTestRecord", fields: [:])], + forRecordType: "AltTestRecord" + ) + + try await service.deleteAllRecords() + + let executeCount = await service.executeCallCount + let operations = await service.lastExecutedOperations + let queryCount = await service.queryCallCount + + #expect(queryCount == 2) + #expect(executeCount == 2) + #expect(operations.count == 2) + #expect(operations.allSatisfy { $0.operationType == .delete }) + } + + @Test("listAllRecords queries every managed type") + internal func listAllRecordsQueriesEveryType() async throws { + guard #available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) else { + Issue.record("Record collection operations are not available on this operating system.") + return + } + + let service = MockCollectionRecordManagingService() + await service.setRecords( + [RecordInfo(recordName: "test-1", recordType: "TestRecord", fields: [:])], + forRecordType: "TestRecord" + ) + + try await service.listAllRecords() + + let queryCount = await service.queryCallCount + #expect(queryCount == 2) + } + } +} diff --git a/Tests/MistKitTests/RecordManagement/RecordTypeSetTests.swift b/Tests/MistKitTests/RecordManagement/RecordTypeSetTests.swift new file mode 100644 index 000000000..450024ab0 --- /dev/null +++ b/Tests/MistKitTests/RecordManagement/RecordTypeSetTests.swift @@ -0,0 +1,53 @@ +// +// RecordTypeSetTests.swift +// MistKit +// +// Created by Leo Dion. +// Copyright © 2026 BrightDigit. +// +// Permission is hereby granted, free of charge, to any person +// obtaining a copy of this software and associated documentation +// files (the "Software"), to deal in the Software without +// restriction, including without limitation the rights to use, +// copy, modify, merge, publish, distribute, sublicense, and/or +// sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following +// conditions: +// +// The above copyright notice and this permission notice shall be +// included in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// + +internal import Testing + +@testable import MistKit + +@Suite("RecordTypeSet") +internal struct RecordTypeSetTests { + @Test("forEach visits every record type in the pack") + internal func forEachVisitsAllTypes() async { + guard #available(macOS 14.0, iOS 17.0, tvOS 17.0, watchOS 10.0, *) else { + Issue.record("RecordTypeSet is not available on this operating system.") + return + } + + let recordTypes = RecordTypeSet(TestRecord.self, AltTestRecord.self) + var visited: [String] = [] + + // swift-format-ignore: ReplaceForEachWithForLoop + await recordTypes.forEach { recordType in + visited.append(recordType.cloudKitRecordType) + } + + #expect(visited.sorted() == ["AltTestRecord", "TestRecord"]) + } +} diff --git a/codecov.yml b/codecov.yml index d3d2ff4f0..43bbfc8df 100644 --- a/codecov.yml +++ b/codecov.yml @@ -9,3 +9,4 @@ ignore: - "Tests" - "Sources/MistKitOpenAPI" - "Examples" + - "Packages/MistKitConfiguration" diff --git a/docs/internals/field-type-polymorphism.md b/docs/internals/field-type-polymorphism.md index a9aacd675..3ea160435 100644 --- a/docs/internals/field-type-polymorphism.md +++ b/docs/internals/field-type-polymorphism.md @@ -11,7 +11,7 @@ public enum FieldValue: Codable, Equatable, Sendable { case string(String) case int64(Int) case double(Double) - case bytes(String) // Base64-encoded binary data + case bytes(Data) // Binary data; base64-encoded on the wire case date(Date) // Stored as milliseconds since epoch case location(Location) case reference(Reference) diff --git a/openapi.yaml b/openapi.yaml index 856bce209..9fc621d53 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -1352,8 +1352,16 @@ components: properties: zoneName: type: string - ownerName: + ownerRecordName: type: string + description: > + The zone owner's user record name. Use this key to identify a zone + owned by another user (e.g. a shared zone). + zoneType: + type: string + description: > + The zone's type. Live responses carry values such as + `REGULAR_CUSTOM_ZONE` and `DEFAULT_ZONE`. Filter: type: object @@ -1636,8 +1644,14 @@ components: description: The record name being referenced action: type: string - enum: [NONE, DELETE_SELF] - description: Action to perform on the referenced record + enum: [NONE, DELETE_SELF, VALIDATE] + description: > + Action to perform on the referenced record. NONE performs no action; + DELETE_SELF deletes this record when the referenced record is deleted; + VALIDATE verifies the target record exists before creating the + reference (create fails if missing). VALIDATE is a CloudKit Web + Services value; native CKRecord.ReferenceAction has only none and + deleteSelf. AssetValue: type: object @@ -1848,11 +1862,11 @@ components: type: object description: > A record zone as returned by the zone endpoints (`zones/list`, - `zones/lookup`, `zones/modify`, `zones/changes`). Matches the - "Zone Dictionary" in Apple's archived CloudKit Web Services - Reference, which documents exactly three keys: `zoneID`, - `syncToken`, and `atomic`. `isEager` is deliberately absent — it - appears in no primary Apple source (see issue #386). + `zones/lookup`, `zones/modify`, `zones/changes`). The archived + "Zone Dictionary" documents `zoneID`, `syncToken`, and `atomic`; + live change feeds also carry `deleted` (issue #444). `isEager` is + deliberately absent — it appears in no primary Apple source (see + issue #386). properties: zoneID: $ref: '#/components/schemas/ZoneID' @@ -1864,6 +1878,12 @@ components: description: > A Boolean value indicating whether this zone supports atomic operations. + deleted: + type: boolean + description: > + When `true`, the zone was deleted. Present on change-feed + responses (`zones/changes`); absent on list/lookup/modify + success payloads. ZonesListResponse: type: object @@ -1946,10 +1966,18 @@ components: DatabaseChangedZone: type: object - description: A zone that changed, as returned by `changes/database`. + description: > + A zone that changed, as returned by `changes/database`. Carries the + same tombstone shape as `zones/changes` — `deleted: true` when the + zone was removed (issue #444). properties: zoneID: $ref: '#/components/schemas/ZoneID' + deleted: + type: boolean + description: > + When `true`, the zone was deleted and should be removed from + local storage. ZoneFetchFailure: type: object