Custom remote templates in "Start from scratch" + allow subfolders in home#473
Conversation
Nix users reflexively type nix-style refs. Both parse_repo_ref and its frontend mirror parseFlakeRef now strip a leading 'github:' as sugar for the plain shorthand, including query params. The path-segment ref form (github:owner/repo/<ref>) stays rejected, now with an error pointing at ?ref=<ref>; attribute suffixes (#attr) stay rejected too. The sugar deliberately applies everywhere the parser is used — imports today, and the upcoming create-from-template flow.
scaffold_template owns the whole write path — destination-safety check, template copy with placeholder substitution, git init, tagged initial commit — with no AppHandle, so a remote-template caller (and unit tests) can drive it directly. bootstrap_with_template delegates; behavior-neutral for bundled templates. copy_template_dir is hardened for untrusted sources ahead of remote templates: .git and .DS_Store are never copied, and symlinks are skipped (with a warning) instead of followed — following them would let a hostile template recurse forever or pull out-of-tree file contents into a config the user may later publish. The hostname is validated at the scaffold boundary since placeholder substitution splices it into file names that pass through Path::join; a crafted value from the raw bootstrapDefault endpoint could previously escape the destination. finalize_flake_lock treats 'nothing to commit' as success: a template may ship a complete flake.lock that nix flake lock leaves untouched.
New config.createFromTemplate route: clones the referenced repository (e.g. github:owner/repo?dir=templates/mac) into a temporary directory and scaffolds the referenced template directory into a fresh config dir — placeholders rendered, fresh git history. Unlike imports, the template's own git history is deliberately not inherited; that is the point of a template. The command is atomic, modeled on config_import_github: the target is prepared, the clone and flake.nix validation happen against the tempdir, and the config dir is only selected on success (a failed clone or validation cleans the target and never advances onboarding). Missing or wrong ?dir= errors list the flake locations actually found in the repository. The GitHub App clone token applies, so private template repos work. flake.lock is finalized non-fatally afterwards, mirroring the bundled bootstrap; a template may ship its own lock. No host attribute is set: third-party templates may not use HOSTNAME_PLACEHOLDER, so the Choose Machine step resolves the real hosts instead.
Pure move of the ref input + live parse-hint block out of flake-ref-source into controls/repo-ref-input, so the upcoming custom-template create flow can reuse it without diverging validation UX.
Start from scratch gains a fourth choice: a Custom template card that accepts the same repository-ref syntax as imports (owner/repo, github:owner/repo, ?ref=, ?dir=) via the shared RepoRefInput, and scaffolds through the atomic config.createFromTemplate — files copied, placeholders rendered, fresh git history, no inheritance of the template's own history. This restores the pre-non-root-flake workflow of using a repo subdirectory as a starting point, in the path that matches the intent. Because the command only selects the config dir on success, clone and validation errors render inline on the still-mounted create screen — unlike the bundled prepareNewDir+bootstrap split, which advances the step machine before scaffolding. The create button stays gated on a valid ref while the custom card is selected; the bundled default selection and the create-default-config-button testid are unchanged for the wdio flow.
What template authors can rely on: placeholder substitution rules (contents vs file names), fresh-history semantics vs imports, symlink and VCS-metadata handling, shipped flake.lock behavior, and the supported reference syntax including the github: sugar.
📋 PR Overview
🔬 Coverage
|
🎨 Storybook previewUpdated for 5eed294
|
…ized A remote template that consumes the hostname placeholder (in .nix contents or file names) follows the template conventions, so darwinConfigurations.<chosen-name> exists after rendering — adopt the chosen name as the host attribute directly, giving convention-following templates the same skip-Choose-Machine UX as bundled ones. Templates that skip the placeholder still leave the host attribute empty and route through the host picker; no nix eval is needed either way. The scaffold copy now reports placeholder consumption (ScaffoldOutcome), and the backend owns the host attribute for template creates end to end — the frontend no longer resets it after createFromTemplate, which would have wiped the adoption.
Destinations for imports and new configurations were restricted to direct children of the home directory, so '~/tmp/nix-darwin' failed with 'Use a directory name, not a path' — a message written when the input really was just a folder name. The UIs now take full paths, so validate_new_dir_location accepts any path inside home (missing parents are created; the canonical /etc/nix-darwin stays allowed). Parent-dir segments are still rejected since the comparison is lexical. The destination fields now say that ~ paths work and that missing folders are created.
… rule Main (#473) relaxed validate_new_dir_location to allow nested config dirs under home, which the PR merge build combines with this branch — asserting that a nested home path is not wipeable now fails there. The guard test keeps only the invariants that are its own: paths outside home are never wipeable, and the active config is never deleted from under itself. How deep an import target may nest is validate_new_dir_location's contract, tested where it lives.
… rule Main (#473) relaxed validate_new_dir_location to allow nested config dirs under home, which the PR merge build combines with this branch — asserting that a nested home path is not wipeable now fails there. The guard test keeps only the invariants that are its own: paths outside home are never wipeable, and the active config is never deleted from under itself. How deep an import target may nest is validate_new_dir_location's contract, tested where it lives.





Summary
#470 changed
?dir=imports to clone the whole repo and point the config dir at the subdirectory — right for imports (preserves.git/origin/sync), but it removed the workflow of usinggithub:user/repo?dir=<path-to-template>as a template (copy the files, no history). As discussed, that use-case belongs in "Start from scratch", not import: importing adopts a repo's identity; a template only seeds a new one.What
"Start from scratch" gains a Custom template card accepting the same repo-ref syntax as imports (
owner/repo,github:owner/repo, git URLs,?ref=,?dir=), with template semantics:flake.nix— is copied into a fresh config dir;HOSTNAME_PLACEHOLDERetc. in.nixcontents,{{hostname}}/placeholders in all file names);nixmac-base-<hash>,flake.lockfinalized non-fatally (a shipped lock is kept — the author's pins);?dir=errors list the flake locations actually found in the repo.Additionally, this allows when importing a template to specify a subfolder in home of arbitrary depths (e.g.
~/configs/nix-darwin) which before was not allowed.Design notes
config.createFromTemplate): clone + validation happen before the config dir is selected, so failures surface inline on the still-mounted create screen instead of advancing onboarding to a broken state (the bundledprepareNewDir→bootstrapsplit would setconfigDirbefore the slow clone).nix evalprobe runs inside the create spinner; the backend owns the host attribute for template creates end to end..git/.DS_Storeand symlinks (a hostile template could previously loop forever vialn -s . selfor copy out-of-tree file contents into a repo the user may later push), and the hostname is validated at the scaffold boundary — it is spliced into file names throughPath::join, so a crafted value via the rawflake.bootstrapDefaultendpoint could escape the destination.github:owner/repois now accepted as sugar by both ref parsers (imports too);github:o/r/<ref>errors with a pointer to?ref=.Follow-ups (out of scope)
Docs