Everything a new maintainer or agent needs to pick up abctl. Roadmap: TODO.md ·
architecture: docs/design-abctl.md · verified API reference:
docs/auth.md + docs/endpoints/ · app-provisioning research:
docs/app-provisioning-research.md.
A Go CLI + GitOps engine (by Gigaion, LLC) that syncs Apple Business built-in-MDM Configurations
(CUSTOM_SETTING .mobileconfig profiles) and Blueprints with a git-declarative desired state:
read-only by default, gated writes, bidirectional sync with newest-wins + archive-on-overwrite.
abgui was a macOS-only SwiftUI app. It is now a Flutter desktop app for macOS, Windows and
Linux, in abgui-flutter/ (Dart package abgui). The SwiftUI tree was deleted — one GUI, not
two — and lives in history at v0.4.27 (git show v0.4.27:abgui/...).
Why it was possible: the GUI never had a real macOS dependency. It is a facade over the abctl
CLI — spawn a process, decode JSON — with no Apple Business API logic, no Keychain binding, and an
entire native surface of two behaviours in three files (clipboard, reveal-in-file-manager).
Read docs/abgui-flutter-port.md before touching it. The things most likely to be broken by a well-meaning edit:
--pruneis unrepresentable outsideabctl_args.dart.ApplyOptionshas a private constructor and three named public ones;pruneis a read-only field, so a checkbox has nothing to bind to, and git-as-truth without prune (a half-applied desired state) cannot be spelled. A test scans all oflib/and fails if the flag appears anywhere else.- The confirmed command is the executed command — same function, pinned across all 11 write verbs by a test using a tapped runner.
- A typed confirmation approves a command, not a phrase. Escalating the plan re-arms the gate.
sync --applyandvalidatedecode stdout before mapping the exit code — abctl prints a valid document and exits non-zero.- Success is the receipt, never the exit code. A cancelled or timed-out apply reports an unknown state and says some writes may have landed.
- A truncated stdin write is undetectable on POSIX. Measured: Dart reports nothing at
add,flush,closeordone. What actually guardscreate config -f -is abctl rejecting a profile it could not parse and exiting non-zero. Do not "fix"_writeStdinbelieving it covers macOS — it never did, and neither did the Swift original. - An apply is spent per PLAN, never per session (v0.4.33).
ApplyStatecarries thePlanobject it ran against;describes()scopes it to the plan a screen is showing, andspent(didRun && isTerminal) is the only thing allowed to disable Apply. Gating onisTerminalalone is the bug this replaced: that state lives in the store, so the first apply of a session disabled Apply permanently — a recompute did not clear it and neither did reopening the dialog, because the flag was never in the widget.didRunis the other half: a pre-flight refusal reaches a terminal verdict without abctl ever being spawned, and nothing spent may lock nothing.
Build: make gui-check / gui-test (anywhere, or ./tool/flutter.sh in Docker) and
make gui-macos|gui-windows|gui-linux, each on its own platform — Flutter does not cross-compile
desktop. Linux ships a single-file AppImage (no .deb; it would need a dependency list correct
on Debian and wrong elsewhere forever) and is pinned to ubuntu-22.04 because an AppImage bundles
GTK but not glibc.
572 tests, verified on Windows and Linux. The long-standing sidebar-blanking bug is gone: load state is per-pane and command progress lives in a pinned run strip, so nothing is ever replaced by a spinner.
Built and live-verified (read-only):
- Auth — ES256 client-assertion,
kidomitted,aud = …/v2/token,exp < iat+180d, token cache, 429/5xx backoff (Retry-After aware). - Read commands —
auth whoami;get configurations|configuration|blueprints|blueprint|devices|audit(table +--json);get configuration --profile(raw XML);apiGET passthrough. - Plan (Phase 1) —
seed(live →gitops/tree + committed baseline via onefields[]list call),validate(pluggable via$ABCTL_VALIDATOR, else a built-in check),diff+sync --dry-run(the full 3-way plan: git ↔ baseline ↔ live). - Engineering baseline — Cobra CLI (help/completion/version), AGPL-3.0-or-later, race-tested unit +
httptestsuite (internal/reconcile3-way matrix, ES256 sign/verify, client pagination/429/403, gitops, config, hash), Makefile,.golangci.yml(golangci-lint v2), version via ldflags. - CI — GitHub Actions: build/vet/race-test on Linux + macOS, golangci-lint v2, and a gated,
read-only live integration test (
internal/ab/integration_test.go,-tags=integration) that self-skips when theAB_*repo secrets are absent.
Built and unit-tested (Phase 2 — gated apply, config scope):
- Apply engine —
internal/reconcile/apply.go(Engine+ injectableApplier/Archiver/FileStoreinterfaces) executes the plan: create / update / pull / delete-git / prune with archive-before-overwrite, newest-wins conflict resolution (git commit/mtime vs liveupdatedDateTime), a--limit-writescircuit breaker, per-item error isolation, and an exact baseline update. Fully unit-tested with fakes — no live tenant or devices required. internal/archive— files the pre-overwrite live version togitops/archive/<name>/<UTC-ts>--<reason>.mobileconfig+ a JSON sidecar. Timestamps are Windows-safe (no colons); reasons:replaced|overwritten-by-newer|pruned.sync --apply— wired ininternal/cli: dry-run default, plan-first, interactive confirm unless--yes(or$ABCTL_APPROVEfor CI),--pruneoff by default,--limit-writes N,--platforms. The config write methods (Create/Update/DeleteConfiguration) now return the serverupdatedDateTimeso the baseline stays byte-exact without an extra GET.- Blueprint config-membership GitOps —
diff/syncalso reconcile each blueprint's CUSTOM_SETTING config membership (git-authoritative):gitops.LoadBlueprintsparsesblueprints/*.yml(yaml.v3),ab.FetchBlueprintsresolves live membership to names,reconcile.ComputeBlueprints+Engine.ApplyBlueprintsplan/execute attach (git→ABM, always) / detach (ABM→git removal, gated--prune), reportingblueprint-new/blueprint-adoptfor unmatched blueprints. Applies in two phases (configs first, so a just-created config resolves to an id for attach);--limit-writesis one shared budget. Never detaches a native/console config it doesn't own. Unit-tested, adversarially reviewed (7 findings fixed), and verified live end-to-end (2026-07-05) via the real CLI:seedatestuser1blueprint → add a config to the manifest →sync --applyattaches it (blueprint shows 2 configs) → remove it →sync --apply(no--prune) leaves it (detach gated) →sync --apply --prunedetaches it (1 config).
Live-verified this session (2026-07-05) — client write + blueprint membership:
- Config CRUD —
TestLiveWriteRoundTripran live and passed: create→download→update→delete of a throwaway unattachedzz-*config; byte-identical GET round-trip;updatedDateTimereturned in the write response. See "Live tenant status" above. - Blueprint membership —
TestLiveBlueprintMembershipran live and passed (config relation, via a throwaway user). Confirmed create-needs-member+content and thatrelationshipsPOST merges.
Built and unit-tested (2026-07-09 — the API v2.0/v2.1 surface, branch feature/abm-api-v2-surface):
Apple shipped API v2.0 (2026-04-14) and v2.1 (2026-06-03); every endpoint contract below was pinned
verbatim from developer.apple.com's docs JSON (applebusinessapi slug).
- Detail reads (
internal/ab/detail.go,internal/cli/inspect.go):get device(assigned server viaorgDevices/{id}/assignedServer, 404 = unassigned;--applecarevia…/appleCareCoverage),get mdmdevices|mdmdevice(/v1/mdmDevices+…/{id}/details— built-in-MDM last-reported posture incl. FileVault/firewall/check-in/storage/lock/erase/lost-mode +enrolledUserId),get user|usergroup [--members]|app|package|mdmserver [--devices],get blueprintresolving all six relationship collections,status device <serial>(blueprints containing it → their configs → posture),-o csvon list commands. - Writes, gated (
internal/ab/manage.go,internal/cli/manage.go): blueprint create/edit/delete (create INLINES members — a member-less POST 409s, live-verified 2026-07-05),attach|detachforconfig|app|package|device|user|group,assign|unassign --server [--wait]viaPOST orgDeviceActivities(ASSIGN_DEVICES/UNASSIGN_DEVICES),status activity, and MDM-server create/edit/delete (v2.1; Apple 409s delete while devices are assigned). - GitOps membership for all six collections — blueprint manifests take optional
apps:/packages:/ devices:/users:/groups:keys (absent = unmanaged, never touched; present = reconciled, detach gated--prune); git-only blueprints plan a real CREATE with resolvable members riding inside the POST (one write converges the blueprint); GitOps never deletes a blueprint;seed --blueprint-membershipadopts live membership. Ambiguous member display names fail closed. - abgui Phase B — Dashboard (stat tiles), Enrolled Devices screen, entity detail sheets (device/user/ group/app/package/mdmserver/blueprint), search + sort + CSV export on every list, gated multi-select device Assign/Unassign sheet. Go↔Swift JSON contract reviewed (0 mismatches); macOS CI is the compile gate.
- Upcoming visibility update (working tree, not committed) — read-only GDMF OS-release client +
get os-releases, opt-instatus device --releasescatalog comparison, six-hour/ETag cache, richer activity completion/result-log output with guarded CSV download, audit actor/type filters, and abgui OS Releases / device comparison / assignment-result / System Health / What's New surfaces. Seedocs/upcoming-release.md; macOS compile/visual QA and live GDMF/device-assignment checks remain release gates. - Users remain API-read-only — no user/group write endpoints exist in v2.x (verified against the docs index); user lifecycle stays portal/SCIM-only and the tools say so.
App-provisioning architecture research (2026-07-15; documented, not yet device-live-verified):
- Apple Business became a free service with included device management on 2026-04-14; this did not make
VPP newly free or require Apple to be an organization-wide "primary MDM." The supported
abctlpath is a portal-acquired app →/v1/apps→ BlueprintappsplusorgDevices/users/userGroupsrelationships. - Provisioning has separate acquisition, license-allocation, installation, and observation stages. Apple
Business automates the last three through a Blueprint, but no published API acquires licenses or changes an
app's automatic/manual installation setting. Detailed installed/pending/failed status remains in the portal;
the API exposes only coarse
appLicenseDeficientplus enrolled-device posture. - Apps & Books v2 can associate a license to a literal serial or MDM-defined
clientUserId, but association is not installation; an external MDM must then sendAppManagedorInstallApplication. A secondary organizational unit and its own content token permit an isolated external-MDM lane, but that remains outsideabctl's product boundary. Never race the primary OU token with built-in management. - Apple Business API v2.2 shipped 2026-07-15 with read-only organizational-unit list/detail/user- relationship endpoints. They do not create OUs, transfer licenses, or manage content tokens, and are not yet implemented or live-verified here.
Full endpoint flows, identity mapping, coexistence rules, alternatives, evidence labels, and the safe first device test are in docs/app-provisioning-research.md. The quarantined VPP protocol implementation remains documented separately in docs/vpp-design.md.
Pre-sync verification + source-of-truth clarity (2026-07-25, working tree — not committed):
abctl validateis now credential-free and lives ininternal/cli/validate.go: it roots the tree with the newconfig.TreeDir(explicitContext)(resolvedEnvDir→ nearest.env→ cwd) instead ofconfig.Load(), so checking local files works offline, in CI, and before a tenant is ever configured.config.Load/Resolveare unchanged for every other caller.- What it checks: every
lib/profile is parsed as an XML plist by a smallencoding/xmlwalker (no new module deps) —Configuration/PayloadContent/PayloadIdentifierstructure, the 1 MiB Apple cap, binary-plist / signed (DER) / malformed XML, and an identifier declared by two files (both are flagged); plus tree issues fromLoadBlueprints()— aconfigurations:entry with no file inlib/(missing-config), an emptylib/, and files sync ignores. Warnings never fail a run. File selection goes throughTree.LoadDesired, so validate and sync can't disagree about which files are profiles. --json(and-o json|yaml;-o csvstill rejected) emits the machine report. Exit codes are unchanged — 1 whenok:false— and the report prints on stdout before that exit so a GUI can decode it.$ABCTL_VALIDATORkeeps its stream-and-propagate behavior on the human path; on--jsonit runs alongside the built-in pass intovalidator/validatorCommand/validatorExitCode/validatorOutput(16 KiB cap) and a non-zero exit fails the report. New capability tokenvalidate-json.- abgui —
Models/Validation.swift(defensively decoded mirrors),AbctlClient.validateProfiles()(decodes stdout first, so a failing report is data, not a thrown error),AppModel.validateProfiles()+Notice/post/dismissNotice/setGitSourceOfTruth, and two new views: Verify Configs (ValidateSheet) andGitSourceOfTruthControl— a literal ON/OFF pill whose click only stages the change until a confirmation dialog spells out what the new mode does, then aNoticeBannerannounces it. That dialog is presented from the content view, never from the toolbar item (macOS hosts toolbar items in a separateNSToolbarhierarchy where presentation is unreliable): the toolbar copy takesisOn:as a value, reads no environment, and stages into DiffView's@State, which.gitSourceOfTruthConfirmation(pending:)confirms and commits. Diff gains the Verify Configs toolbar button (queued-sheet hand-off into Apply); Apply gains a pre-flight row and an "Apply anyway" confirm only on a failed report, worded in problems — failing profiles + error-level tree issues + a non-zero$ABCTL_VALIDATORexit, which is the third way a report goesok:falsewith every file green. A failedvalidateProfiles()clears the old report rather than leaving a stale green light. Verification informs; it never blocks. - Tests:
internal/cli/validate_test.go(16 cases — per-code failures, duplicate identifiers,missing-config, warnings not flippingok, empty slices marshaling as[], JSON-still-printed-on-exit-1, credential-freeTreeDir) plus fiveContractTestsadditions (golden report, exit-1 decode, undecodable stdout →AbctlError.cli, argv/cwd, an older payload missing the newer keys). macOS CI is still the Swift compile gate.
abgui shows the abctl commands it runs (2026-07-25, working tree — not committed; Swift only, no Go change):
- One recording seam.
Backend/RecordingRunner.swiftis anAbctlRunnerdecorator (init(wrapping:onStart:onFinish:)) thatAppModel.makeClient(narrating:)wraps aroundProcessRunner, so every abctl call is captured at the single chokepoint it already passes through — verbs added later are recorded without anyone instrumenting them — and forwarded untouched (result and error unaltered).AppModelgainedcommands(append order, capped at 200 like the progress logs),lastCommand,recordCommandStart/recordCommandFinish(returns the closed record, so the caller prints its text) andclearCommands().makeClient(onProgress:)becamemakeClient(narrating:): oneNarration(.silent/.progress/.apply) picks abctl's stderr sink and the command sink together viacommandSink(into:), so$ abctl diff …, abctl's narration and→ exit 0 in 2.4sland in ONE transcript. - One record type, one formatter.
Models/CommandRecord.swiftholds the record plusCommandFormatter(line,script(argv:cwd:stdin:),redact,quote) — the only argv→text conversion in the app, shared by the previews, the progress logs, the Command Log and every copy button.Statusis.running/.succeeded/.failed(code)/.cancelled/.timedOut; the fifth case exists because abgui's own watchdog (AbctlError.timedOut) is not an abctl exit code andexit -1would read as a real result. - Secrets can't get in.
CommandRecord.initrunsCommandFormatter.redact(_:)before storing argv (deny-listredactedFlags=--vpp-tokentoday, both--flag valueand--flag=value, value →****, idempotent);--client-id,--key(a path) and--contextstay visible on purpose or the copied command won't run. stdin is recorded as a byte count only (.profile(bytes:)), and the copy form rewrites-f -to./<name>.mobileconfigplus a#note, since a pasted-f -waits forever on an empty terminal. - Preview/execute parity is structural.
AbctlClientgrew purestaticargv builders —syncApplyArgs,planArgs,validateArgs,assignArgs,seedArgs— that the instance methods now call and the sheets preview, so a view never re-spells a flag. No argv changed.--contextis appended by the instanceargv(_:)at run time and byAppModel.previewArgv(_:)in a preview — one tail per path. - Where it shows: new
Views/CommandLogView.swiftcarriesCommandPreview(a quiet "Equivalent CLI" line + copy),CommandLogView(new sidebar page — Overview, after System Health, symbolterminal; newest-first rows with status icon/duration/workspace, per-row copy + context menu, Copy All as Script and Clear,ContentUnavailableViewempty state),CommandCopyButtonandCommandClipboard.CommandPreviewsits inApplySheet(passing the raw prune toggle —AbctlClient.syncApplyArgsis the single place git-as-truth forces--pruneon, so the sheet andAppModel.applycannot re-derive it differently),ValidateSheet(with the workspace cwd) andAssignSheet;ConnectionFooter(showCommandLog:)showslastCommandand clicks through to the log. Read-only lists get no preview by design. - Tests:
Tests/abguiTests/CommandRecordTests.swift(16 cases — redaction in both spellings, "a raw token appears in no rendered form", quoting,script()/stdin rewriting, status + duration text, and the decorator's status mapping) plus six moreContractTests: each builder's argv against what a tapped runner actually received (sync/validate/assign, including--contextappended exactly once), the decorator end-to-end (record == argv sent, cwd carried,.succeededkeyed to the record id), and two AppModel-level checks — ApplySheet's preview INPUTS against the apply path across the toggle matrix, andpreviewArgvthreading the context exactly as the run does. macOS CI remains the compile gate.
Write confirmation + failure legibility (2026-07-25, working tree — not committed):
- The incident this fixes. Apple answered a configuration
PATCHwith a2xxand then silently did not store the profile: the live XML andupdatedDateTimenever moved, so the next plan recomputed the identical change and archived another snapshot — forever. One profile of 39 behaved this way while the other eight edits in the same run converged. Cause: a top-levelPayloadVersionof2; Apple pins the outerPayloadVersionto exactly1because it versions the profile format, not the operator's content (TopLevel). A plain hash comparison cannot see this — an unchanged live copy is indistinguishable from ordinary drift — which is why the fix is a read-back, in three independent places (before the write, at the write, after the run). internal/reconcile/apply.go— the write contract is now read-back-and-confirm.Engine.pushno longer recordsstate.Entry{Hash: hash.Raw(want)}from the bytes it sent (that optimistic baseline booked a dropped write as success and made the loop invisible). It now always re-reads the config through the newApplier.FetchCustomSettingDetail(id)and comparesstored.ContentHash()tohash.Raw(want); a write response echoing the pre-applyupdatedDateTime(unchangedTimestamp, compared by instant soZvs+00:00isn't read as progress) is corroboration on a mismatch and the tiebreaker when the read-back itself yields no answer — never a verdict on its own, because a no-opPATCHthat already agrees with live cannot bump the timestamp either. The read-back is (already*ab.Client's own method, so no adapter) and comparesstored.ContentHash()tohash.Raw(want); writes the baseline from the observed read-back on a match; on a mismatch reports an error carrying thenotPersistedsentence (which names thePayloadVersioncause) plus the pre-overwrite archive path via the newfailWithArchive— that copy is the evidence the live bytes never moved. A read-back that fails or returns no XML isdone … — NOT VERIFIED (…)with the baseline left alone: an unhappened read says nothing about the write, and a stale baseline costs one redundant archived re-write while an optimistic one hides real drift. Cost: exactly one extra GET per config actually written (never per planned item, never counted as a write).internal/cli/phase1.go—--verifyactually verifies.writtenConfigs(res)lists the completedCreate/Updateoutcomes;verifyApplythen dispatches:targetedre-reads only the writes the apply could not confirm (verifyWrittenConfigs, by the id on the outcome — normally zero GETs, sincepushalready confirmed each write; never a tenant-wide fan-out) on top of the blueprint-membership refresh;fulldiffs them against the refresh it already pays for (verifyAgainstLive);nonereads nothing.compareWrittenis the proof (ContentHash()vshash.Raw(desired)); an unreadable config is a mismatch, not a pass.reportVerificationprintspost-apply verification FAILED: <name> still differs from desired on Apple Business(the wording CI greps for) and a summary naming thePayloadVersioncause; any mismatch exits1. NewfinishApplyguarantees the receipt: every exit path aftereng.Apply(baseline-save failure, a verification fetch error, a mismatch) renders the per-item table or the machine object before returning the error, and keeps the machine shape stable when the blueprint phase never ran.internal/cli/validate.go— catch it offline, before it is ever pushed. New error codepayload-version(missing or ≠1on the top-level dict, message citing Apple's TopLevel doc and explaining the silent-drop consequence) and new warninginner-payload-version(a present per-payloadPayloadVersion≠1; only the outer one is known to trigger the drop, and an omitted key is not flagged). HelpersdescribeScalar(renders<true/>or<dict>rather than an empty value) andinnerPayloadLabel(names an inner payload by itsPayloadType, else its position). The inner-payload loop now bindspt := item.dict["PayloadType"].textbefore theswitchinstead of in its statement, so the new warning after it can name the payload by type (a nil map on a non-dict item still reads as"").- abgui — the failure is now a sentence, not a log blob.
AbctlClient.syncApplyRundecodes stdout before mapping the exit code (the rulevalidateProfiles()already followed) and returnsApplyRun {result, code, stderr}, because abctl can mark every itemdoneand still exit non-zero — the post-apply verdict lives only on stderr. NewModels/SyncFailure.swiftranks without discarding (headline≤ 180 chars + fulldetails; kinds.itemsFailed/.aborted/.timedOut/.cancelled/.unreadable/.exitedNonZero; a pure stderr extractor with a verifiednarrationPrefixesallow-list, sincemain.goexits silently for acli.ExitError).ApplySheetgained a verdict banner above the scroll view (Applied N / Applied N, M failed / Sync FAILED), Copy Error, Copy Results, and a Done-on-any-terminal-outcome button. NewViews/TranscriptView.swiftis now the one log pane (one selectable string instead of per-lineTexts, line count, Copy Log, visible scrollers, an expand toggle that grows the sheet, measured un-animated auto-follow) used by Apply / Diff / Validate.CommandLogViewmoved offList(self-sizing wrapping rows at unbounded width cycle and hang the window).CommandCopyButtontakes an@autoclosureso Copy-All-as-Script stops rebuilding on every render. - abgui run log —
~/Library/Logs/abgui/<verb>-<UTC>-<6 hex>.log. NewBackend/RunLog.swift(an actor) writes a self-describing header (schema, verb, abgui/abctl versions, macOS, context, workspace, the redacted command line, stdin as a byte count), the run's transcript, and an outcome/duration footer.0600in a0700directory; retention 50 files / 14 days / 20 MiB, 5 MiB per file with a[log truncated]marker; the pruner matches only abgui's own filename shape. It can never break a sync:beginreturnsnilon any failure,line(_:)is fire-and-forget, a failed write retires the log.AppModelkeeps one open at a time; the seed → diff hand-off shares one file, and the post-apply refresh opens none solastRunLogURLkeeps naming the sync being reported. Path is shown, copyable, and revealable in Finder. - Tests:
internal/reconcile/apply_test.go—TestApplyWriteConfirmation(7-case matrix: persisted → baseline takes the observed hash/timestamp; silently dropped → error + baseline unmoved; frozen echoed timestamp → still read back, and the read-back's answer decides (mismatch → error; a no-op write that already matches → confirmed); read-back error and read-back-without-XML → done-but-unverified),TestApplyCreateWriteConfirmation(a mismatch leaves no baseline entry) andTestApplyLimitWritesConfirmation.internal/cli/phase1_test.go— 8 new cases acrosswrittenConfigs, all three verify modes (including the dropped-write catch and the unverifiable-write cases) andfinishApply's receipt/exit contract.internal/cli/validate_test.go—TestValidateOuterPayloadVersion,TestValidateInnerPayloadVersionWarns,TestValidateInnerPayloadVersionUntyped. No Swift tests were added forSyncFailure/RunLogyet — both were written pure/testable for exactly that, and macOS CI remains the compile gate.
Built but NOT yet driven live / still gated out:
- The all-six-collection
sync --applypath — config upsert and configuration-membership orchestration ran live on 2026-07-05, but app/package/device/user/group reconciliation has not. The full engine is unit-tested; the remaining end-to-end check needs a portal-acquired test app and real enrolled test device. - The new v2 write verbs — blueprint lifecycle, non-config membership sync, assign/unassign, MDM-server
lifecycle: all unit-tested + gated, none has touched the tenant yet. Assign/unassign and non-config
membership additionally need a real test device (
testuser1has 0 devices). Apple documents that multiple Blueprints can target a device/user/group; live transport behavior still needs confirmation. The new read commands are safe to run anytime.
make build # → bin/abctl (version injected via ldflags)
make test # go test -race ./... (the race detector needs a C compiler / CGO)
make lint # golangci-lint
./bin/abctl --helpNo production credentials are needed to build or test — the suite mocks the API with httptest.
.env and secrets/ are gitignored and must stay that way.
- Apple Business → Settings > API → create an API account; download the private key once.
cp .env.example .env; setAB_CLIENT_IDandAB_PRIVATE_KEY(a path to the key file). SEC1 or PKCS#8 EC P-256 both work — abctl reads either, no conversion needed.abctl auth whoamito verify. No Key ID — the JWT omitskid(akid→400 invalid_client).- If the account was migrated from ABM/Essentials, grant it View/Manage Blueprints +
Create/edit device configurations and regenerate the key (else
403). If the org's built-in ("Included") MDM is not enabled, the Configurations/Blueprints endpoints return403 …INCLUDED_MDM_NOT_ENABLED— enable it in the console.
- Auth: working —
auth whoamimints a token,GET users→ 200 (TestLiveReadOnlypasses). - Config CRUD: VERIFIED LIVE (2026-07-05). Included MDM is now enabled in the console, so
/configurationsis live.TestLiveWriteRoundTrippassed against the tenant — create → download → update → download → delete a throwaway unattachedzz-*config. Confirmed live: the raw.mobileconfigGET round-trip is byte-identical (drift hash is sound) and the write response returnsupdatedDateTime(baseline stays exact). The API validator rejects an emptyPayloadContent(400 PARAMETER_ERROR) — a profile needs ≥1 real payload. - Blueprint membership: VERIFIED LIVE (2026-07-05). Using a console-created throwaway managed user
(
testuser1; the API can't mint one —POST /usersandPOST /userGroupsboth403 … does not allow 'CREATE'),TestLiveBlueprintMembership(-tags=live_blueprint,ABCTL_LIVE_BLUEPRINT=1) created a test blueprint → attached config A → attached config B → detached A → deleted everything. Confirmed:- Blueprint create needs BOTH a member AND content (
409 MISSING_RESOURCESif content-only) — the docs previously captured only the member half. relationshipsPOST MERGES (additive): POST B to{A}→{A,B}; DELETE-with-body A →{B}. So abctl'sAdd/RemoveBlueprintMembersconverge correctly. BlueprintDELETE→ 204 (immediate).- Tested on the
configurationsrelation with a member holding 0 devices (nothing deploys). The device-side "does assigning a new Blueprint reassign a device away from its current one?" question still needs a real test device. - Persistent test member:
testuser1(testuser1@gigaion.appleid.com, id001173-10-501a55ca-…) is intentionally kept in the tenant as the reusable throwaway blueprint member, soTestLiveBlueprintMembership(defaults toABCTL_TEST_USER=testuser1) can be re-run any time. The strayzz-abctl-testcustom role has been removed. Reminder: the API can't create/delete users or groups — that's console-only.
- Blueprint create needs BOTH a member AND content (
The native control plane is built; the main gap is a controlled app/device live test. Full breakdown in TODO.md. Short version:
- Run the first controlled app-to-device Blueprint test — acquire one free test app in the portal, use a
throwaway ADE device enrolled in built-in management, dry-run then attach the app and device, confirm
appLicenseDeficient == falseplus the portal/on-device result, then detach and clean up. The native app and non-config relationship code is unit-tested but has not yet touched a real managed device. - Phase 3 CI/CD — SHIPPED. Three GitOps workflows in
.github/workflows/cd-{plan,apply,drift}.yml(guide: docs/cicd.md): PR →sync --dry-runplan comment; gatedsync --applyon merge behind a protectedproductionenvironment (serialized,ABCTL_APPROVE=1, commits the baseline back with[skip ci]); daily--exit-on-diffdrift alert. Config now falls back to env vars (AB_*) when there's no.env, so CI needs no.envfile. All self-skip without secrets. To activate: adopt (un-ignore + commit)gitops/, set theAB_*Actions secrets, and create theproductionenvironment with reviewers. Remaining Phase 3: a scheduled apply that auto-commits pulled console edits back to git (needsabctlto rungit add/commititself — the merge-apply job already commits the baseline back, just not on a timer). - Blueprint membership + device moves — the code side shipped 2026-07-09 (all six collections + assign/ unassign; see the v2 block above). Apple documents multiple Blueprint targets, resolving the conceptual one-Blueprint-only question; the commands still need their first live round-trip on a throwaway device.
- Live-verify the v2 write verbs (blueprint lifecycle / non-config membership / MDM-server lifecycle) with throwaway resources, then update this file. The v2 read surface is safe immediately.
- Auth omits
kid;aud = …/oauth2/v2/token;expstrictly< iat + 180d; bearer TTL 60 min. - Only
CUSTOM_SETTINGconfigs are API-writable.customSettingsValues.configurationProfileis raw XML (not base64);GETround-trips it byte-identically → drift = raw SHA-256. - The API validates uploads (malformed →
400 PARAMETER_ERROR.INVALID; valid →201). Config CRUD:POST 201/PATCH 200/DELETE 204,Content-Type: application/json. - A
2xxis NOT proof of persistence (2026-07-25). Apple's upload validation is not exhaustive, and what it misses fails silently: the write is accepted and the profile is simply never stored — the live XML andupdatedDateTimestay unchanged, with no error anywhere. Confirmed trigger: a top-levelPayloadVersionother than1(Apple requires exactly1; TopLevel); a2reproduces it exactly. Always confirm a write by reading the configuration back — abctl does, inEngine.pushand again in--verify. - Blueprint create requires ≥1
orgDevices/users/userGroupsmember (configs alone →409 …MISSING_MEMBERS). ⇒ there is no harmless empty test Blueprint; blueprint/membership ops always target real devices/users. Config CRUD on unattached configs deploys to nobody and is the safe test path. relationshipsPOST is additive (merge), confirmed live 2026-07-05 onconfigurations; DELETE-with-body removes only the listed member. Continue converging via explicit per-member POST/DELETE. Transport behavior for app/device/user/group relationships remains unit-tested rather than live-verified.- The API rate-limits aggressively — back off (the client is Retry-After aware); avoid rapid loops. Prefer
one
fields[]list call over N per-item GETs.
Read-only by default. Every write is gated behind --apply + confirmation. --prune is off by default.
Dry-run first, always. Never commit secrets. Keep make test green.