Provision the macOS account with a configuration profile
rake profile renders carddav.mobileconfig (com.apple.carddav.account
payload, keys verified against the device-management reference, which
allows manual install so no MDM is involved). Fixed payload
identifiers make reinstall replace in place and removal
deterministic, so the remove-and-re-add rounds of the minimization
loop are one command each way.

Assisted-by: GLM 5.2 via pi
change wypklyxoypquukuqmnqzumzlrsnrwrty
commit ce945654b409e6df42f88853b5711579e9ff1640
author Alpha Chen <alpha@kejadlen.dev>
date
parent nyslmutr
diff --git a/.gitignore b/.gitignore
index 0e3001d..2b5a195 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,3 @@
 /log
 /servers
+/carddav.mobileconfig
diff --git a/Rakefile b/Rakefile
index 09b9c1a..a7a0989 100644
--- a/Rakefile
+++ b/Rakefile
@@ -18,4 +18,24 @@ task :fixtures do
   ExchangeFixtures.record_responses(ProTacts::Web)
 end
 
+desc "Generate carddav.mobileconfig to provision the macOS account"
+task :profile do
+  $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
+  require "pro_tacts/profile"
+
+  File.write("carddav.mobileconfig", ProTacts::Profile.render(
+    hostname: ENV.fetch("PRO_TACTS_HOSTNAME"),
+    username: ENV.fetch("PRO_TACTS_USERNAME", "a@b.com"),
+    password: ENV.fetch("PRO_TACTS_PASSWORD", "a")
+  ))
+  identifier = ProTacts::Profile::PAYLOAD_IDENTIFIER
+  puts <<~MESSAGE
+    Wrote carddav.mobileconfig. Install:
+      sudo profiles install -type configuration -path carddav.mobileconfig
+    Remove:
+      sudo profiles remove -identifier #{identifier}
+    Recent macOS may ask you to approve the profile in System Settings → Profiles.
+  MESSAGE
+end
+
 task default: :test
diff --git a/docs/macos-contacts.md b/docs/macos-contacts.md
index 034d6df..505f514 100644
--- a/docs/macos-contacts.md
+++ b/docs/macos-contacts.md
@@ -26,6 +26,24 @@ refusing a redirect) that never reach the server at all.
 
 ## The account setup path
 
+The fastest path is a configuration profile: `rake profile` (with
+`PRO_TACTS_HOSTNAME` set) writes `carddav.mobileconfig`, then
+
+```sh
+sudo profiles install -type configuration -path carddav.mobileconfig
+sudo profiles remove -identifier dev.kejadlen.pro-tacts.carddav
+```
+
+adds and removes the account. The profile carries the hostname,
+credentials, and SSL — `CardDAVPrincipalURL` is deliberately omitted so the
+account gets an empty Server Path, exercising discovery. Fixed payload
+identifiers mean a reinstall replaces the account in place, and removal is
+what resets the client's cached discovery results. Recent macOS may stage
+the profile as pending until you approve it once in System Settings →
+Profiles. Apple's device-management reference marks the CardDAV payload as
+allowing manual install, so no MDM is involved.
+
+The manual alternative, for cross-checking when the profile path misbehaves:
 In System Settings, add the account under Internet Accounts, Add Other
 Account, CardDAV account, with Account Type set to Manual. Manual matters:
 automatic setup runs its own discovery and fails in ways that are harder to
