Skip to content

feat(setup): two-step wizard - #52

Merged
Behnam-RK merged 6 commits into
mainfrom
feat/two-step-wizard
Sep 6, 2026
Merged

feat(setup): two-step wizard#52
Behnam-RK merged 6 commits into
mainfrom
feat/two-step-wizard

Conversation

@Behnam-RK

@Behnam-RK Behnam-RK commented Aug 21, 2026

Copy link
Copy Markdown
Owner

Stacked on #51#50#49.

Implements the two-step shape you described: step 1 countries, step 2 an automatic-detection tickbox that reveals the self-hosted options when unticked, with configureVPN dropped.

The shape

dezhban setup --questions now emits exactly:

blockedCountries | group 1 | multiselect | gate —
otherCountries   | group 1 | list        | gate —
autoMode         | group 2 | bool        | gate —
tunnels          | group 2 | multiselect | gate autoMode=false
profileFiles     | group 2 | list        | gate autoMode=false
endpoints        | group 2 | list        | gate autoMode=false

Both wizards read this one set, so dezhban setup changes with the app.

Dropping configureVPN needed two guards

That question was what kept a re-run from touching a working VPN config (Apply returned early when it was false). Removing it means a run always writes the VPN keys, so:

1. autoMode's default is seeded from the config. A config with pinned vpn.tunnelInterfaces starts on manual. Without this, a re-run would default to automatic, and Apply clears TunnelInterfaces under AutoMode on purpose — so pressing Enter through the wizard would have silently unpinned interfaces someone chose deliberately. Pinned by TestAutoModeSeedsFalseWhenInterfacesArePinned on both sides.

2. Input.Endpoints becomes *[]string, nil when the question was never shown. On macOS the endpoint question is gated behind "not automatic", so someone re-running setup to change their blocked-country list — leaving detection on, as recommended — reaches Apply with no endpoint answer at all. Writing that as an empty list would delete their server. Nil rather than empty for exactly the reason the existing AutoDiscover *bool is a pointer, and its doc comment already says so: an empty slice cannot be told apart from "asked, and cleared on purpose". The Swift side already had this property for free via shouldAsk.

One thing worth flagging

Off macOS the endpoint question is ungated. There is no live discovery on Linux or Windows, so an endpoint is required whichever detection mode is chosen — gating it would let a Linux user finish the wizard with a config that cannot enforce. TestEndpointsAreUngatedWhereThereIsNoDiscovery pins it.

The CLI renders step 2 as two prompts

A huh form binds every field before any of them is answered, so a question gated on another question in the same group would be decided by that question's seeded default rather than by what the user just ticked. The app has no such problem — it re-evaluates gates as answers change, which is what makes step 2 a single screen there.

Rather than split the shared question set to suit one renderer, the terminal now asks a group in waves: the ungated questions first, then re-evaluate, then whatever that opened up. Documented in cli.md so the difference is not a surprise.

Verification

  • go build ./..., go vet ./..., go test ./... — pass. internal/setup gains five tests: the two-group shape across all three platforms, the pinned-interface seed both ways, the unasked-endpoint rule, the gating, and the off-macOS ungating.
  • swift test — 248 tests pass (199 when this was opened; the rebase onto main and the review-loop rounds below added the rest). The SetupQuestionsTests JSON fixture is updated to the real emitted shape, and gains theWizardIsTwoSteps, anUnaskedQuestionWritesNoKey, and aSeededManualModeReWritesItsPins.
  • dezhban setup --questions output inspected directly (above).

On-host checks added to docs/contribute/testing.md, the important two being: a re-run on a pinned config keeps its pins, and a re-run under automatic detection keeps configured endpoints. Also fixes a stale defaults delete com.dezhban.menu in the first-run checklist — that domain has been com.behnam-rk.dezhban.app for a while.


Rebase onto main

This branch was stacked on #51 and carried its own copy of that work as f75954c. #51 merged as f834f8f after review, and the two are not the same change — main's version renamed removeLoginItem to retractLoginItem(), moved uninstallerPath/uninstallerCommand into DezhbanCore/UninstallDecision.swift, and dropped the Step.removed flag deliberately. The local commit was the earlier draft, so the rebase drops it and keeps main's. Only the wizard commit replays.

