Skip to content

Commit 5b6abec

Browse files
Add portable Home Manager starter
1 parent 7e8df38 commit 5b6abec

3 files changed

Lines changed: 254 additions & 0 deletions

File tree

‎modules/home.nix‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
{
2+
pkgs,
3+
lib,
4+
machine,
5+
...
6+
}:
7+
{
8+
home.username = machine.username;
9+
home.homeDirectory = machine.homeDirectory;
10+
# Preserve this value for existing installations when updating packages.
11+
home.stateVersion = "26.05";
12+
programs.home-manager.enable = true;
13+
targets.genericLinux.enable = true;
14+
xdg.enable = true;
15+
home.packages = with pkgs; [
16+
ripgrep
17+
fd
18+
bat
19+
eza
20+
jq
21+
];
22+
programs.fish = lib.mkIf machine.features.fish {
23+
enable = true;
24+
shellAliases = {
25+
ll = "eza -la";
26+
gs = "git status";
27+
};
28+
};
29+
programs.starship = {
30+
enable = machine.features.fish;
31+
enableFishIntegration = machine.features.fish;
32+
settings = {
33+
add_newline = false;
34+
character.success_symbol = "[❯](bold green)";
35+
};
36+
};
37+
programs.fzf = {
38+
enable = true;
39+
enableFishIntegration = machine.features.fish;
40+
};
41+
programs.zoxide = {
42+
enable = true;
43+
enableFishIntegration = machine.features.fish;
44+
};
45+
programs.neovim = lib.mkIf machine.features.neovim {
46+
enable = true;
47+
initLua = ''
48+
vim.opt.number = true
49+
vim.opt.relativenumber = true
50+
vim.opt.expandtab = true
51+
vim.opt.shiftwidth = 2
52+
vim.opt.tabstop = 2
53+
vim.opt.ignorecase = true
54+
vim.opt.smartcase = true
55+
vim.opt.termguicolors = true
56+
vim.g.mapleader = " "
57+
'';
58+
};
59+
programs.git.enable = machine.features.development;
60+
programs.lazygit.enable = machine.features.development;
61+
}