diff --git a/lib/pro_tacts/profile.rb b/lib/pro_tacts/profile.rb
new file mode 100644
index 0000000..6076fb9
--- /dev/null
+++ b/lib/pro_tacts/profile.rb
@@ -0,0 +1,87 @@
+# frozen_string_literal: true
+
+require "nokogiri"
+
+module ProTacts
+  # Renders the configuration profile that provisions the pro-tacts CardDAV
+  # account on macOS, so the resync loop is `rake profile` plus one
+  # `profiles install` instead of the Internet Accounts dance. Payload keys
+  # per Apple's Device Management reference for com.apple.carddav.account.
+  #
+  # UUIDs are fixed so reinstalling replaces the profile in place and
+  # `profiles remove -identifier` always targets the same one.
+  class Profile
+    PAYLOAD_IDENTIFIER = "dev.kejadlen.pro-tacts.carddav"
+    TOP_LEVEL_UUID = "6F1E2D3C-4B5A-4E7F-8C9D-0A1B2C3D4E5F"
+    PAYLOAD_UUID = "7A2F3E4D-5C6B-4F80-9DAE-1B2C3D4E5F6A"
+
+    def self.render(hostname:, username:, password:)
+      template % {
+        hostname: escape(hostname),
+        username: escape(username),
+        password: escape(password)
+      }
+    end
+
+    def self.template
+      <<~XML
+        <?xml version="1.0" encoding="UTF-8"?>
+        <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+        <plist version="1.0">
+        <dict>
+          <key>PayloadContent</key>
+          <array>
+            <dict>
+              <key>PayloadType</key>
+              <string>com.apple.carddav.account</string>
+              <key>PayloadVersion</key>
+              <integer>1</integer>
+              <key>PayloadIdentifier</key>
+              <string>#{PAYLOAD_IDENTIFIER}.account</string>
+              <key>PayloadUUID</key>
+              <string>#{PAYLOAD_UUID}</string>
+              <key>PayloadDisplayName</key>
+              <string>pro-tacts</string>
+              <key>PayloadOrganization</key>
+              <string>pro-tacts</string>
+              <key>CardDAVAccountDescription</key>
+              <string>pro-tacts</string>
+              <key>CardDAVHostName</key>
+              <string>%{hostname}</string>
+              <key>CardDAVUsername</key>
+              <string>%{username}</string>
+              <key>CardDAVPassword</key>
+              <string>%{password}</string>
+              <key>CardDAVUseSSL</key>
+              <true/>
+            </dict>
+          </array>
+          <key>PayloadDisplayName</key>
+          <string>pro-tacts CardDAV</string>
+          <key>PayloadIdentifier</key>
+          <string>#{PAYLOAD_IDENTIFIER}</string>
+          <key>PayloadOrganization</key>
+          <string>pro-tacts</string>
+          <key>PayloadRemovalDisallowed</key>
+          <false/>
+          <key>PayloadScope</key>
+          <string>User</string>
+          <key>PayloadType</key>
+          <string>Configuration</string>
+          <key>PayloadUUID</key>
+          <string>#{TOP_LEVEL_UUID}</string>
+          <key>PayloadVersion</key>
+          <integer>1</integer>
+        </dict>
+        </plist>
+      XML
+    end
+
+    # CardDAVPrincipalURL is omitted on purpose: no Server Path, matching
+    # the bare-hostname setup the working session used.
+
+    def self.escape(text)
+      text.gsub("&", "&amp;").gsub("<", "&lt;").gsub(">", "&gt;")
+    end
+  end
+end
diff --git a/test/pro_tacts/test_profile.rb b/test/pro_tacts/test_profile.rb
new file mode 100644
index 0000000..004c68c
--- /dev/null
+++ b/test/pro_tacts/test_profile.rb
@@ -0,0 +1,48 @@
+# frozen_string_literal: true
+
+require_relative "../test_helper"
+
+require "pro_tacts/profile"
+
+class ProfileTest < Minitest::Test
+  def render(hostname: "example.ts.net", username: "a@b.com", password: "a")
+    ProTacts::Profile.render(hostname:, username:, password:)
+  end
+
+  def test_is_well_formed_xml
+    refute_nil Nokogiri::XML(render).root
+  end
+
+  def test_carries_the_carddav_payload_type
+    assert_includes render, "<string>com.apple.carddav.account</string>"
+  end
+
+  def test_embeds_credentials_and_hostname
+    xml = render
+
+    assert_includes xml, "<string>example.ts.net</string>"
+    assert_includes xml, "<string>a@b.com</string>"
+    assert_includes xml, "<string>a</string>"
+  end
+
+  def test_enables_ssl
+    assert_match(/<key>CardDAVUseSSL<\/key>\s*<true\/>/, render)
+  end
+
+  def test_omits_principal_url_to_leave_server_path_empty
+    refute_includes render, "CardDAVPrincipalURL"
+  end
+
+  def test_identifiers_are_stable_across_renders
+    assert_equal render, render
+    assert_includes render, ProTacts::Profile::PAYLOAD_IDENTIFIER
+  end
+
+  def test_escapes_xml_in_field_values
+    xml = render(hostname: "a&b.ts.net")
+
+    assert_includes xml, "<string>a&amp;b.ts.net</string>"
+    refute_includes xml, "<string>a&b.ts.net</string>"
+    assert_empty Nokogiri::XML(xml).errors
+  end
+end