Review loop

Four rounds of read-only review, with every fix applied by the orchestrator and covered by a test that fails against the unfixed code.

Round Findings Pre-existing Loop-introduced Headline
1 10 10 0 The CLI wave loop's deferral never fired
2 8 4 4 The app imported profile files the user had withdrawn
3 3 2 1 The "Two steps" on-host check was written for macOS only
4 1 0 1 A comma inside an option label read as two options

Stopped after round 4: it produced no new pre-existing defects, and its single finding was the loop's own.

The two that mattered

The wave loop shipped step 2 as one form. asked[q.ID] was set inside the same pass that read asked[q.RequiresID], so a question gated on one appearing earlier in the set — which is how autoMode sits relative to its three manual fields — was never held back. The fresh-config path looked right for the wrong reason: autoMode seeds true there, so the manual fields fail their gate outright. On a re-run against a pinned config, where autoMode seeds false, all four questions arrived on one huh form — and ticking automatic detection on that form retracted the gate for the endpoint field beside it, so wasAsked returned false and the endpoint that same form had just collected was silently dropped. That is the failure Input.Endpoints became a pointer to prevent, reproduced one layer up. It also made three docs describe a two-prompt step 2 that a pinned config never got.

The app imported withdrawn profile files. Round 1 gated the Go wizard's import on "was this question shown?" and left Swift ungated, so the two wizards disagreed for identical answers. Because the app reveals step 2 in place and re-evaluates gates live, unticking automatic detection, choosing files, then ticking it again makes the field vanish while its answer stays in the dictionary — and save() ran dezhban vpn import on it.

Regressions the loop caused and then fixed

Named rather than absorbed, since they are not the branch's:

  • The Go-only import gate above, which created the Go/Swift divergence round 2 found.
  • The tunnel pick list put an undetected interface into a list headed "Detected tunnels", with no marker.
  • TestEveryGatedQuestionIsReachable as first written could not fail; its fixture answered autoMode false, which every plausible implementation gets right.
  • Printing option labels put prose containing a comma into a comma-joined list.

Settled by decision

  • On macOS, endpoints stays gated behind manual mode. A path to a hand-set server under automatic tunnel detection does exist and validates — untick automatic, leave the tunnel list blank, type the endpoint, giving tunnelInterfaces=[] plus your endpoint. vpn.autoDiscoverEndpoints defaults to true, so the gap only reaches someone who explicitly disabled discovery, and they still have config set vpn.endpoints. An OR-gate would mean adding a second condition to the shared Question JSON and honouring it in both wizards; not worth it for that case.

Not examined

  • The prose. docs/usage/cli.md, docs/contribute/testing.md and the changelog entry total roughly 90 changed lines. A reviewer summarizes prose rather than citing lines in it, so no round genuinely examined the writing. Its factual claims were checked against the code — that is what caught the macOS-only on-host check and the unqualified "two consecutive prompts" — but a human read is still outstanding.
  • internal/vpnimport, ConfigApply.apply, and the huh rendering internals, all unchanged by this branch.

Deferred to a separate PR

setup.Apply writes vpn.autoDiscoverEndpoints=true for a brand-new macOS config; Swift's configPairs never emits that key. So the app's first-run wizard and dezhban setup produce different configs on the same fresh host. Pre-existing and outside this diff, so it was left alone.

🤖 Generated with Claude Code

Countries, then one "Use automatic VPN detection?" tickbox with the manual
fields — tunnel interfaces, self-hosted config files, endpoints — hanging off
it. The opening "Configure your VPN now?" question is gone, and both wizards
read the same question set, so the CLI changes with the app.

Dropping configureVPN removes the thing that stood between a re-run and a
working config, so two guards replace it.

autoMode's default is seeded from the config: a config with pinned
vpn.tunnelInterfaces starts on manual, because Apply clears TunnelInterfaces
under AutoMode on purpose and a wizard defaulting to automatic would silently
unpin them.

