|
| 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) |
0 commit comments