boxman 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
boxman/__init__.py ADDED
File without changes
boxman/cli.py ADDED
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env python3
2
+ """Set up and manage a Linux development box."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+
12
+ def fail(message: str) -> None:
13
+ raise SystemExit(message)
14
+
15
+
16
+ def run(*args: str) -> None:
17
+ result = subprocess.run(args, check=False)
18
+ if result.returncode:
19
+ raise SystemExit(result.returncode)
20
+
21
+
22
+ def mounted(path: Path) -> bool:
23
+ return subprocess.run(
24
+ ("mountpoint", "-q", str(path)),
25
+ stdout=subprocess.DEVNULL,
26
+ stderr=subprocess.DEVNULL,
27
+ check=False,
28
+ ).returncode == 0
29
+
30
+
31
+ def vault_paths() -> tuple[Path, Path]:
32
+ home = Path.home()
33
+ cipher, plain = home / ".private.cipher", home / "private"
34
+ if cipher.is_symlink() or plain.is_symlink():
35
+ fail("Private vault paths must not be symlinks")
36
+ return cipher, plain
37
+
38
+
39
+ def private_dir(path: Path) -> None:
40
+ path.mkdir(mode=0o700, parents=True, exist_ok=True)
41
+ path.chmod(0o700)
42
+
43
+
44
+ def vault(action: str) -> None:
45
+ if os.geteuid() == 0:
46
+ fail("Run vault commands as your login user, not root")
47
+ os.umask(0o077)
48
+ cipher, plain = vault_paths()
49
+
50
+ if action == "status":
51
+ print("Private vault is unlocked" if mounted(plain) else "Private vault is locked")
52
+ elif action == "init":
53
+ if mounted(plain):
54
+ fail("Private vault is already unlocked")
55
+ if (cipher / "gocryptfs.conf").exists():
56
+ fail("Private vault is already initialized")
57
+ private_dir(cipher)
58
+ private_dir(plain)
59
+ if any(cipher.iterdir()):
60
+ fail("Encrypted backing directory is not empty")
61
+ if any(plain.iterdir()):
62
+ fail("Plaintext mount directory is not empty")
63
+ run("gocryptfs", "-init", str(cipher))
64
+ print("Keep the password and recovery key outside this host.")
65
+ elif action == "unlock":
66
+ if not (cipher / "gocryptfs.conf").is_file():
67
+ fail("Private vault is not initialized; run boxman vault init")
68
+ private_dir(cipher)
69
+ private_dir(plain)
70
+ if mounted(plain):
71
+ print("Private vault is already unlocked")
72
+ return
73
+ if any(plain.iterdir()):
74
+ fail("Plaintext mount directory is not empty; refusing to hide its files")
75
+ run("gocryptfs", str(cipher), str(plain))
76
+ elif action == "lock":
77
+ if not mounted(plain):
78
+ print("Private vault is already locked")
79
+ return
80
+ run("fusermount3", "-u", str(plain))
81
+ else:
82
+ fail("Usage: boxman vault {init|unlock|lock|status}")
83
+
84
+
85
+ def claude(args: list[str]) -> None:
86
+ if os.geteuid() == 0:
87
+ fail("Run Claude as your login user, not root")
88
+ _, plain = vault_paths()
89
+ if not mounted(plain):
90
+ fail("Private vault is locked; run boxman vault unlock first")
91
+ config = plain / "claude"
92
+ if config.is_symlink():
93
+ fail("Claude config directory must not be a symlink")
94
+ os.umask(0o077)
95
+ private_dir(config)
96
+ os.environ["CLAUDE_CONFIG_DIR"] = str(config)
97
+ os.execvp("claude", ["claude", *args])
98
+
99
+
100
+ def main() -> None:
101
+ if len(sys.argv) < 2:
102
+ fail("Usage: boxman {system|user|vault|claude|verify|ec2} ...")
103
+ command, *args = sys.argv[1:]
104
+ if command == "system":
105
+ from boxman import system
106
+
107
+ system.main(args)
108
+ elif command == "user":
109
+ if args:
110
+ fail("Usage: boxman user")
111
+ from boxman import user
112
+
113
+ user.main()
114
+ elif command == "vault":
115
+ if len(args) != 1:
116
+ fail("Usage: boxman vault {init|unlock|lock|status}")
117
+ vault(args[0])
118
+ elif command == "claude":
119
+ claude(args)
120
+ elif command == "ec2":
121
+ from boxman import ec2
122
+
123
+ ec2.main(args)
124
+ elif command == "verify":
125
+ if args:
126
+ fail("Usage: boxman verify")
127
+ from boxman import verify
128
+
129
+ verify.main()
130
+ else:
131
+ fail("Usage: boxman {system|user|vault|claude|verify|ec2} ...")
132
+
133
+
134
+ if __name__ == "__main__":
135
+ main()
File without changes
@@ -0,0 +1,126 @@
1
+ AWSTemplateFormatVersion: "2010-09-09"
2
+ Description: >
3
+ EC2 development box:
4
+ one instance, no inbound security-group rules, SSM-only access.
5
+
6
+ Parameters:
7
+ VpcId:
8
+ Type: AWS::EC2::VPC::Id
9
+ SubnetId:
10
+ Type: AWS::EC2::Subnet::Id
11
+ InstanceType:
12
+ Type: String
13
+ VolumeSizeGb:
14
+ Type: Number
15
+ InstanceName:
16
+ Type: String
17
+ LatestAmiId:
18
+ Type: AWS::SSM::Parameter::Value<AWS::EC2::Image::Id>
19
+
20
+ Resources:
21
+ SecurityGroup:
22
+ Type: AWS::EC2::SecurityGroup
23
+ Properties:
24
+ GroupName: !Sub "${AWS::StackName}-sg"
25
+ GroupDescription: Shared coding-agent dev box - no inbound rules, SSM only
26
+ VpcId: !Ref VpcId
27
+ Tags:
28
+ - Key: Name
29
+ Value: !Sub "${AWS::StackName}-sg"
30
+ # No SecurityGroupIngress here: no inbound rules from the internet, on
31
+ # purpose. The only inbound access is via SecurityGroupIngressFromEice
32
+ # below, scoped to the Instance Connect Endpoint's own security group.
33
+ # No SecurityGroupEgress: keeps CloudFormation's default allow-all outbound.
34
+
35
+ EiceSecurityGroup:
36
+ Type: AWS::EC2::SecurityGroup
37
+ Properties:
38
+ GroupName: !Sub "${AWS::StackName}-eice-sg"
39
+ GroupDescription: EC2 Instance Connect Endpoint - no inbound rules needed
40
+ VpcId: !Ref VpcId
41
+ Tags:
42
+ - Key: Name
43
+ Value: !Sub "${AWS::StackName}-eice-sg"
44
+
45
+ SecurityGroupIngressFromEice:
46
+ Type: AWS::EC2::SecurityGroupIngress
47
+ Properties:
48
+ GroupId: !Ref SecurityGroup
49
+ IpProtocol: tcp
50
+ FromPort: 22
51
+ ToPort: 22
52
+ SourceSecurityGroupId: !Ref EiceSecurityGroup
53
+ Description: SSH from the EC2 Instance Connect Endpoint only
54
+
55
+ InstanceConnectEndpoint:
56
+ Type: AWS::EC2::InstanceConnectEndpoint
57
+ Properties:
58
+ SubnetId: !Ref SubnetId
59
+ SecurityGroupIds:
60
+ - !Ref EiceSecurityGroup
61
+ PreserveClientIp: true
62
+ Tags:
63
+ - Key: Name
64
+ Value: !Sub "${AWS::StackName}-eice"
65
+
66
+ InstanceRole:
67
+ Type: AWS::IAM::Role
68
+ Properties:
69
+ RoleName: !Sub "${AWS::StackName}-ssm-role"
70
+ AssumeRolePolicyDocument:
71
+ Version: "2012-10-17"
72
+ Statement:
73
+ - Effect: Allow
74
+ Principal:
75
+ Service: ec2.amazonaws.com
76
+ Action: sts:AssumeRole
77
+ ManagedPolicyArns:
78
+ - arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore
79
+ Tags:
80
+ - Key: Name
81
+ Value: !Sub "${AWS::StackName}-ssm-role"
82
+
83
+ InstanceProfile:
84
+ Type: AWS::IAM::InstanceProfile
85
+ Properties:
86
+ InstanceProfileName: !Sub "${AWS::StackName}-ssm-profile"
87
+ Roles:
88
+ - !Ref InstanceRole
89
+
90
+ Instance:
91
+ Type: AWS::EC2::Instance
92
+ Properties:
93
+ ImageId: !Ref LatestAmiId
94
+ InstanceType: !Ref InstanceType
95
+ SubnetId: !Ref SubnetId
96
+ SecurityGroupIds:
97
+ - !Ref SecurityGroup
98
+ IamInstanceProfile: !Ref InstanceProfile
99
+ BlockDeviceMappings:
100
+ - DeviceName: /dev/sda1
101
+ Ebs:
102
+ VolumeSize: !Ref VolumeSizeGb
103
+ VolumeType: gp3
104
+ DeleteOnTermination: true
105
+ MetadataOptions:
106
+ HttpTokens: required
107
+ HttpPutResponseHopLimit: 2
108
+ Tags:
109
+ - Key: Name
110
+ Value: !Ref InstanceName
111
+
112
+ Outputs:
113
+ InstanceId:
114
+ Value: !Ref Instance
115
+ SecurityGroupId:
116
+ Value: !Ref SecurityGroup
117
+ InstanceRoleArn:
118
+ Value: !GetAtt InstanceRole.Arn
119
+ InstanceProfileArn:
120
+ Value: !GetAtt InstanceProfile.Arn
121
+ # No PublicIp output: !GetAtt Instance.PublicIp fails outright whenever the
122
+ # instance isn't running (stopped by uptime governance, mid-restart, etc.),
123
+ # breaking every subsequent stack operation. basbox.py status() already
124
+ # reads the live public IP via ec2:DescribeInstances instead.
125
+ InstanceConnectEndpointId:
126
+ Value: !Ref InstanceConnectEndpoint
@@ -0,0 +1,80 @@
1
+ # zipget recipe for boxman on Ubuntu 24.04
2
+
3
+ [system_packages]
4
+ apt = [
5
+ "acl", "build-essential", "ca-certificates", "curl", "dbus-user-session",
6
+ "direnv", "file", "fuse3", "fuse-overlayfs", "gh", "git", "git-lfs",
7
+ "gocryptfs", "jq",
8
+ "less", "lf", "libsecret-tools", "openssh-client", "podman", "python3",
9
+ "python3-venv", "python-is-python3", "rsync", "shellcheck",
10
+ "slirp4netns", "uidmap", "unzip", "xz-utils", "zip", "zstd",
11
+ ]
12
+
13
+ [vars]
14
+ bin_dir = "~/.local/bin"
15
+
16
+ [herdr]
17
+ # Agent multiplexer - https://herdr.dev
18
+ github = { repo = "herdrdev/herdr", asset = "herdr-linux-x86_64" }
19
+ save_as = "${bin_dir}/herdr"
20
+ executable = true
21
+
22
+ [cship]
23
+ # Claude Code statusline - https://cship.dev
24
+ github = { repo = "stephenleo/cship", asset = "cship-x86_64-unknown-linux-musl" }
25
+ save_as = "${bin_dir}/cship"
26
+ executable = true
27
+
28
+ [microsoft-edit]
29
+ # Terminal-based text editor - https://github.com/microsoft/edit
30
+ # Note: only gnu builds available (no musl)
31
+ github = { repo = "microsoft/edit", asset = "x86_64-linux-gnu.tar.gz" }
32
+ unzip_to = "${bin_dir}"
33
+ files = "edit"
34
+
35
+ [starship]
36
+ # Cross-shell prompt - https://github.com/starship/starship
37
+ github = { repo = "starship/starship", asset = "x86_64-unknown-linux-musl.tar.gz" }
38
+ unzip_to = "${bin_dir}"
39
+ files = "starship"
40
+
41
+ [ripgrep]
42
+ # Fast regex search tool - https://github.com/BurntSushi/ripgrep
43
+ github = { repo = "BurntSushi/ripgrep", asset = "x86_64-unknown-linux-musl.tar.gz" }
44
+ unzip_to = "${bin_dir}"
45
+ files = "*/rg"
46
+
47
+ [fd]
48
+ # Fast find alternative - https://github.com/sharkdp/fd
49
+ github = { repo = "sharkdp/fd", asset = "x86_64-unknown-linux-musl.tar.gz" }
50
+ unzip_to = "${bin_dir}"
51
+ files = "*/fd"
52
+
53
+ [bat]
54
+ # Cat with syntax highlighting - https://github.com/sharkdp/bat
55
+ github = { repo = "sharkdp/bat", asset = "x86_64-unknown-linux-musl.tar.gz" }
56
+ unzip_to = "${bin_dir}"
57
+ files = "*/bat"
58
+
59
+ [fzf]
60
+ # Fuzzy finder - https://github.com/junegunn/fzf
61
+ github = { repo = "junegunn/fzf", asset = "linux_amd64.tar.gz" }
62
+ unzip_to = "${bin_dir}"
63
+ files = "fzf"
64
+
65
+ [zoxide]
66
+ # Smarter cd command - https://github.com/ajeetdsouza/zoxide
67
+ github = { repo = "ajeetdsouza/zoxide", asset = "x86_64-unknown-linux-musl.tar.gz" }
68
+ unzip_to = "${bin_dir}"
69
+ files = "zoxide"
70
+
71
+ [delta]
72
+ # Git diff viewer - https://github.com/dandavison/delta
73
+ github = { repo = "dandavison/delta", asset = "x86_64-unknown-linux-musl.tar.gz" }
74
+ unzip_to = "${bin_dir}"
75
+ files = "*/delta"
76
+
77
+ [aws-cli]
78
+ # AWS CLI v2 - official installer
79
+ url = "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip"
80
+ unzip_to = "./aws-cli-installer"
boxman/ec2.py ADDED
@@ -0,0 +1,297 @@
1
+ """Manage an EC2 development box through a CloudFormation stack."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import base64
7
+ import contextlib
8
+ import importlib.resources
9
+ import json
10
+ import os
11
+ import re
12
+ import shlex
13
+ import subprocess
14
+ import tomllib
15
+ from pathlib import Path
16
+
17
+
18
+ PARAMETERS = {
19
+ "vpc_id": "VpcId",
20
+ "subnet_id": "SubnetId",
21
+ "instance_type": "InstanceType",
22
+ "volume_size_gb": "VolumeSizeGb",
23
+ "instance_name": "InstanceName",
24
+ "ami_id": "LatestAmiId",
25
+ }
26
+ USERNAME = re.compile(r"[a-z_][a-z0-9_-]{0,31}\Z")
27
+ ALIAS = re.compile(r"[A-Za-z0-9_.-]+\Z")
28
+
29
+
30
+ def required(value: str | None, name: str) -> str:
31
+ if not value:
32
+ raise SystemExit(f"Missing {name}; provide it by option or --config")
33
+ return value
34
+
35
+
36
+ def username(value: str) -> str:
37
+ if not USERNAME.fullmatch(value):
38
+ raise SystemExit(f"Invalid Unix username: {value!r}")
39
+ return value
40
+
41
+
42
+ def config_dir() -> Path:
43
+ return Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) / "boxman"
44
+
45
+
46
+ def stack_template(name: str) -> Path:
47
+ if not re.fullmatch(r"[A-Za-z][A-Za-z0-9-]{0,127}", name):
48
+ raise SystemExit("Stack name must be a valid CloudFormation stack name")
49
+ return config_dir() / "stacks" / f"{name}.yaml"
50
+
51
+
52
+ def init_stack(name: str, values: dict) -> Path:
53
+ target = stack_template(name)
54
+ target.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
55
+ body = importlib.resources.files("boxman").joinpath("data/ec2-template.yaml").read_text()
56
+ for key, parameter in PARAMETERS.items():
57
+ value = required(values.get(key), "--" + key.replace("_", "-"))
58
+ marker = f" {parameter}:\n Type: "
59
+ match = re.search(re.escape(marker) + r"[^\n]+\n", body)
60
+ if not match:
61
+ raise SystemExit(f"Template is missing parameter {parameter}")
62
+ body = body[:match.end()] + f" Default: {json.dumps(str(value))}\n" + body[match.end():]
63
+ try:
64
+ with target.open("x") as output:
65
+ output.write(body)
66
+ except FileExistsError:
67
+ raise SystemExit(f"Stack template already exists: {target}") from None
68
+ return target
69
+
70
+
71
+ def settings(args: argparse.Namespace) -> dict:
72
+ config = {}
73
+ config_path = args.config or config_dir() / "ec2.toml"
74
+ if config_path.is_file():
75
+ with config_path.open("rb") as source:
76
+ config = tomllib.load(source)
77
+ elif args.config:
78
+ raise SystemExit(f"Config file does not exist: {config_path}")
79
+ if config:
80
+ if set(config) - {"ec2"} or not isinstance(config.get("ec2"), dict):
81
+ raise SystemExit("Config must contain an [ec2] table")
82
+ config = config["ec2"]
83
+ keys = {"profile", "region", "stack_name", "tags"}
84
+ if set(config) - keys:
85
+ raise SystemExit(f"Unknown [ec2] settings: {', '.join(sorted(set(config) - keys))}")
86
+ result = {key: getattr(args, key, None) or config.get(key) for key in keys}
87
+ result["stack_name"] = required(result["stack_name"], "--stack-name")
88
+ stack_template(result["stack_name"])
89
+ if args.action != "init":
90
+ result["profile"] = required(result["profile"], "--profile")
91
+ result["region"] = required(result["region"], "--region")
92
+ tags = config.get("tags", {}).copy()
93
+ if not isinstance(tags, dict) or any(not isinstance(k, str) or not isinstance(v, str) for k, v in tags.items()):
94
+ raise SystemExit("[ec2.tags] must contain string keys and values")
95
+ for item in getattr(args, "tag", None) or []:
96
+ if "=" not in item or not item.split("=", 1)[0]:
97
+ raise SystemExit("--tag must be KEY=VALUE")
98
+ key, value = item.split("=", 1)
99
+ tags[key] = value
100
+ result["tags"] = tags
101
+ return result
102
+
103
+
104
+ def stack(cfn, name: str):
105
+ try:
106
+ return cfn.describe_stacks(StackName=name)["Stacks"][0]
107
+ except Exception as exc:
108
+ if getattr(exc, "response", {}).get("Error", {}).get("Code") == "ValidationError" and "does not exist" in str(exc):
109
+ return None
110
+ raise
111
+
112
+
113
+ def instance_id(cfn, name: str) -> str:
114
+ current = stack(cfn, name)
115
+ if current:
116
+ for item in current.get("Outputs", []):
117
+ if item["OutputKey"] == "InstanceId":
118
+ return item["OutputValue"]
119
+ raise SystemExit(f"Stack {name} has no InstanceId output; deploy it first")
120
+
121
+
122
+ def execute(ssm, instance: str, command: str, user: str | None = None) -> None:
123
+ if user:
124
+ user = username(user)
125
+ payload = base64.b64encode(command.encode()).decode()
126
+ command = (f"BOXMAN_TMP=$(mktemp)\ntrap 'rm -f \"$BOXMAN_TMP\"' EXIT\n"
127
+ f"echo {shlex.quote(payload)} | base64 -d > \"$BOXMAN_TMP\"\n"
128
+ f"chmod 755 \"$BOXMAN_TMP\"\nsudo -iu {user} bash \"$BOXMAN_TMP\"")
129
+ response = ssm.send_command(InstanceIds=[instance], DocumentName="AWS-RunShellScript", Parameters={"commands": [command]})
130
+ command_id = response["Command"]["CommandId"]
131
+ with contextlib.suppress(Exception):
132
+ ssm.get_waiter("command_executed").wait(CommandId=command_id, InstanceId=instance)
133
+ result = ssm.get_command_invocation(CommandId=command_id, InstanceId=instance)
134
+ print(f"Status: {result['Status']}")
135
+ for key in ("StandardOutputContent", "StandardErrorContent"):
136
+ if result.get(key):
137
+ print(result[key], end="" if result[key].endswith("\n") else "\n")
138
+ if result["Status"] != "Success":
139
+ raise SystemExit(f"Remote command failed: {result['Status']}")
140
+
141
+
142
+ def add_key(ssm, instance: str, user: str, key_path: Path) -> None:
143
+ user = username(user)
144
+ key = key_path.read_text().strip()
145
+ if "\n" in key or not key.startswith(("ssh-ed25519 ", "ssh-rsa ", "ecdsa-sha2-")):
146
+ raise SystemExit(f"Invalid public key: {key_path}")
147
+ payload = base64.b64encode(key.encode()).decode()
148
+ script = ("set -e\nmkdir -p ~/.ssh\nchmod 700 ~/.ssh\n"
149
+ "touch ~/.ssh/authorized_keys\nchmod 600 ~/.ssh/authorized_keys\n"
150
+ f"KEY=$(echo {shlex.quote(payload)} | base64 -d)\n"
151
+ "grep -qxF \"$KEY\" ~/.ssh/authorized_keys || printf '%s\\n' \"$KEY\" >> ~/.ssh/authorized_keys\n")
152
+ execute(ssm, instance, script, user)
153
+
154
+
155
+ def keypair(path: Path) -> None:
156
+ if path.exists():
157
+ if not Path(str(path) + ".pub").is_file():
158
+ raise SystemExit(f"Missing public key for {path}")
159
+ return
160
+ path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
161
+ subprocess.run(["ssh-keygen", "-t", "ed25519", "-N", "", "-f", str(path)], check=True)
162
+
163
+
164
+ def proxy(settings_: dict) -> str:
165
+ return " ".join(shlex.quote(part) for part in ("aws", "ec2-instance-connect", "open-tunnel", "--profile", settings_["profile"], "--region", settings_["region"], "--instance-id")) + " %h"
166
+
167
+
168
+ def write_ssh_config(alias: str, body: str) -> None:
169
+ if not ALIAS.fullmatch(alias):
170
+ raise SystemExit("Alias may contain letters, numbers, dot, underscore and hyphen")
171
+ path = Path.home() / ".ssh" / "config"
172
+ path.parent.mkdir(mode=0o700, exist_ok=True)
173
+ content = path.read_text() if path.exists() else ""
174
+ begin, end = f"# boxman:{alias} begin", f"# boxman:{alias} end"
175
+ block = f"{begin}\n{body}\n{end}\n"
176
+ pattern = re.compile(re.escape(begin) + r".*?" + re.escape(end) + r"\n?", re.DOTALL)
177
+ content = pattern.sub(lambda _: block, content) if pattern.search(content) else content.rstrip("\n") + ("\n\n" if content else "") + block
178
+ path.write_text(content)
179
+ path.chmod(0o600)
180
+
181
+
182
+ def main(argv: list[str]) -> None:
183
+ parser = argparse.ArgumentParser(prog="boxman ec2")
184
+ parser.add_argument("--config", type=Path, help="TOML file (default: $XDG_CONFIG_HOME/boxman/ec2.toml)")
185
+ parser.add_argument("--profile")
186
+ parser.add_argument("--region")
187
+ parser.add_argument("--stack-name")
188
+ sub = parser.add_subparsers(dest="action", required=True)
189
+ sub.add_parser("init", help="create a local stack template under the boxman config directory")
190
+ init = sub.choices["init"]
191
+ for key in PARAMETERS:
192
+ init.add_argument("--" + key.replace("_", "-"), dest=key)
193
+ deploy = sub.add_parser("deploy")
194
+ deploy.add_argument("--tag", action="append", help="stack tag as KEY=VALUE; repeatable")
195
+ for action in ("status", "start", "stop"):
196
+ sub.add_parser(action)
197
+ connect = sub.add_parser("connect")
198
+ connect.add_argument("-u", "--user")
199
+ ssh = sub.add_parser("ssh")
200
+ ssh.add_argument("-u", "--user", required=True)
201
+ ssh.add_argument("--key-path", type=Path)
202
+ ssh.add_argument("-c", "--container")
203
+ ssh_config = sub.add_parser("ssh-config")
204
+ ssh_config.add_argument("-u", "--user", required=True)
205
+ ssh_config.add_argument("--alias", required=True)
206
+ ssh_config.add_argument("--key-path", type=Path)
207
+ run = sub.add_parser("run")
208
+ run.add_argument("command")
209
+ run.add_argument("-u", "--user")
210
+ args = parser.parse_args(argv)
211
+ conf = settings(args)
212
+ if args.action == "init":
213
+ values = {key: getattr(args, key) for key in PARAMETERS}
214
+ try:
215
+ if int(required(values["volume_size_gb"], "--volume-size-gb")) < 8:
216
+ raise SystemExit("--volume-size-gb must be at least 8")
217
+ except ValueError as exc:
218
+ raise SystemExit("--volume-size-gb must be an integer") from exc
219
+ print(f"Created {init_stack(conf['stack_name'], values)}")
220
+ return
221
+ try:
222
+ import boto3
223
+ import botocore.exceptions
224
+ except ImportError as exc:
225
+ raise SystemExit("EC2 commands require boto3; install boxman with the ec2 extra") from exc
226
+ try:
227
+ session = boto3.Session(profile_name=conf["profile"], region_name=conf["region"])
228
+ cfn = session.client("cloudformation")
229
+ name = conf["stack_name"]
230
+ if args.action == "deploy":
231
+ template = stack_template(name)
232
+ if not template.is_file():
233
+ raise SystemExit(f"Stack template missing: {template}; run boxman ec2 init first")
234
+ body = template.read_text()
235
+ params = []
236
+ for aws in PARAMETERS.values():
237
+ match = re.search(rf"^ {re.escape(aws)}:\n Type: [^\n]+\n Default: (.+)$", body, re.MULTILINE)
238
+ if not match:
239
+ raise SystemExit(f"Stack template lacks a default for {aws}: {template}")
240
+ try:
241
+ value = json.loads(match.group(1))
242
+ except json.JSONDecodeError as exc:
243
+ raise SystemExit(f"Invalid default for {aws}: {exc}") from exc
244
+ params.append({"ParameterKey": aws, "ParameterValue": str(value)})
245
+ tags = [{"Key": key, "Value": value} for key, value in conf["tags"].items()]
246
+ current = stack(cfn, name)
247
+ method = cfn.update_stack if current else cfn.create_stack
248
+ try:
249
+ method(StackName=name, TemplateBody=body, Parameters=params, Capabilities=["CAPABILITY_NAMED_IAM"], Tags=tags)
250
+ except botocore.exceptions.ClientError as exc:
251
+ if current and "No updates are to be performed" in str(exc):
252
+ print("No changes to apply.")
253
+ return
254
+ raise
255
+ cfn.get_waiter("stack_update_complete" if current else "stack_create_complete").wait(StackName=name)
256
+ print(f"InstanceId: {instance_id(cfn, name)}")
257
+ return
258
+ instance = instance_id(cfn, name)
259
+ if args.action == "status":
260
+ ec2 = session.client("ec2")
261
+ info = ec2.describe_instances(InstanceIds=[instance])["Reservations"][0]["Instances"][0]
262
+ print(f"Instance {instance}: {info['State']['Name']}, {info.get('PublicIpAddress', 'no public IP')}")
263
+ ssm = session.client("ssm")
264
+ records = ssm.describe_instance_information(Filters=[{"Key": "InstanceIds", "Values": [instance]}])["InstanceInformationList"]
265
+ print(f"SSM: {records[0]['PingStatus'] if records else 'not registered'}")
266
+ elif args.action in ("start", "stop"):
267
+ ec2 = session.client("ec2")
268
+ getattr(ec2, args.action + "_instances")(InstanceIds=[instance])
269
+ ec2.get_waiter("instance_running" if args.action == "start" else "instance_stopped").wait(InstanceIds=[instance])
270
+ print(f"Instance {instance}: {'running' if args.action == 'start' else 'stopped'}")
271
+ elif args.action == "connect":
272
+ cmd = ["aws", "--profile", conf["profile"], "--region", conf["region"], "ssm", "start-session", "--target", instance]
273
+ if args.user:
274
+ cmd += ["--document-name", "AWS-StartInteractiveCommand", "--parameters", f"command=sudo -iu {username(args.user)}"]
275
+ subprocess.run(cmd, check=True)
276
+ elif args.action in ("ssh", "ssh-config"):
277
+ user = username(args.user)
278
+ key = args.key_path or Path.home() / ".ssh" / f"boxman-{name}-ed25519"
279
+ keypair(key)
280
+ add_key(session.client("ssm"), instance, user, Path(str(key) + ".pub"))
281
+ tunnel = proxy(conf)
282
+ if args.action == "ssh":
283
+ cmd = ["ssh", "-t", "-o", f"ProxyCommand={tunnel}", "-o", "StrictHostKeyChecking=accept-new", "-i", str(key), f"{user}@{instance}"]
284
+ if args.container:
285
+ cmd.append(f"podman exec -it {shlex.quote(args.container)} bash")
286
+ subprocess.run(cmd, check=True)
287
+ else:
288
+ write_ssh_config(args.alias, f"Host {args.alias}\n HostName {instance}\n User {user}\n IdentityFile {key}\n ProxyCommand {tunnel}")
289
+ print(f"SSH host {args.alias} configured for {user}@{instance}")
290
+ elif args.action == "run":
291
+ execute(session.client("ssm"), instance, args.command, args.user)
292
+ except botocore.exceptions.BotoCoreError as exc:
293
+ raise SystemExit(f"AWS error: {exc}") from exc
294
+ except botocore.exceptions.ClientError as exc:
295
+ raise SystemExit(f"AWS error: {exc.response['Error'].get('Message', exc)}") from exc
296
+ except subprocess.CalledProcessError as exc:
297
+ raise SystemExit(f"Command failed ({exc.returncode}): {exc.cmd}") from exc
boxman/system.py ADDED
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env python3
2
+ """Install shared Ubuntu packages and prepare users for rootless Podman."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import os
8
+ import pwd
9
+ import re
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ SUBID_START = 100_000
18
+ SUBID_COUNT = 65_536
19
+ USERNAME = re.compile(r"[a-z_][a-z0-9_-]{0,31}")
20
+
21
+ RECIPE = Path(__file__).resolve().parent / "data" / "linux-tools.toml"
22
+ ZIPGET_URL = (
23
+ "https://github.com/vivainio/zipget-rs/releases/latest/download/"
24
+ "zipget-linux-x64-musl"
25
+ )
26
+
27
+
28
+ def log(message: str) -> None:
29
+ print(f"[system-setup] {message}", flush=True)
30
+
31
+
32
+ def run(*args: str, env: dict[str, str] | None = None) -> None:
33
+ log(f"running: {' '.join(args)}")
34
+ subprocess.run(args, check=True, env=env)
35
+
36
+
37
+ def supports_system_packages(zipget: Path) -> bool:
38
+ result = subprocess.run(
39
+ (str(zipget), "recipe", "--help"),
40
+ capture_output=True,
41
+ text=True,
42
+ check=False,
43
+ )
44
+ return result.returncode == 0 and "--system-only" in result.stdout
45
+
46
+
47
+ def install_zipget(temp: Path) -> Path:
48
+ installed = shutil.which("zipget")
49
+ if installed and supports_system_packages(Path(installed)):
50
+ return Path(installed)
51
+
52
+ zipget = temp / "zipget"
53
+ log(f"downloading {ZIPGET_URL}")
54
+ request = urllib.request.Request(ZIPGET_URL, headers={"User-Agent": "boxman"})
55
+ with urllib.request.urlopen(request, timeout=120) as response: # noqa: S310
56
+ zipget.write_bytes(response.read())
57
+ zipget.chmod(0o755)
58
+ if not supports_system_packages(zipget):
59
+ sys.exit("zipget release does not support recipe --system-only yet")
60
+ return zipget
61
+
62
+
63
+ def os_release() -> dict[str, str]:
64
+ values: dict[str, str] = {}
65
+ for line in Path("/etc/os-release").read_text().splitlines():
66
+ if "=" in line:
67
+ key, value = line.split("=", 1)
68
+ values[key] = value.strip('"')
69
+ return values
70
+
71
+
72
+ def allocated_ranges(path: Path) -> list[tuple[int, int]]:
73
+ ranges = []
74
+ for line in path.read_text().splitlines():
75
+ try:
76
+ _, start, count = line.split(":")
77
+ ranges.append((int(start), int(count)))
78
+ except ValueError:
79
+ continue
80
+ return ranges
81
+
82
+
83
+ def next_subid(path: Path) -> int:
84
+ next_id = max(
85
+ (start + count for start, count in allocated_ranges(path)),
86
+ default=SUBID_START,
87
+ )
88
+ next_id = max(next_id, SUBID_START)
89
+ remainder = (next_id - SUBID_START) % SUBID_COUNT
90
+ return next_id if remainder == 0 else next_id + SUBID_COUNT - remainder
91
+
92
+
93
+ def has_subid(path: Path, username: str) -> bool:
94
+ return any(
95
+ line.partition(":")[0] == username for line in path.read_text().splitlines()
96
+ )
97
+
98
+
99
+ def ensure_subid(username: str, path: Path, flag: str) -> None:
100
+ if has_subid(path, username):
101
+ return
102
+ start = next_subid(path)
103
+ end = start + SUBID_COUNT - 1
104
+ run("usermod", flag, f"{start}-{end}", username)
105
+
106
+
107
+ def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
108
+ parser = argparse.ArgumentParser(description=__doc__)
109
+ parser.add_argument(
110
+ "--packages-only",
111
+ action="store_true",
112
+ help="install shared packages without configuring login users",
113
+ )
114
+ parser.add_argument("users", nargs="*", help="existing Unix users to configure")
115
+ return parser.parse_args(argv)
116
+
117
+
118
+ def regular_users() -> list[str]:
119
+ """Return normal login users, excluding nobody and system accounts."""
120
+ return sorted(
121
+ entry.pw_name
122
+ for entry in pwd.getpwall()
123
+ if 1_000 <= entry.pw_uid < 65_534
124
+ and entry.pw_shell not in {"/usr/sbin/nologin", "/bin/false"}
125
+ )
126
+
127
+
128
+ def main(argv: list[str] | None = None) -> None:
129
+ args = parse_args(argv)
130
+ if os.geteuid() != 0:
131
+ sys.exit("boxman system must run as root")
132
+
133
+ release = os_release()
134
+ if release.get("ID") != "ubuntu" or release.get("VERSION_ID") != "24.04":
135
+ sys.exit(
136
+ "Ubuntu 24.04 is required "
137
+ f"(found {release.get('ID', 'unknown')} {release.get('VERSION_ID', 'unknown')})"
138
+ )
139
+
140
+ if args.packages_only and args.users:
141
+ sys.exit("--packages-only cannot be combined with usernames")
142
+
143
+ users = [] if args.packages_only else (args.users or regular_users())
144
+ if not args.packages_only and not args.users:
145
+ log(f"auto-detected login users: {', '.join(users) if users else '(none)'}")
146
+
147
+ for username in users:
148
+ if not USERNAME.fullmatch(username):
149
+ sys.exit(f"invalid Unix username: {username!r}")
150
+ try:
151
+ pwd.getpwnam(username)
152
+ except KeyError:
153
+ raise SystemExit(f"user does not exist: {username}") from None
154
+
155
+ if not RECIPE.is_file():
156
+ sys.exit(f"missing zipget recipe: {RECIPE}")
157
+ with tempfile.TemporaryDirectory(prefix="linux-system-") as temp_name:
158
+ zipget = install_zipget(Path(temp_name))
159
+ apt_env = {**os.environ, "DEBIAN_FRONTEND": "noninteractive"}
160
+ run(str(zipget), "recipe", str(RECIPE), "--system-only", env=apt_env)
161
+ run("git", "lfs", "install", "--system")
162
+
163
+ if args.packages_only:
164
+ log("package-only system setup complete")
165
+ return
166
+
167
+ for username in users:
168
+ log(f"configuring rootless Podman prerequisites for {username}")
169
+ ensure_subid(username, Path("/etc/subuid"), "--add-subuids")
170
+ ensure_subid(username, Path("/etc/subgid"), "--add-subgids")
171
+ run("loginctl", "enable-linger", username)
172
+
173
+ if not users:
174
+ log("no login users found; user Podman setup skipped")
175
+ log("system setup complete")
176
+
177
+
178
+ if __name__ == "__main__":
179
+ main()
boxman/user.py ADDED
@@ -0,0 +1,190 @@
1
+ #!/usr/bin/env python3
2
+ """Install per-user AI development tools without storing credentials."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import hashlib
7
+ import os
8
+ import platform
9
+ import shutil
10
+ import subprocess
11
+ import sys
12
+ import tarfile
13
+ import tempfile
14
+ import urllib.request
15
+ from pathlib import Path
16
+
17
+ NODE_MAJOR = 22
18
+ HOME = Path.home()
19
+ LOCAL_BIN = HOME / ".local" / "bin"
20
+ NODE_ROOT = HOME / ".local" / "share" / f"node-v{NODE_MAJOR}"
21
+ SCRIPT_DIR = Path(__file__).resolve().parent
22
+ TOOLS_RECIPE = SCRIPT_DIR / "data" / "linux-tools.toml"
23
+ ZIPGET_URL = (
24
+ "https://github.com/vivainio/zipget-rs/releases/latest/download/"
25
+ "zipget-linux-x64-musl"
26
+ )
27
+ CLAUDE_INSTALL_URL = "https://claude.ai/install.sh"
28
+ COPILOT_INSTALL_URL = "https://gh.io/copilot-install"
29
+ UV_INSTALL_URL = "https://astral.sh/uv/install.sh"
30
+
31
+
32
+ def log(message: str) -> None:
33
+ print(f"[user-setup] {message}", flush=True)
34
+
35
+
36
+ def run(
37
+ *args: str,
38
+ env: dict[str, str] | None = None,
39
+ cwd: Path | None = None,
40
+ ) -> None:
41
+ log(f"running: {' '.join(args)}")
42
+ subprocess.run(args, check=True, env=env, cwd=cwd)
43
+
44
+
45
+ def download(url: str, destination: Path) -> None:
46
+ log(f"downloading {url}")
47
+ request = urllib.request.Request(url, headers={"User-Agent": "boxman"})
48
+ with urllib.request.urlopen(request, timeout=120) as response: # noqa: S310
49
+ destination.write_bytes(response.read())
50
+
51
+
52
+ def node_architecture() -> str:
53
+ machine = platform.machine().lower()
54
+ try:
55
+ return {"x86_64": "x64", "aarch64": "arm64", "arm64": "arm64"}[machine]
56
+ except KeyError:
57
+ sys.exit(f"unsupported CPU architecture: {machine}")
58
+
59
+
60
+ def find_node_archive(checksums: str, architecture: str) -> tuple[str, str]:
61
+ suffix = f"-linux-{architecture}.tar.xz"
62
+ for line in checksums.splitlines():
63
+ digest, _, filename = line.partition(" ")
64
+ if filename.endswith(suffix):
65
+ return filename, digest
66
+ sys.exit(f"no Node.js {NODE_MAJOR} archive found for {architecture}")
67
+
68
+
69
+ def install_node(temp: Path) -> None:
70
+ base_url = f"https://nodejs.org/dist/latest-v{NODE_MAJOR}.x"
71
+ checksums_path = temp / "SHASUMS256.txt"
72
+ download(f"{base_url}/SHASUMS256.txt", checksums_path)
73
+ filename, expected_digest = find_node_archive(
74
+ checksums_path.read_text(), node_architecture()
75
+ )
76
+
77
+ archive = temp / filename
78
+ download(f"{base_url}/{filename}", archive)
79
+ actual_digest = hashlib.sha256(archive.read_bytes()).hexdigest()
80
+ if actual_digest != expected_digest:
81
+ sys.exit("Node.js checksum verification failed")
82
+
83
+ shutil.rmtree(NODE_ROOT, ignore_errors=True)
84
+ NODE_ROOT.mkdir(parents=True)
85
+ with tarfile.open(archive, "r:xz") as bundle:
86
+ members = bundle.getmembers()
87
+ top_directory = Path(members[0].name).parts[0]
88
+ for member in members:
89
+ member.name = str(Path(member.name).relative_to(top_directory))
90
+ if member.name != ".":
91
+ bundle.extract(member, NODE_ROOT, filter="data")
92
+
93
+ for executable in ("node", "npm", "npx", "corepack"):
94
+ link = LOCAL_BIN / executable
95
+ link.unlink(missing_ok=True)
96
+ link.symlink_to(NODE_ROOT / "bin" / executable)
97
+
98
+
99
+ def install_zipget() -> Path:
100
+ zipget = LOCAL_BIN / "zipget"
101
+ if not zipget.exists():
102
+ download(ZIPGET_URL, zipget)
103
+ zipget.chmod(0o755)
104
+ return zipget
105
+
106
+
107
+ def install_recipe_tools(zipget: Path) -> None:
108
+ if not TOOLS_RECIPE.is_file():
109
+ sys.exit(f"missing zipget recipe: {TOOLS_RECIPE}")
110
+ with tempfile.TemporaryDirectory(prefix="linux-tools-") as stage_name:
111
+ stage = Path(stage_name)
112
+ # The root setup handles apt packages. Keep this step compatible with
113
+ # zipget releases predating [system_packages] support.
114
+ lines = TOOLS_RECIPE.read_text().splitlines(keepends=True)
115
+ user_lines = []
116
+ in_system_packages = False
117
+ for line in lines:
118
+ if line.strip() == "[system_packages]":
119
+ in_system_packages = True
120
+ elif in_system_packages and line.lstrip().startswith("["):
121
+ in_system_packages = False
122
+ if not in_system_packages:
123
+ user_lines.append(line)
124
+ user_recipe = stage / "user-tools.toml"
125
+ user_recipe.write_text("".join(user_lines))
126
+ run(str(zipget), "recipe", str(user_recipe), cwd=stage)
127
+
128
+ aws_installer = stage / "aws-cli-installer" / "aws" / "install"
129
+ if not aws_installer.is_file():
130
+ sys.exit(
131
+ f"zipget did not produce the AWS CLI installer at {aws_installer}"
132
+ )
133
+ args = [
134
+ str(aws_installer),
135
+ "--install-dir",
136
+ str(HOME / ".local" / "aws-cli"),
137
+ "--bin-dir",
138
+ str(LOCAL_BIN),
139
+ ]
140
+ if (HOME / ".local" / "aws-cli").exists():
141
+ args.append("--update")
142
+ run(*args)
143
+
144
+
145
+ def install_claude_code(temp: Path) -> None:
146
+ installer = temp / "install-claude-code.sh"
147
+ download(CLAUDE_INSTALL_URL, installer)
148
+ run("bash", str(installer))
149
+
150
+
151
+ def install_copilot(temp: Path) -> None:
152
+ installer = temp / "install-copilot.sh"
153
+ download(COPILOT_INSTALL_URL, installer)
154
+ env = {**os.environ, "PREFIX": str(HOME / ".local")}
155
+ run("bash", str(installer), env=env)
156
+
157
+
158
+ def install_uv(temp: Path) -> None:
159
+ installer = temp / "install-uv.sh"
160
+ download(UV_INSTALL_URL, installer)
161
+ env = {
162
+ **os.environ,
163
+ "UV_INSTALL_DIR": str(LOCAL_BIN),
164
+ "UV_NO_MODIFY_PATH": "1",
165
+ }
166
+ run("sh", str(installer), env=env)
167
+
168
+
169
+ def main() -> None:
170
+ if os.geteuid() == 0:
171
+ sys.exit("boxman user must run as the target user, not root")
172
+
173
+ LOCAL_BIN.mkdir(parents=True, exist_ok=True)
174
+ NODE_ROOT.parent.mkdir(parents=True, exist_ok=True)
175
+
176
+ zipget = install_zipget()
177
+ install_recipe_tools(zipget)
178
+
179
+ with tempfile.TemporaryDirectory(prefix="boxman-user-") as temp_name:
180
+ temp = Path(temp_name)
181
+ install_node(temp)
182
+ install_claude_code(temp)
183
+ install_copilot(temp)
184
+ install_uv(temp)
185
+
186
+ log("user setup complete; authenticate claude, copilot, and gh interactively")
187
+
188
+
189
+ if __name__ == "__main__":
190
+ main()
boxman/verify.py ADDED
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ """Verify a user's AI development tool installation."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import shutil
7
+ import subprocess
8
+
9
+ COMMANDS = {
10
+ "git": ("--version",),
11
+ "gocryptfs": ("-version",),
12
+ "gh": ("--version",),
13
+ "podman": ("--version",),
14
+ "node": ("--version",),
15
+ "npm": ("--version",),
16
+ "zipget": ("--version",),
17
+ "aws": ("--version",),
18
+ "herdr": ("--version",),
19
+ "cship": ("--version",),
20
+ "starship": ("--version",),
21
+ "fd": ("--version",),
22
+ "bat": ("--version",),
23
+ "zoxide": ("--version",),
24
+ "delta": ("--version",),
25
+ "uv": ("--version",),
26
+ "claude": ("--version",),
27
+ "copilot": ("version",),
28
+ }
29
+
30
+
31
+ def main() -> None:
32
+ failures = 0
33
+ for command, args in COMMANDS.items():
34
+ if not shutil.which(command):
35
+ print(f"MISSING {command}")
36
+ failures += 1
37
+ continue
38
+ result = subprocess.run(
39
+ (command, *args), capture_output=True, text=True, timeout=20, check=False
40
+ )
41
+ output = (result.stdout or result.stderr).splitlines()
42
+ if result.returncode == 0:
43
+ print(f"OK {command:<10} {output[0] if output else ''}")
44
+ else:
45
+ print(f"FAILED {command:<10} {output[0] if output else ''}")
46
+ failures += 1
47
+
48
+ if shutil.which("podman"):
49
+ result = subprocess.run(
50
+ ("podman", "info", "--format", "{{.Host.Security.Rootless}}"),
51
+ capture_output=True,
52
+ text=True,
53
+ timeout=20,
54
+ check=False,
55
+ )
56
+ if result.returncode == 0 and result.stdout.strip() == "true":
57
+ print("OK rootless Podman")
58
+ else:
59
+ print("FAILED Podman is not running rootless")
60
+ failures += 1
61
+
62
+ raise SystemExit(failures)
63
+
64
+
65
+ if __name__ == "__main__":
66
+ main()
@@ -0,0 +1,169 @@
1
+ Metadata-Version: 2.4
2
+ Name: boxman
3
+ Version: 0.1.0
4
+ Summary: Set up and manage an Ubuntu development box
5
+ Project-URL: Homepage, https://github.com/vivainio/boxman
6
+ Project-URL: Documentation, https://vivainio.github.io/boxman/
7
+ Project-URL: Repository, https://github.com/vivainio/boxman
8
+ Project-URL: Issues, https://github.com/vivainio/boxman/issues
9
+ Keywords: ubuntu,development,ec2,cloudformation,cli
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.11
16
+ Classifier: Programming Language :: Python :: 3.12
17
+ Classifier: Programming Language :: Python :: 3.13
18
+ Classifier: Topic :: System :: Systems Administration
19
+ Requires-Python: >=3.11
20
+ Description-Content-Type: text/markdown
21
+ Provides-Extra: ec2
22
+ Requires-Dist: boto3>=1.34; extra == "ec2"
23
+
24
+ # boxman
25
+
26
+ [Read the Boxman book](https://vivainio.github.io/boxman/) for the concepts, setup steps, and command reference.
27
+
28
+ Set up and manage an Ubuntu 24.04 development box. `boxman` is a Python command
29
+ with system, user, and vault operations.
30
+
31
+ ## Install a host
32
+
33
+ From this checkout, run the system step as root, then the user step from each
34
+ login account. The first command works with the Ubuntu system Python before
35
+ `uv` is installed:
36
+
37
+ ```bash
38
+ sudo python3 -m boxman.cli system
39
+ python3 -m boxman.cli user
40
+ ```
41
+
42
+ With `uv` available, run it without installing the package:
43
+
44
+ ```bash
45
+ uvx --from . boxman vault status
46
+ ```
47
+
48
+ After publishing the repository, `uvx --from
49
+ git+https://github.com/vivainio/boxman boxman ...` can run the same console
50
+ command. For a persistent command, use `uv tool install .` from this checkout.
51
+
52
+ `boxman system` uses zipget to install the apt packages declared in
53
+ `linux-tools.toml`, sets up Git LFS, and configures rootless Podman for normal
54
+ login users. It accepts explicit usernames, or `--packages-only` for a
55
+ container build. It downloads zipget if no version supporting
56
+ `recipe --system-only` is on root's PATH. A fresh install needs a released
57
+ zipget with that option.
58
+
59
+ `boxman user` installs the tools in the recipe, Node.js 22, Claude Code,
60
+ Copilot CLI, and uv. Run the user step for each account.
61
+ Run `boxman verify` afterward to check the installed commands and rootless
62
+ Podman.
63
+
64
+ The same package set can be used in a dev container:
65
+
66
+ ```bash
67
+ podman build -f container/Dockerfile -t boxman-dev .
68
+ ```
69
+
70
+ The container build skips host login-user and systemd configuration. FUSE
71
+ mounting may require additional container privileges, so the vault commands
72
+ are intended for the native host.
73
+
74
+ ## Private directory
75
+
76
+ The recipe installs `gocryptfs` and FUSE 3. Each user initializes their own
77
+ vault once and unlocks it after a reboot or unmount:
78
+
79
+ ```bash
80
+ uvx --from . boxman vault init
81
+ uvx --from . boxman vault unlock
82
+ uvx --from . boxman vault status
83
+ uvx --from . boxman claude # starts Claude with its config in the mounted vault
84
+ uvx --from . boxman vault lock # after stopping processes that use the mount
85
+ ```
86
+
87
+ Encrypted files live in `~/.private.cipher`; the plaintext mount is
88
+ `~/private`. Initialization and unlocking prompt for the password. Keep the
89
+ password and gocryptfs recovery key outside the host. Back up the encrypted
90
+ directory, including `gocryptfs.conf`, while keeping the plaintext mount out
91
+ of backups.
92
+
93
+ `boxman claude` refuses to run unless the vault is mounted. Use it before the
94
+ first Claude login. It sets `CLAUDE_CONFIG_DIR` to `~/private/claude` and does
95
+ not move existing credentials from `~/.claude`. Configure other tools'
96
+ credential locations separately if they should use the vault. Persistent
97
+ agents need the vault mounted while they use credentials. Locking fails while
98
+ a process holds files in the mount open.
99
+
100
+ The vault protects its backing files and snapshots while locked. It does not
101
+ hide credentials from the user's running processes or a host administrator
102
+ while unlocked. Keep home directory permissions private to each Unix owner.
103
+
104
+ ## EC2 host
105
+
106
+ Install the optional AWS dependency, then create
107
+ `${XDG_CONFIG_HOME:-~/.config}/boxman/ec2.toml`:
108
+
109
+ ```bash
110
+ uv tool install '.[ec2]'
111
+ mkdir -p "${XDG_CONFIG_HOME:-$HOME/.config}/boxman"
112
+ ```
113
+
114
+ ```toml
115
+ [ec2]
116
+ profile = "your-aws-profile"
117
+ region = "your-region"
118
+ stack_name = "your-stack-name"
119
+
120
+ [ec2.tags]
121
+ Owner = "your-owner"
122
+ Environment = "your-environment"
123
+ ```
124
+
125
+ The config contains no credentials; AWS uses the named profile. Supply the tags
126
+ required by your account. The TOML file selects the AWS profile, region, stack,
127
+ and tags. Command-line options override the file;
128
+ `--tag KEY=VALUE` adds or overrides a tag. `--config PATH` selects another TOML
129
+ file. Put global options before the action:
130
+
131
+ ```bash
132
+ boxman ec2 init --vpc-id vpc-... --subnet-id subnet-... \
133
+ --instance-type t3.xlarge --volume-size-gb 100 \
134
+ --instance-name mybox \
135
+ --ami-id /aws/service/canonical/ubuntu/server/24.04/stable/current/amd64/hvm/ebs-gp3/ami-id
136
+ boxman ec2 deploy
137
+ boxman ec2 status
138
+ boxman ec2 start
139
+ boxman ec2 stop
140
+ boxman ec2 connect -u myuser
141
+ boxman ec2 ssh -u myuser
142
+ boxman ec2 ssh-config -u myuser --alias mybox
143
+ boxman ec2 run -u myuser 'uname -a'
144
+ ```
145
+
146
+ `init` writes `${XDG_CONFIG_HOME:-~/.config}/boxman/stacks/<stack_name>.yaml`
147
+ with the instance settings as parameter defaults and refuses to overwrite an
148
+ existing file. Edit that YAML to customize the stack. `deploy` reads the YAML
149
+ for the configured stack name and sends its defaults as CloudFormation
150
+ parameters. Tags from `[ec2.tags]` and repeatable
151
+ `--tag KEY=VALUE` options become CloudFormation stack tags. A single config
152
+ file selects one stack; use `--config PATH` for another box.
153
+
154
+ Deployment creates or updates a CloudFormation stack containing an Ubuntu EC2
155
+ instance, an SSM role, and an EC2 Instance Connect Endpoint. SSH uses that
156
+ endpoint and installs a generated public key through SSM. `ssh-config` writes a
157
+ marked host entry to `~/.ssh/config`. The stack name selects the instance for
158
+ all subsequent commands. Starting, stopping, and deploying incur AWS charges.
159
+
160
+ ## Release
161
+
162
+ Build the Zensical book locally with `zensical build --clean`. The docs workflow
163
+ publishes it to GitHub Pages when documentation changes on `main`.
164
+
165
+ A published GitHub release triggers the PyPI workflow. Use a `vX.Y.Z` tag;
166
+ the workflow sets the package version from the release tag, builds the wheel
167
+ and source distribution, and publishes with PyPI trusted publishing. Configure
168
+ a PyPI trusted publisher for repository `vivainio/boxman`, workflow
169
+ `publish.yml`, environment `pypi` before the first release.
@@ -0,0 +1,14 @@
1
+ boxman/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ boxman/cli.py,sha256=EZ35JcoM9o-FgCC1HFb594l8d9x0xSvKUm_YcE5XecA,4012
3
+ boxman/ec2.py,sha256=Lsr17vnQvYNyzxb-yi6yb7VkqgUB5yn3xmmx8gjxFKY,14119
4
+ boxman/system.py,sha256=LnD66M_3j3eJ4ThzUt1e149qOzN0u18EiDf-EbHIXls,5655
5
+ boxman/user.py,sha256=HFnDuk3G-B1jEp3f3qmp5OM1e4Ka5aROZUuULNEgDLE,6179
6
+ boxman/verify.py,sha256=TUDM90uAoDkVQhPVqmMDG3Mnh2aARPjC0Df36jGgJ5Q,1848
7
+ boxman/data/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ boxman/data/ec2-template.yaml,sha256=1khSQuedLGlYKhYhcRkIMa6ms-43_RGjMw6SKCRnSmM,3753
9
+ boxman/data/linux-tools.toml,sha256=dX43xzB7tT0_nSj0C5X8IGwxdW3bZ9n_-LfCbdRH57w,2511
10
+ boxman-0.1.0.dist-info/METADATA,sha256=CXozDBpVsDCOEYKS_NDMgXm9INOW7ryqm_YLwBTG6wc,6608
11
+ boxman-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ boxman-0.1.0.dist-info/entry_points.txt,sha256=NCnQD5BTI7cuNJaO7FM1NIR6xownHScvOvlLRWKwV0M,43
13
+ boxman-0.1.0.dist-info/top_level.txt,sha256=l75VydLS2iSnvvs6j0TWg-QekP0OIhkTk-WIvKgQknI,7
14
+ boxman-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ boxman = boxman.cli:main
@@ -0,0 +1 @@
1
+ boxman