Input.Endpoints becomes *[]string, nil when the question was never shown. On
macOS the endpoint question is gated behind "not automatic", so a re-run that
leaves detection on reaches Apply with no answer — writing that as an empty list
would delete the user's server. Same convention, and the same reasoning, as the
existing AutoDiscover *bool. The Swift side already had this property via
shouldAsk. Off macOS the question is ungated instead: there is no live
discovery, so an endpoint is required whichever mode is chosen.

The CLI now asks a group in waves. A huh form binds every field before any is
answered, so a question gated on another question in the same group would be
decided by that question's seeded default. Rather than split the shared question
set to suit one renderer, the terminal asks the ungated questions, re-evaluates,
and asks whatever that opened up — the app shows step 2 as one screen because it
re-evaluates gates as answers change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK
Behnam-RK force-pushed the feat/two-step-wizard branch from 496be74 to 3e2fa83 Compare September 5, 2026 06:29
Behnam-RK and others added 5 commits September 5, 2026 10:03
Seeding autoMode to false from pinned vpn.tunnelInterfaces closes only
half of "a re-run clicked straight through preserves your pins". The
other half is the pick list it lands on: tunnelQuestion built its options
from DetectedTunnels alone, and detection only sees tunnels that are UP.

So a re-run while the VPN is down offered a list the configured interface
was not in, with nothing preselected. Pressing Enter through that answers
"none of them", which Apply writes as an empty vpn.tunnelInterfaces —
silently unpinning an interface someone chose deliberately, which is the
exact failure the autoMode seed exists to prevent.

Configured interfaces are now options in their own right, appended after
the detected ones and deduplicated, and preselected as before.

TestAPinnedTunnelSurvivesADetectionMiss checks both the question shape and
that the whole click-through is a no-op; it fails on the unfixed code with
vpn.tunnelInterfaces changing from "utun9" to "".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The wave loop's deferral never fired. It marked a question asked inside
the same selection pass that read `asked[q.RequiresID]`, so a question
gated on one that appears EARLIER in the question set — the normal way to
write one, and how autoMode sits relative to its three manual fields — saw
its gate already marked and was never held back.

It went unnoticed because the fresh-config path looks right for the wrong
reason: autoMode seeds to true there, so the manual fields fail their gate
outright and the loop happens to produce two waves. The bug shows on a
re-run against a pinned config, where autoMode seeds to FALSE and so every
manual field satisfies its gate before the user has touched anything: all
four questions arrive on one huh form. Ticking "use automatic detection"
on that form then retracts the gate for the endpoint field beside it, so
`wasAsked` returns false and the endpoint answer the same form collected
is silently dropped — the failure `Input.Endpoints` became a pointer to
prevent, one layer up. It also made three docs describe a two-prompt step
2 that a pinned config never got.

Selection is now a plain function over (questions, answers) that treats
`asked` as read-only and leaves marking to the caller, which is both the
fix and the only reason it is testable without driving a terminal.

Also from the same review:

- Deferring now requires the gate question to be genuinely still coming.
  A question gated on a same-group question that this run will never show
  would otherwise wait for a wave that never arrives, and be silently
  dropped when the loop ran out of fields. Latent at today's depth of one.
- The profile-file import reads its answer only when that question was
  shown, matching the macOS app, whose profileFiles is empty for the same
  reason.
- The two bare blocks left where `if answers.Bool("configureVPN")` was
  removed are unindented, and a doc comment naming that dead question is
  corrected.

TestStepTwoArrivesInWaves pins wave membership for both seedings and fails
on the old code with [[autoMode tunnels profileFiles endpoints]] where two
waves were wanted. TestEveryGatedQuestionIsReachable pins that nothing is
stranded. cmd/dezhban had no test for this loop at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 2 of the review loop, and the headline finding is an asymmetry the
loop itself created: gating the Go wizard's profile-file import on
"was this question shown?" left the macOS app ungated, so the two wizards
disagreed for identical answers.

