feat(setup): two-step wizard - #52
Merged
Merged
Conversation
This was referenced Aug 21, 2026
Behnam-RK
force-pushed
the
feat/two-step-wizard
branch
from
August 22, 2026 07:11
76b09e9 to
496be74
Compare
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
force-pushed
the
feat/two-step-wizard
branch
from
September 5, 2026 06:29
496be74 to
3e2fa83
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
configureVPNdropped.The shape
dezhban setup --questionsnow emits exactly:Both wizards read this one set, so
dezhban setupchanges with the app.Dropping
configureVPNneeded two guardsThat question was what kept a re-run from touching a working VPN config (
Applyreturned 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 pinnedvpn.tunnelInterfacesstarts on manual. Without this, a re-run would default to automatic, andApplyclearsTunnelInterfacesunderAutoModeon purpose — so pressing Enter through the wizard would have silently unpinned interfaces someone chose deliberately. Pinned byTestAutoModeSeedsFalseWhenInterfacesArePinnedon both sides.2.
Input.Endpointsbecomes*[]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 — reachesApplywith no endpoint answer at all. Writing that as an empty list would delete their server. Nil rather than empty for exactly the reason the existingAutoDiscover *boolis 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 viashouldAsk.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.
TestEndpointsAreUngatedWhereThereIsNoDiscoverypins it.The CLI renders step 2 as two prompts
A
huhform 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.mdso the difference is not a surprise.Verification
go build ./...,go vet ./...,go test ./...— pass.internal/setupgains 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). TheSetupQuestionsTestsJSON fixture is updated to the real emitted shape, and gainstheWizardIsTwoSteps,anUnaskedQuestionWritesNoKey, andaSeededManualModeReWritesItsPins.dezhban setup --questionsoutput 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.menuin the first-run checklist — that domain has beencom.behnam-rk.dezhban.appfor a while.Rebase onto main
This branch was stacked on #51 and carried its own copy of that work as
f75954c. #51 merged asf834f8fafter review, and the two are not the same change — main's version renamedremoveLoginItemtoretractLoginItem(), moveduninstallerPath/uninstallerCommandintoDezhbanCore/UninstallDecision.swift, and dropped theStep.removedflag 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.
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 readasked[q.RequiresID], so a question gated on one appearing earlier in the set — which is howautoModesits relative to its three manual fields — was never held back. The fresh-config path looked right for the wrong reason:autoModeseeds true there, so the manual fields fail their gate outright. On a re-run against a pinned config, whereautoModeseeds false, all four questions arrived on onehuhform — and ticking automatic detection on that form retracted the gate for the endpoint field beside it, sowasAskedreturned false and the endpoint that same form had just collected was silently dropped. That is the failureInput.Endpointsbecame 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()randezhban vpn importon it.Regressions the loop caused and then fixed
Named rather than absorbed, since they are not the branch's:
TestEveryGatedQuestionIsReachableas first written could not fail; its fixture answeredautoModefalse, which every plausible implementation gets right.Settled by decision
endpointsstays 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, givingtunnelInterfaces=[]plus your endpoint.vpn.autoDiscoverEndpointsdefaults to true, so the gap only reaches someone who explicitly disabled discovery, and they still haveconfig set vpn.endpoints. An OR-gate would mean adding a second condition to the sharedQuestionJSON and honouring it in both wizards; not worth it for that case.Not examined
docs/usage/cli.md,docs/contribute/testing.mdand 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 thehuhrendering internals, all unchanged by this branch.Deferred to a separate PR
setup.Applywritesvpn.autoDiscoverEndpoints=truefor a brand-new macOS config; Swift'sconfigPairsnever emits that key. So the app's first-run wizard anddezhban setupproduce different configs on the same fresh host. Pre-existing and outside this diff, so it was left alone.🤖 Generated with Claude Code