‎scripts/bootstrap.py‎

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
#!/usr/bin/env python3
2+
"""Build a private, curated Home Manager source tree; activate only on request."""
3+
import argparse
4+
import json
5+
import os
6+
from pathlib import Path
7+
import platform
8+
import pwd
9+
import shutil
10+
import subprocess
11+
import sys
12+
import tempfile
13+
import time
14+
15+
ROOT = Path(__file__).resolve().parents[1]
16+
FEATURES = ('fish', 'neovim', 'development')
17+
18+
19+
def validate(machine):
20+
if set(machine) != {'username', 'homeDirectory', 'system', 'features'}:
21+
raise ValueError('Machine settings must contain only username, homeDirectory, system, features')
22+
if not isinstance(machine['username'], str) or not machine['username'] or '/' in machine['username']:
23+
raise ValueError('Invalid username')
24+
if not isinstance(machine['homeDirectory'], str) or not machine['homeDirectory'].startswith('/'):
25+
raise ValueError('homeDirectory must be an absolute path')
26+
if machine['system'] not in ('x86_64-linux', 'aarch64-linux'):
27+
raise ValueError('Supported architectures: x86_64-linux and aarch64-linux')
28+
if not isinstance(machine['features'], dict) or set(machine['features']) != set(FEATURES):
29+
raise ValueError('features must contain fish, neovim, development')
30+
if any(type(v) is not bool for v in machine['features'].values()):
31+
raise ValueError('Feature values must be true or false')
32+
return machine
33+
34+
35+
def stage(destination, machine):
36+
# Explicit allowlist: never send arbitrary checkout/private files to the Nix store.
37+
for name in ('flake.nix', 'flake.lock', 'machine.example.json'):
38+
shutil.copyfile(ROOT / name, destination / name)
39+
shutil.copytree(ROOT / 'modules', destination / 'modules')
40+
(destination / 'machine.json').write_text(json.dumps(validate(machine), indent=2) + '\n')
41+
42+
43+
def default_machine():
44+
return validate({
45+
'username': pwd.getpwuid(os.getuid()).pw_name,
46+
'homeDirectory': str(Path.home()),
47+
'system': platform.machine() + '-linux',
48+
'features': {'fish': True, 'neovim': True, 'development': False},
49+
})
50+
51+
52+
def main():
53+
parser = argparse.ArgumentParser(description=__doc__)
54+
parser.add_argument('--apply', action='store_true', help='build, then ask before activating')
55+
parser.add_argument('--init', action='store_true', help='create settings only; do not build')
56+
parser.add_argument('--config', type=Path, default=Path(os.environ.get('XDG_CONFIG_HOME', str(Path.home() / '.config'))) / 'commander-os/machine.json')
57+
args = parser.parse_args()
58+
if platform.system() != 'Linux' or os.geteuid() == 0:
59+
raise ValueError('Run as your normal user on Linux, without sudo')
60+
os.umask(0o077)
61+
if args.config.exists():
62+
machine = validate(json.loads(args.config.read_text()))
63+
else:
64+
machine = default_machine()
65+
args.config.parent.mkdir(parents=True, exist_ok=True)
66+
with args.config.open('x') as target:
67+
target.write(json.dumps(machine, indent=2) + '\n')
68+
print(f'Created settings: {args.config}', flush=True)
69+
print('Features: ' + ', '.join(k for k, v in machine['features'].items() if v), flush=True)
70+
if args.init:
71+
return 0
72+
if not shutil.which('nix'):
73+
print('Nix is required; Home Manager does not need to be installed first.\n'
74+
'Follow https://nixos.org/download/ for your system.\n'
75+
'For Linux with systemd and SELinux disabled, the official multi-user command is:\n'
76+
" curl --proto '=https' --tlsv1.2 -L https://nixos.org/nix/install | sh -s -- --daemon\n"
77+
'Then open a new terminal and rerun ./install.sh.', file=sys.stderr)
78+
return 1
79+
with tempfile.TemporaryDirectory(prefix='commander-os-') as directory:
80+
source = Path(directory)
81+
stage(source, machine)
82+
command = ['nix', '--extra-experimental-features', 'nix-command flakes', 'build',
83+
'--no-write-lock-file', '--no-link', '--print-out-paths',
84+
f'path:{source}#homeConfigurations.commander.activationPackage']
85+
print('Building preview; your live configuration will not change during the build.', flush=True)
86+
result = subprocess.run(command, check=True, text=True, stdout=subprocess.PIPE)
87+
package = Path(result.stdout.strip())
88+
if not package.is_absolute() or not (package / 'activate').is_file():
89+
raise RuntimeError('Nix did not return a valid activation package')
90+
print(f'Built: {package}\nManaged files: {package}/home-files', flush=True)
91+
if not args.apply:
92+
print('Preview complete. Inspect home-files, edit your settings, then run ./install.sh --apply.')
93+
return 0
94+
if machine['username'] != pwd.getpwuid(os.getuid()).pw_name or machine['homeDirectory'] != str(Path.home()):
95+
raise ValueError('Activation settings must match the current user and home directory')
96+
print('This replaces your active Home Manager configuration, if any.\n'
97+
'Existing unmanaged conflicts will receive a unique backup suffix.\n'
98+
'Review the built home-files and README rollback instructions before continuing.')
99+
if input('Type APPLY to activate: ') != 'APPLY':
100+
print('Cancelled; no activation performed.')
101+
return 0
102+
env = dict(os.environ, HOME_MANAGER_BACKUP_EXT=f'commander-os-{time.time_ns()}')
103+
subprocess.run([str(package / 'activate')], env=env, check=True)
104+
print('Activated. Start fish to try the shell; your login shell was not changed.')
105+
return 0
106+
107+
108+
if __name__ == '__main__':
109+
try:
110+
sys.exit(main())
111+
except (ValueError, RuntimeError, OSError, subprocess.CalledProcessError, EOFError, KeyboardInterrupt) as error:
112+
print(f'Commander-os: {error}', file=sys.stderr)
113+
sys.exit(1)