The app reveals step 2 in place and re-evaluates gates live, so unticking
automatic detection, choosing files, then ticking it again makes the field
vanish while the answer it collected stays in the dictionary. `save()` then
ran `dezhban vpn import` on files the user had visibly withdrawn, which the
CLI no longer does. `profileFiles` is now `profileFiles(for:)` and returns
nothing unless its question is being asked, matching `configPairs`, which
has always skipped an unasked key. The property's doc comment claimed this
behaviour already; now it is true.

Also from round 2:

- The tunnel pick list labels an interface it did not detect. A list headed
  "Detected tunnels" with a ticked interface that is plainly down reads as a
  broken detector; the label now says "(configured, not up right now)" and
  the description explains it. Values are untouched, so nothing changes in
  what gets written.
- Two comments claimed more than the code delivers, both about gate depth.
  `stillToAsk` treats a gate question that will never be shown as fixed at
  its seed, which is only sound while gates are one deep; `wasAsked` stands
  in "gate satisfied by the final answers" for "the user saw it", which
  holds only because no gate points at a later group. Both are properties of
  the question set rather than of those functions, so
  TestGatesAreShallowAndPointBackwards pins the shape across all three
  platforms and both comments now say what is actually relied on.
- TestEveryGatedQuestionIsReachable could not fail — its fixture answered
  autoMode false, which every plausible implementation gets right. It is
  joined by TestTickingAutomaticRetractsTheManualFields, which drives the
  flip and asserts the three manual questions are neither asked nor written.
- TestLeavingAutomaticOnKeepsAConfiguredEndpoint drives the PR's headline
  guarantee through Answers instead of a hand-built Input. The existing
  TestAnUnaskedEndpointListTouchesNoEndpoint pinned Apply's half with a nil
  Endpoints but never showed the wizard producing that nil.
- cli.md and the changelog said step 2 arrives as two prompts without
  qualification; that is only so when you untick automatic detection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 3 of the review loop. No defects in the wizard itself this time; the
findings are about what the branch tells people it does.

- The "Two steps" on-host check sits in the cross-platform section but
  describes macOS behaviour: it says leaving automatic detection ticked ends
  the wizard, which off macOS is false. There is no live discovery there, so
  the endpoint question is ungated and rides on the first prompt beside the
  tickbox, and unticking brings only tunnel interfaces and config files. A
  tester on Linux would have marked correct code as failed. The item now
  splits the two platforms.
- The changelog bullet claimed "a question that is not asked writes no key"
  without the exception it shares a paragraph with: choosing automatic
  detection deliberately CLEARS pinned interfaces, because a leftover pin is
  what stops autodetection happening. cli.md already stated this; the
  changelog now does too.
- `setup --questions` printed option values only, so the "(configured, not up
  right now)" label added last round was visible in --json and invisible in
  the plain text a human actually reads to answer "why is that interface on
  the list?". Options now render their label where it adds anything, which
  also turns the country list from "IR, RU, CN" into "Iran (IR), Russia (RU)".
  Nothing pins that output and the app reads --json, so this is display only.

TestOffMacOSTheEndpointQuestionRidesTheFirstWave closes the coverage gap the
same round named: every wave test was GOOS darwin, leaving the platform where
the wave shape actually differs unpinned.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Round 4, and the only finding is the loop's own. Printing option labels
last round put prose into a comma-joined list, and the one multi-word
label in the tree contains a comma: a re-run with the VPN down printed

    options: utun0, utun4, utun9 (configured, not up right now)

which reads as four options, one of them an interface named
"utun9 (configured". Joining with semicolons fixes the class rather than
this instance, so a future label carrying a comma cannot reintroduce it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Behnam-RK Behnam-RK added the run-ci Run CI on this PR (gates .github/workflows/ci.yml) label Sep 6, 2026
@Behnam-RK
Behnam-RK merged commit 9f73c8b into main Sep 6, 2026
11 checks passed
@Behnam-RK
Behnam-RK deleted the feat/two-step-wizard branch September 6, 2026 05:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

run-ci Run CI on this PR (gates .github/workflows/ci.yml)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant