import: the host a plan lands on comes from the config
The first standing key in data/import/config.yml, and the only place
a host is named: HOST is gone from import:execute.

Assisted-by: GLM-5.3 via pi
change syvxzmtksskqnxnvrzmnvpsonltwklxo
commit b35f47ac1ab26b60f924814e9702839b5a5f1e90
author Alpha Chen <alpha@kejadlen.dev>
date
parent lwpxyooy
diff --git a/AGENTS.md b/AGENTS.md
index c4b8618..9092477 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -37,7 +37,8 @@ rake import:macos:finalize # Finalize the contacts the oldest
                       # (PLAN=dir for another; writes Contacts, so
                       # leave it to the user)
 rake import:execute   # Land the oldest plan still to land on a host
-                      # (HOST=https://contacts; PLAN=dir for another)
+                      # (host in data/import/config.yml; PLAN=dir for
+                      # another)
 rake import:status    # Summarize the plans in data/import and what
                       # execute and finalize would carry next
 rake profile:install  # Download carddav.mobileconfig from the app and
diff --git a/docs/plans/2026-09-19-import-under-data-import.md b/docs/plans/2026-09-19-import-under-data-import.md
index f480c92..2e35bde 100644
--- a/docs/plans/2026-09-19-import-under-data-import.md
+++ b/docs/plans/2026-09-19-import-under-data-import.md
@@ -12,6 +12,11 @@ data/import/
 └── done/        # plans filed away, every contact off this Mac
 ```
 
+The config's first key is `host`: the base URL `import:execute`
+lands on, and the only place a host is named — command line included —
+so a plan cannot be aimed at another server by accident. Finalize
+takes no host at all, reading the one its plan recorded.
+
 Before, plans lived in `data/imports` beside nothing. Everything the
 tasks keep now sits under the one directory, plans in flight under
 `active/` and finished under `done/` — the two states a plan's
diff --git a/tasks/import.rake b/tasks/import.rake
index 7ece2cf..a5ee47f 100644
--- a/tasks/import.rake
+++ b/tasks/import.rake
@@ -5,6 +5,9 @@
 
 require "fileutils"
 require "pathname"
+require "uri"
+
+require "pro_tacts/import/config"
 
 # The plan a task carries further. A module rather than task-file methods,
 # which rake redefines noisily when a file is loaded twice.
@@ -50,6 +53,21 @@ module ImportTasks
     FileUtils.mkdir_p(DONE)
     FileUtils.mv(plan.dir, DONE / plan.dir.basename)
   end
+
+  # The host to land a plan on: the standing `host` under CONFIG, the
+  # one place a host is named. A bare hostname is the base URL of a
+  # server that serves HTTPS, which every deployment does; the scheme
+  # is spelled out here so the host a plan records is the one a second
+  # run is compared against.
+  #: () -> String
+  def self.host
+    named = ProTacts::Import::Config.read(CONFIG).fetch("host", nil)
+    abort "no host to land on: write host: under #{CONFIG}" if named.nil?
+
+    uri = URI.parse(named)
+    uri = URI.parse("https://#{uri}") if uri.scheme.nil?
+    uri.to_s
+  end
 end
 
 namespace :import do
@@ -77,7 +95,6 @@ namespace :import do
     desc "Finalize the contacts the oldest landed plan imported, taking them off this Mac and filing the plan away done (PLAN=dir for another)"
     task :finalize do
       require "net/http"
-      require "uri"
       require "pro_tacts/import/http_client"
       require "pro_tacts/import/macos"
       require "pro_tacts/import/finalize"
@@ -135,21 +152,16 @@ namespace :import do
     puts "next: import:macos:finalize would finish #{finalizing.dir.basename}" if finalizing
   end
 
-  desc "Land the oldest plan in data/import/active still to land, or the one in PLAN, on the pro-tacts at HOST, a base URL such as https://contacts"
+  desc "Land the oldest plan in data/import/active still to land, or the one in PLAN, on the pro-tacts at the host in data/import/config.yml, a base URL such as https://contacts"
   task :execute do
     require "net/http"
-    require "uri"
     require "pro_tacts/import/execute"
     require "pro_tacts/import/http_client"
     require "pro_tacts/import/plan"
 
     plan = ImportTasks.plan(ImportTasks::TO_LAND, "to land")
-    # A bare hostname is the base URL of a server that serves HTTPS, which
-    # every deployment does; the scheme is spelled out here so the host
-    # the plan records is the one a second run is compared against.
-    uri = URI.parse(ENV.fetch("HOST"))
-    uri = URI.parse("https://#{uri}") if uri.scheme.nil?
-    host = uri.to_s
+    host = ImportTasks.host
+    uri = URI.parse(host)
     puts "landing #{plan.dir} on #{host}"
 
     Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https") do |http|
diff --git a/test/tasks/test_import.rb b/test/tasks/test_import.rb
index 6d1bb16..c9d8996 100644
--- a/test/tasks/test_import.rb
+++ b/test/tasks/test_import.rb
@@ -6,7 +6,7 @@ require "tmpdir"
 
 require "pro_tacts/import/plan"
 
-class ImportStatusTaskTest < Minitest::Test
+class ImportTasksTest < Minitest::Test
   Plan = ProTacts::Import::Plan
 
   SOURCE = {"identifier" => "A:ABPerson", "vcard" => "BEGIN:VCARD\r\nEND:VCARD\r\n", "note" => nil, "contact" => {}}
@@ -29,7 +29,7 @@ class ImportStatusTaskTest < Minitest::Test
       underway.record("vmnlryyvktux", Plan::DONE)
       Plan.write(plans / "macos-20260916T180412Z", source: "macos", created_at: Time.utc(2026, 9, 16, 18, 4, 12), entries: [entry("aaaaaaaaaaaa")])
 
-      out, = run_task(root)
+      out, = run_status(root)
 
       assert_includes out, "macos-20260901T000000Z  2/2 contacts\n"
       assert_includes out, "macos-20260916T180412Z  0/1 contacts\n"
@@ -43,22 +43,52 @@ class ImportStatusTaskTest < Minitest::Test
 
   def test_status_with_no_plans_says_how_to_build_one
     Dir.mktmpdir do |root|
-      out, = run_task(Pathname.new(root))
+      out, = run_status(Pathname.new(root))
 
       assert_includes out, "no plans in data/import/active; run rake import:macos:plan"
     end
   end
 
+  def test_the_host_comes_from_the_config_and_a_bare_hostname_gains_a_scheme
+    Dir.mktmpdir do |root|
+      root = Pathname.new(root)
+      write_config(root, "host: contacts\n")
+
+      with_import_tasks(root) do
+        assert_equal "https://contacts", ImportTasks.host
+      end
+    end
+  end
+
+  def test_no_host_in_the_config_aborts_naming_it
+    Dir.mktmpdir do |root|
+      with_import_tasks(Pathname.new(root)) do
+        _, err = capture_io { assert_raises(SystemExit) { ImportTasks.host } }
+
+        assert_equal "no host to land on: write host: under data/import/config.yml\n", err
+      end
+    end
+  end
+
   private
 
-  # Runs import:status in a Rake application of its own, from root as
-  # the working directory: the task reads data/import relative to
+  def write_config(root, content)
+    (root / "data" / "import").mkpath
+    (root / "data" / "import" / "config.yml").write(content)
+  end
+
+  def run_status(root)
+    with_import_tasks(root) { capture_io { Rake.application["import:status"].invoke } }
+  end
+
+  # Loads tasks/import.rake in a Rake application of its own, from root
+  # as the working directory: the tasks read data/import relative to
   # where rake runs, not through ProTacts.config.
-  def run_task(root)
+  def with_import_tasks(root)
     application = Rake.application
     Rake.application = Rake::Application.new
     load (Pathname.new(__dir__).parent.parent / "tasks" / "import.rake").to_s
-    Dir.chdir(root) { capture_io { Rake.application["import:status"].invoke } }
+    Dir.chdir(root) { yield }
   ensure
     Rake.application = application
   end