‎tests/test_bootstrap.py‎

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
import importlib.util
2+
import json
3+
import os
4+
from pathlib import Path
5+
import subprocess
6+
import sys
7+
import tempfile
8+
import unittest
9+
from unittest.mock import patch
10+
11+
spec = importlib.util.spec_from_file_location('bootstrap', Path(__file__).resolve().parents[1] / 'scripts/bootstrap.py')
12+
bootstrap = importlib.util.module_from_spec(spec)
13+
spec.loader.exec_module(bootstrap)
14+
15+
16+
class BootstrapTests(unittest.TestCase):
17+
def setUp(self):
18+
self.temp = tempfile.TemporaryDirectory()
19+
self.addCleanup(self.temp.cleanup)
20+
self.root = Path(self.temp.name)
21+
self.machine = json.loads((bootstrap.ROOT / 'machine.example.json').read_text())
22+
23+
def test_private_files_do_not_enter_build_source(self):
24+
source = self.root / 'checkout'
25+
source.mkdir()
26+
for name in ('flake.nix', 'flake.lock', 'machine.example.json'):
27+
(source / name).write_text('{}')
28+
(source / 'modules').mkdir()
29+
(source / '.env').write_text('fixture-secret')
30+
destination = self.root / 'build'
31+
destination.mkdir()
32+
with patch.object(bootstrap, 'ROOT', source):
33+
bootstrap.stage(destination, self.machine)
34+
self.assertFalse((destination / '.env').exists())
35+
self.assertEqual(json.loads((destination / 'machine.json').read_text()), self.machine)
36+
37+
def test_rejects_unknown_fields_and_invalid_features(self):
38+
self.machine['password'] = 'fixture'
39+
with self.assertRaises(ValueError):
40+
bootstrap.validate(self.machine)
41+
self.machine.pop('password')
42+
self.machine['features']['fish'] = 'false'
43+
with self.assertRaises(ValueError):
44+
bootstrap.validate(self.machine)
45+
46+
def run_preview(self, apply=False, answer='cancel'):
47+
config = self.root / 'machine.json'
48+
machine = bootstrap.default_machine()
49+
config.write_text(json.dumps(machine))
50+
original = config.read_bytes()
51+
package = self.root / 'package'
52+
package.mkdir()
53+
(package / 'activate').touch()
54+
argv = ['bootstrap', '--config', str(config)] + (['--apply'] if apply else [])
55+
with patch.object(sys, 'argv', argv), patch.object(bootstrap.os, 'geteuid', return_value=1000), \
56+
patch.object(bootstrap.shutil, 'which', return_value='/fixture/nix'), \
57+
patch.object(bootstrap.subprocess, 'run', return_value=subprocess.CompletedProcess([], 0, str(package))) as run, \
58+
patch('builtins.input', return_value=answer) as prompt:
59+
self.assertEqual(bootstrap.main(), 0)
60+
self.assertEqual(config.read_bytes(), original)
61+
return run.call_args_list, prompt.call_count
62+
63+
def test_preview_never_activates_or_prompts(self):
64+
calls, prompts = self.run_preview()
65+
self.assertEqual(len(calls), 1)
66+
self.assertEqual(prompts, 0)
67+
68+
def test_cancel_does_not_activate(self):
69+
calls, prompts = self.run_preview(apply=True)
70+
self.assertEqual(len(calls), 1)
71+
self.assertEqual(prompts, 1)
72+
73+
def test_confirmed_activation_uses_backup_suffix(self):
74+
calls, prompts = self.run_preview(apply=True, answer='APPLY')
75+
self.assertEqual(len(calls), 2)
76+
self.assertTrue(calls[1].kwargs['env']['HOME_MANAGER_BACKUP_EXT'].startswith('commander-os-'))
77+
78+
79+
if __name__ == '__main__':
80+
unittest.main()

0 commit comments

Comments
 (0)