projectwrap 202604.1__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.
- project_wrap/__init__.py +3 -0
- project_wrap/cli.py +115 -0
- project_wrap/core.py +566 -0
- project_wrap/deps.py +87 -0
- project_wrap/templates/init.fish +21 -0
- project_wrap/templates/init.sh +21 -0
- project_wrap/templates/project.toml +32 -0
- project_wrap/validate.py +133 -0
- project_wrap/vault.py +563 -0
- projectwrap-202604.1.dist-info/LICENSE +21 -0
- projectwrap-202604.1.dist-info/METADATA +279 -0
- projectwrap-202604.1.dist-info/RECORD +14 -0
- projectwrap-202604.1.dist-info/WHEEL +4 -0
- projectwrap-202604.1.dist-info/entry_points.txt +3 -0
project_wrap/__init__.py
ADDED
project_wrap/cli.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""CLI entry point for project-wrap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from . import __version__
|
|
9
|
+
from .core import (
|
|
10
|
+
create_project,
|
|
11
|
+
ensure_templates,
|
|
12
|
+
get_config_dir,
|
|
13
|
+
list_projects,
|
|
14
|
+
run_project,
|
|
15
|
+
)
|
|
16
|
+
from .deps import check_optional_deps
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def main(argv: list[str] | None = None) -> int:
|
|
20
|
+
"""Main entry point."""
|
|
21
|
+
parser = argparse.ArgumentParser(
|
|
22
|
+
prog="pwrap",
|
|
23
|
+
description="Isolated project environments with bubblewrap sandboxing",
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument(
|
|
26
|
+
"project",
|
|
27
|
+
nargs="?",
|
|
28
|
+
help="Project name to load",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--new",
|
|
32
|
+
metavar="DIR",
|
|
33
|
+
help="Create a new project config with DIR as the project directory",
|
|
34
|
+
)
|
|
35
|
+
parser.add_argument(
|
|
36
|
+
"--no-sandbox",
|
|
37
|
+
action="store_true",
|
|
38
|
+
help="Disable sandbox in generated config (use with --new)",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument(
|
|
41
|
+
"--shell",
|
|
42
|
+
metavar="SHELL",
|
|
43
|
+
help="Shell to configure (use with --new, defaults to $SHELL)",
|
|
44
|
+
)
|
|
45
|
+
parser.add_argument(
|
|
46
|
+
"-l",
|
|
47
|
+
"--list",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="List available projects",
|
|
50
|
+
)
|
|
51
|
+
parser.add_argument(
|
|
52
|
+
"--check-deps",
|
|
53
|
+
action="store_true",
|
|
54
|
+
help="Check availability of optional dependencies",
|
|
55
|
+
)
|
|
56
|
+
parser.add_argument(
|
|
57
|
+
"--version",
|
|
58
|
+
action="version",
|
|
59
|
+
version=f"%(prog)s {__version__}",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"-v",
|
|
63
|
+
"--verbose",
|
|
64
|
+
action="store_true",
|
|
65
|
+
help="Verbose output",
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
args = parser.parse_args(argv)
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
if args.check_deps:
|
|
72
|
+
check_optional_deps(verbose=True)
|
|
73
|
+
return 0
|
|
74
|
+
|
|
75
|
+
if args.new:
|
|
76
|
+
if ensure_templates():
|
|
77
|
+
config_dir = get_config_dir()
|
|
78
|
+
print("Templates created:")
|
|
79
|
+
for name in ["project.tpl.toml", "init.tpl.fish", "init.tpl.sh"]:
|
|
80
|
+
print(f" {config_dir / name}")
|
|
81
|
+
print("Edit these templates, then run pwrap --new again.")
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
print(f"Using template {get_config_dir() / 'project.tpl.toml'}")
|
|
85
|
+
config_path = create_project(
|
|
86
|
+
args.new,
|
|
87
|
+
name=args.project or None,
|
|
88
|
+
sandbox=not args.no_sandbox,
|
|
89
|
+
shell=args.shell,
|
|
90
|
+
)
|
|
91
|
+
print(f"Created {config_path}")
|
|
92
|
+
return 0
|
|
93
|
+
|
|
94
|
+
if args.list or not args.project:
|
|
95
|
+
list_projects()
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
run_project(args.project, verbose=args.verbose)
|
|
99
|
+
return 0 # Won't reach here if exec succeeds
|
|
100
|
+
|
|
101
|
+
except KeyboardInterrupt:
|
|
102
|
+
print("\nAborted.")
|
|
103
|
+
return 130
|
|
104
|
+
except SystemExit as e:
|
|
105
|
+
if isinstance(e.code, int):
|
|
106
|
+
return e.code
|
|
107
|
+
print(f"Error: {e.code}", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
except Exception as e:
|
|
110
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
111
|
+
return 1
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
if __name__ == "__main__":
|
|
115
|
+
sys.exit(main())
|
project_wrap/core.py
ADDED
|
@@ -0,0 +1,566 @@
|
|
|
1
|
+
"""Core functionality for project-wrap."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shlex
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
import tomllib
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
from .deps import require_dep
|
|
15
|
+
from .validate import (
|
|
16
|
+
check_config_permissions,
|
|
17
|
+
tiocsti_vulnerable,
|
|
18
|
+
validate_config,
|
|
19
|
+
validate_project_name,
|
|
20
|
+
validate_shell,
|
|
21
|
+
)
|
|
22
|
+
from .vault import VaultConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ProjectExec:
|
|
27
|
+
"""Everything needed to exec into a project environment."""
|
|
28
|
+
|
|
29
|
+
display_name: str
|
|
30
|
+
program: str
|
|
31
|
+
argv: list[str]
|
|
32
|
+
is_sandboxed: bool = False
|
|
33
|
+
verbose_info: str | None = None
|
|
34
|
+
vault_config: VaultConfig | None = None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_config_dir() -> Path:
|
|
38
|
+
"""Get the project configuration directory."""
|
|
39
|
+
# Support XDG, fall back to ~/.config
|
|
40
|
+
xdg_config = os.environ.get("XDG_CONFIG_HOME")
|
|
41
|
+
if xdg_config:
|
|
42
|
+
base = Path(xdg_config)
|
|
43
|
+
else:
|
|
44
|
+
base = Path.home() / ".config"
|
|
45
|
+
return base / "pwrap"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def expand_path(path: str) -> Path:
|
|
49
|
+
"""Expand ~ and environment variables in path."""
|
|
50
|
+
return Path(os.path.expandvars(os.path.expanduser(path)))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def load_config(name: str) -> dict[str, Any]:
|
|
55
|
+
"""Load project configuration."""
|
|
56
|
+
validate_project_name(name)
|
|
57
|
+
config_dir = get_config_dir()
|
|
58
|
+
project_path = config_dir / name
|
|
59
|
+
|
|
60
|
+
if not project_path.is_dir():
|
|
61
|
+
raise SystemExit(f"Unknown project: {name}")
|
|
62
|
+
|
|
63
|
+
config_file = project_path / "project.toml"
|
|
64
|
+
if not config_file.exists():
|
|
65
|
+
raise SystemExit(f"Missing config: {config_file}")
|
|
66
|
+
|
|
67
|
+
check_config_permissions(config_file)
|
|
68
|
+
|
|
69
|
+
with open(config_file, "rb") as f:
|
|
70
|
+
config: dict[str, Any] = tomllib.load(f)
|
|
71
|
+
|
|
72
|
+
validate_config(config)
|
|
73
|
+
config["_config_dir"] = project_path
|
|
74
|
+
return config
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_bwrap_args(
|
|
79
|
+
sandbox: dict[str, Any],
|
|
80
|
+
project_dir: Path,
|
|
81
|
+
init_script: Path | None = None,
|
|
82
|
+
ro_bind_extra: list[Path] | None = None,
|
|
83
|
+
rw_bind_extra: list[Path] | None = None,
|
|
84
|
+
) -> list[str]:
|
|
85
|
+
"""Build bubblewrap command arguments.
|
|
86
|
+
|
|
87
|
+
Args:
|
|
88
|
+
sandbox: Sandbox configuration dict
|
|
89
|
+
project_dir: Project working directory
|
|
90
|
+
init_script: Optional init script to bind-mount read-only into sandbox
|
|
91
|
+
ro_bind_extra: Additional paths to bind-mount read-only
|
|
92
|
+
rw_bind_extra: Additional paths to bind-mount read-write (e.g. encrypted mountpoint)
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
List of bwrap arguments
|
|
96
|
+
"""
|
|
97
|
+
args = [
|
|
98
|
+
"bwrap",
|
|
99
|
+
# Base filesystem (read-only root, read-only home, writable project dir)
|
|
100
|
+
"--ro-bind",
|
|
101
|
+
"/",
|
|
102
|
+
"/",
|
|
103
|
+
"--dev",
|
|
104
|
+
"/dev",
|
|
105
|
+
"--proc",
|
|
106
|
+
"/proc",
|
|
107
|
+
"--tmpfs",
|
|
108
|
+
"/tmp",
|
|
109
|
+
"--ro-bind",
|
|
110
|
+
str(Path.home()),
|
|
111
|
+
str(Path.home()),
|
|
112
|
+
# Hardening
|
|
113
|
+
"--die-with-parent",
|
|
114
|
+
"--unshare-ipc",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
# Isolate XDG runtime dir (D-Bus, Wayland, SSH/GPG agent sockets)
|
|
118
|
+
uid = os.getuid()
|
|
119
|
+
args.extend(["--tmpfs", f"/run/user/{uid}"])
|
|
120
|
+
|
|
121
|
+
# Always blacklist the config directory (prevents reading other project configs
|
|
122
|
+
# or modifying sandbox rules from inside the sandbox)
|
|
123
|
+
config_dir_resolved = get_config_dir().resolve()
|
|
124
|
+
if config_dir_resolved.exists():
|
|
125
|
+
args.extend(["--tmpfs", str(config_dir_resolved)])
|
|
126
|
+
|
|
127
|
+
# Blacklist paths by overlaying with tmpfs
|
|
128
|
+
blacklist_paths: list[Path] = [config_dir_resolved]
|
|
129
|
+
for path in sandbox.get("blacklist", []):
|
|
130
|
+
p = expand_path(path)
|
|
131
|
+
if not p.exists():
|
|
132
|
+
raise SystemExit(
|
|
133
|
+
f"Blacklist path does not exist: {p}\n"
|
|
134
|
+
f"Fix your config or remove this entry."
|
|
135
|
+
)
|
|
136
|
+
# bwrap can't mount tmpfs over symlinks — resolve to the real path
|
|
137
|
+
mount_path = p.resolve()
|
|
138
|
+
args.extend(["--tmpfs", str(mount_path)])
|
|
139
|
+
blacklist_paths.append(mount_path)
|
|
140
|
+
|
|
141
|
+
# Whitelist paths by binding them back (must be under a blacklisted path)
|
|
142
|
+
for path in sandbox.get("whitelist", []):
|
|
143
|
+
p = expand_path(path)
|
|
144
|
+
if not p.exists():
|
|
145
|
+
continue
|
|
146
|
+
# Resolve once to prevent symlink TOCTOU
|
|
147
|
+
resolved = p.resolve()
|
|
148
|
+
if not any(resolved == bl or bl in resolved.parents for bl in blacklist_paths):
|
|
149
|
+
raise SystemExit(
|
|
150
|
+
f"Whitelist path {p} is not under any blacklisted path. "
|
|
151
|
+
f"Blacklisted: {[str(bl) for bl in blacklist_paths]}"
|
|
152
|
+
)
|
|
153
|
+
args.extend(["--bind", str(resolved), str(resolved)])
|
|
154
|
+
|
|
155
|
+
# Extra writable paths (e.g. ~/.pyenv/shims, ~/.keychain)
|
|
156
|
+
for path in sandbox.get("writable", []):
|
|
157
|
+
p = expand_path(path)
|
|
158
|
+
if not p.exists():
|
|
159
|
+
raise SystemExit(
|
|
160
|
+
f"Writable path does not exist: {p}\n"
|
|
161
|
+
f"Fix your config or remove this entry."
|
|
162
|
+
)
|
|
163
|
+
mount_path = p.resolve()
|
|
164
|
+
args.extend(["--bind", str(mount_path), str(mount_path)])
|
|
165
|
+
|
|
166
|
+
# Project dir writable (after blacklist/whitelist so it's not overwritten)
|
|
167
|
+
args.extend(["--bind", str(project_dir), str(project_dir)])
|
|
168
|
+
|
|
169
|
+
# Bind-mount init script read-only (config dir may be blacklisted)
|
|
170
|
+
if init_script is not None:
|
|
171
|
+
args.extend(["--ro-bind", str(init_script), str(init_script)])
|
|
172
|
+
|
|
173
|
+
# Bind-mount extra read-only paths (e.g. secrets identity file)
|
|
174
|
+
for path in ro_bind_extra or []:
|
|
175
|
+
args.extend(["--ro-bind", str(path), str(path)])
|
|
176
|
+
|
|
177
|
+
# Bind-mount extra read-write paths (e.g. writeback archive)
|
|
178
|
+
for path in rw_bind_extra or []:
|
|
179
|
+
args.extend(["--bind", str(path), str(path)])
|
|
180
|
+
|
|
181
|
+
# TIOCSTI protection (--new-session breaks fish TTY, so only enable when needed)
|
|
182
|
+
vulnerable = tiocsti_vulnerable()
|
|
183
|
+
new_session_cfg = sandbox.get("new_session")
|
|
184
|
+
if new_session_cfg is True:
|
|
185
|
+
args.append("--new-session")
|
|
186
|
+
elif new_session_cfg is None and vulnerable:
|
|
187
|
+
# Default: auto-enable on vulnerable kernels
|
|
188
|
+
args.append("--new-session")
|
|
189
|
+
elif new_session_cfg is False and vulnerable:
|
|
190
|
+
import sys
|
|
191
|
+
|
|
192
|
+
print(
|
|
193
|
+
"Warning: new_session disabled but kernel is vulnerable to TIOCSTI "
|
|
194
|
+
f"(Linux {os.uname().release}). Upgrade to 6.2+ or set "
|
|
195
|
+
"new_session = true.",
|
|
196
|
+
file=sys.stderr,
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
# Network isolation
|
|
200
|
+
if sandbox.get("unshare_net", False):
|
|
201
|
+
args.append("--unshare-net")
|
|
202
|
+
|
|
203
|
+
# PID namespace isolation (default: on)
|
|
204
|
+
if sandbox.get("unshare_pid", True):
|
|
205
|
+
args.append("--unshare-pid")
|
|
206
|
+
|
|
207
|
+
# Set environment variables
|
|
208
|
+
args.extend(["--setenv", "PROJECT_WRAP", "1"])
|
|
209
|
+
|
|
210
|
+
# Set working directory
|
|
211
|
+
args.extend(["--chdir", str(project_dir)])
|
|
212
|
+
|
|
213
|
+
return args
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def get_init_script(config_dir: Path, shell: str) -> Path | None:
|
|
217
|
+
"""Find shell-specific init script.
|
|
218
|
+
|
|
219
|
+
Looks for init.{shell} (e.g., init.fish) then falls back to init.sh.
|
|
220
|
+
"""
|
|
221
|
+
shell_name = Path(shell).name
|
|
222
|
+
|
|
223
|
+
candidates = [
|
|
224
|
+
config_dir / f"init.{shell_name}",
|
|
225
|
+
config_dir / "init.sh",
|
|
226
|
+
]
|
|
227
|
+
|
|
228
|
+
for candidate in candidates:
|
|
229
|
+
if candidate.exists():
|
|
230
|
+
return candidate
|
|
231
|
+
|
|
232
|
+
return None
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _build_init_commands(
|
|
236
|
+
project_dir: Path,
|
|
237
|
+
config_dir: Path,
|
|
238
|
+
shell: str,
|
|
239
|
+
) -> list[str]:
|
|
240
|
+
"""Build setup commands (cd, init script sourcing).
|
|
241
|
+
|
|
242
|
+
Returns list of shell commands — does NOT include the final exec/shell launch.
|
|
243
|
+
"""
|
|
244
|
+
commands: list[str] = []
|
|
245
|
+
|
|
246
|
+
commands.append(f"cd {shlex.quote(str(project_dir))}")
|
|
247
|
+
|
|
248
|
+
# Custom init script
|
|
249
|
+
if init_script := get_init_script(config_dir, shell):
|
|
250
|
+
commands.append(f"source {shlex.quote(str(init_script))}")
|
|
251
|
+
|
|
252
|
+
return commands
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def build_shell_argv(
|
|
256
|
+
project_dir: Path,
|
|
257
|
+
config_dir: Path,
|
|
258
|
+
shell: str,
|
|
259
|
+
) -> list[str]:
|
|
260
|
+
"""Build the full argv to launch an interactive shell with project setup.
|
|
261
|
+
|
|
262
|
+
Uses shell-specific mechanisms so setup runs inside the interactive shell:
|
|
263
|
+
- fish: --init-command (runs after config.fish, before prompt)
|
|
264
|
+
- other shells: -c "setup; exec shell"
|
|
265
|
+
"""
|
|
266
|
+
commands = _build_init_commands(project_dir, config_dir, shell)
|
|
267
|
+
shell_name = Path(shell).name
|
|
268
|
+
|
|
269
|
+
if shell_name == "fish":
|
|
270
|
+
return [shell, "--init-command", "; ".join(commands)]
|
|
271
|
+
|
|
272
|
+
commands.append(f"exec {shlex.quote(shell)}")
|
|
273
|
+
return [shell, "-c", "; ".join(commands)]
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def redact_bwrap_args(args: list[str]) -> list[str]:
|
|
277
|
+
"""Redact --setenv values from bwrap args for display."""
|
|
278
|
+
redacted = list(args)
|
|
279
|
+
i = 0
|
|
280
|
+
while i < len(redacted):
|
|
281
|
+
if redacted[i] == "--setenv" and i + 2 < len(redacted):
|
|
282
|
+
redacted[i + 2] = "***"
|
|
283
|
+
i += 3
|
|
284
|
+
else:
|
|
285
|
+
i += 1
|
|
286
|
+
return redacted
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def rename_tmux_window(name: str) -> None:
|
|
290
|
+
"""Rename current tmux window if in tmux session."""
|
|
291
|
+
if os.environ.get("TMUX"):
|
|
292
|
+
subprocess.run(
|
|
293
|
+
["tmux", "rename-window", name],
|
|
294
|
+
capture_output=True,
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def prepare_project(name: str, verbose: bool = False) -> ProjectExec:
|
|
299
|
+
"""Prepare a project environment for execution.
|
|
300
|
+
|
|
301
|
+
Loads config, renames tmux window, and returns everything needed
|
|
302
|
+
to exec into the project shell.
|
|
303
|
+
"""
|
|
304
|
+
config = load_config(name)
|
|
305
|
+
|
|
306
|
+
# Extract config sections
|
|
307
|
+
project_cfg = config.get("project", {})
|
|
308
|
+
sandbox_cfg = config.get("sandbox", {})
|
|
309
|
+
encrypted_cfg = config.get("encrypted", {})
|
|
310
|
+
config_dir: Path = config["_config_dir"]
|
|
311
|
+
|
|
312
|
+
# Resolve settings
|
|
313
|
+
display_name = project_cfg.get("name", name)
|
|
314
|
+
project_dir = expand_path(project_cfg.get("dir", f"~/projects/{name}"))
|
|
315
|
+
shell = project_cfg.get("shell", os.environ.get("SHELL", "/bin/bash"))
|
|
316
|
+
validate_shell(shell)
|
|
317
|
+
sandbox_enabled = sandbox_cfg.get("enabled", False)
|
|
318
|
+
|
|
319
|
+
# Verify project directory exists
|
|
320
|
+
if not project_dir.exists():
|
|
321
|
+
raise SystemExit(f"Project directory does not exist: {project_dir}")
|
|
322
|
+
|
|
323
|
+
# Encrypted volumes require sandbox
|
|
324
|
+
if encrypted_cfg and not sandbox_enabled:
|
|
325
|
+
raise SystemExit(
|
|
326
|
+
"[encrypted] requires sandbox to be enabled"
|
|
327
|
+
)
|
|
328
|
+
|
|
329
|
+
# Resolve encrypted volume config
|
|
330
|
+
vault_config: VaultConfig | None = None
|
|
331
|
+
if encrypted_cfg:
|
|
332
|
+
require_dep("gocryptfs")
|
|
333
|
+
|
|
334
|
+
cipherdir_raw = encrypted_cfg["cipherdir"]
|
|
335
|
+
cipherdir_path = expand_path(cipherdir_raw)
|
|
336
|
+
if not cipherdir_path.is_absolute():
|
|
337
|
+
cipherdir_path = config_dir / cipherdir_raw
|
|
338
|
+
cipherdir = cipherdir_path.resolve()
|
|
339
|
+
|
|
340
|
+
if not cipherdir.is_dir():
|
|
341
|
+
raise SystemExit(
|
|
342
|
+
f"Encrypted cipherdir does not exist: {cipherdir}\n"
|
|
343
|
+
f"Initialize it with: mkdir -p '{cipherdir}' && "
|
|
344
|
+
f"gocryptfs -init '{cipherdir}'"
|
|
345
|
+
)
|
|
346
|
+
|
|
347
|
+
mountpoint = expand_path(encrypted_cfg["mountpoint"])
|
|
348
|
+
mountpoint.mkdir(parents=True, exist_ok=True)
|
|
349
|
+
|
|
350
|
+
vault_config = VaultConfig(
|
|
351
|
+
cipherdir=cipherdir,
|
|
352
|
+
mountpoint=mountpoint.resolve(),
|
|
353
|
+
project_name=name,
|
|
354
|
+
shared=encrypted_cfg.get("shared", False),
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# Rename tmux window
|
|
358
|
+
rename_tmux_window(display_name)
|
|
359
|
+
|
|
360
|
+
# Resolve init script and build shell argv
|
|
361
|
+
init_script = get_init_script(config_dir, shell)
|
|
362
|
+
shell_argv = build_shell_argv(project_dir, config_dir, shell)
|
|
363
|
+
|
|
364
|
+
# Non-sandboxed execution
|
|
365
|
+
if not sandbox_enabled:
|
|
366
|
+
return ProjectExec(
|
|
367
|
+
display_name=display_name,
|
|
368
|
+
program=shell,
|
|
369
|
+
argv=shell_argv,
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
# Sandboxed execution - verify bwrap is available
|
|
373
|
+
require_dep("bwrap")
|
|
374
|
+
|
|
375
|
+
# If encrypted volume, bind the mountpoint into the sandbox
|
|
376
|
+
rw_bind_extra: list[Path] = []
|
|
377
|
+
if vault_config:
|
|
378
|
+
rw_bind_extra = [vault_config.mountpoint]
|
|
379
|
+
|
|
380
|
+
bwrap_args = build_bwrap_args(
|
|
381
|
+
sandbox_cfg, project_dir,
|
|
382
|
+
init_script=init_script, rw_bind_extra=rw_bind_extra,
|
|
383
|
+
)
|
|
384
|
+
if vault_config:
|
|
385
|
+
bwrap_args.extend(["--setenv", "PWRAP_VAULT_DIR", str(vault_config.mountpoint)])
|
|
386
|
+
bwrap_args.extend(shell_argv)
|
|
387
|
+
|
|
388
|
+
verbose_info = None
|
|
389
|
+
if verbose:
|
|
390
|
+
verbose_info = f"Exec: {' '.join(redact_bwrap_args(bwrap_args))}"
|
|
391
|
+
|
|
392
|
+
return ProjectExec(
|
|
393
|
+
display_name=display_name,
|
|
394
|
+
program="bwrap",
|
|
395
|
+
argv=bwrap_args,
|
|
396
|
+
is_sandboxed=True,
|
|
397
|
+
verbose_info=verbose_info,
|
|
398
|
+
vault_config=vault_config,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def run_project(name: str, verbose: bool = False) -> None:
|
|
403
|
+
"""Load and run a project environment.
|
|
404
|
+
|
|
405
|
+
This function does not return on success (execs into new shell).
|
|
406
|
+
"""
|
|
407
|
+
from .vault import run_vault
|
|
408
|
+
|
|
409
|
+
result = prepare_project(name, verbose)
|
|
410
|
+
|
|
411
|
+
label = result.display_name
|
|
412
|
+
if result.is_sandboxed:
|
|
413
|
+
label += " (sandboxed)"
|
|
414
|
+
if result.vault_config is not None:
|
|
415
|
+
if result.vault_config.shared:
|
|
416
|
+
label += " (shared vault)"
|
|
417
|
+
else:
|
|
418
|
+
label += " (vault)"
|
|
419
|
+
print(f"Loading {label}")
|
|
420
|
+
|
|
421
|
+
if result.verbose_info:
|
|
422
|
+
print(result.verbose_info)
|
|
423
|
+
|
|
424
|
+
if result.vault_config:
|
|
425
|
+
sys.exit(run_vault(result.vault_config, result.argv))
|
|
426
|
+
else:
|
|
427
|
+
os.execvp(result.program, result.argv)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
TEMPLATE_NAMES = ["project.tpl.toml", "init.tpl.fish", "init.tpl.sh"]
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _load_package_template(name: str) -> str:
|
|
435
|
+
"""Load a template file from the package templates directory."""
|
|
436
|
+
from importlib.resources import files
|
|
437
|
+
|
|
438
|
+
# Package templates use plain names (project.toml), user templates use .tpl. names
|
|
439
|
+
return (files("project_wrap") / "templates" / name).read_text()
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def ensure_templates() -> bool:
|
|
443
|
+
"""Ensure user-editable templates exist in the config directory.
|
|
444
|
+
|
|
445
|
+
On first run, copies package templates to ~/.config/pwrap/ with .tpl. names.
|
|
446
|
+
Returns True if templates were just created (caller should pause for editing).
|
|
447
|
+
"""
|
|
448
|
+
config_dir = get_config_dir()
|
|
449
|
+
marker = config_dir / "project.tpl.toml"
|
|
450
|
+
|
|
451
|
+
if marker.exists():
|
|
452
|
+
return False
|
|
453
|
+
|
|
454
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
455
|
+
|
|
456
|
+
# Map .tpl. names to package template names
|
|
457
|
+
pkg_names = {"project.tpl.toml": "project.toml", "init.tpl.fish": "init.fish",
|
|
458
|
+
"init.tpl.sh": "init.sh"}
|
|
459
|
+
for tpl_name, pkg_name in pkg_names.items():
|
|
460
|
+
(config_dir / tpl_name).write_text(_load_package_template(pkg_name))
|
|
461
|
+
|
|
462
|
+
return True
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _load_template(name: str) -> str:
|
|
466
|
+
"""Load a template, preferring user-editable version over package default.
|
|
467
|
+
|
|
468
|
+
Maps template names: project.toml -> project.tpl.toml, init.fish -> init.tpl.fish
|
|
469
|
+
"""
|
|
470
|
+
tpl_name = name.replace(".", ".tpl.", 1) # project.toml -> project.tpl.toml
|
|
471
|
+
user_tpl = get_config_dir() / tpl_name
|
|
472
|
+
if user_tpl.exists():
|
|
473
|
+
return user_tpl.read_text()
|
|
474
|
+
return _load_package_template(name)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def create_project(
|
|
478
|
+
project_dir: str,
|
|
479
|
+
name: str | None = None,
|
|
480
|
+
sandbox: bool = True,
|
|
481
|
+
shell: str | None = None,
|
|
482
|
+
) -> Path:
|
|
483
|
+
"""Create a new project config directory with templates.
|
|
484
|
+
|
|
485
|
+
Args:
|
|
486
|
+
project_dir: Path to the project working directory.
|
|
487
|
+
name: Project name. Defaults to the directory basename.
|
|
488
|
+
sandbox: Whether to enable sandbox in the generated config.
|
|
489
|
+
shell: Shell path. Defaults to $SHELL.
|
|
490
|
+
|
|
491
|
+
Returns the path to the created config directory.
|
|
492
|
+
"""
|
|
493
|
+
resolved_dir = expand_path(project_dir).resolve()
|
|
494
|
+
if not resolved_dir.is_dir():
|
|
495
|
+
raise SystemExit(f"Project directory does not exist: {resolved_dir}")
|
|
496
|
+
|
|
497
|
+
if name is None:
|
|
498
|
+
name = resolved_dir.name
|
|
499
|
+
|
|
500
|
+
if shell is None:
|
|
501
|
+
shell = os.environ.get("SHELL", "/bin/bash")
|
|
502
|
+
|
|
503
|
+
validate_project_name(name)
|
|
504
|
+
config_dir = get_config_dir() / name
|
|
505
|
+
|
|
506
|
+
if config_dir.exists():
|
|
507
|
+
raise SystemExit(f"Project already exists: {config_dir}")
|
|
508
|
+
|
|
509
|
+
sandbox_enabled = "true" if sandbox else "false"
|
|
510
|
+
|
|
511
|
+
toml = _load_template("project.toml").format(
|
|
512
|
+
name=name, dir=resolved_dir, sandbox_enabled=sandbox_enabled, shell=shell
|
|
513
|
+
)
|
|
514
|
+
|
|
515
|
+
config_dir.mkdir(parents=True)
|
|
516
|
+
(config_dir / "project.toml").write_text(toml)
|
|
517
|
+
|
|
518
|
+
# Copy matching init template
|
|
519
|
+
shell_name = Path(shell).name
|
|
520
|
+
if shell_name == "fish":
|
|
521
|
+
(config_dir / "init.fish").write_text(_load_template("init.fish"))
|
|
522
|
+
else:
|
|
523
|
+
(config_dir / "init.sh").write_text(_load_template("init.sh"))
|
|
524
|
+
|
|
525
|
+
return config_dir
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def list_projects() -> None:
|
|
529
|
+
"""List all available projects."""
|
|
530
|
+
config_dir = get_config_dir()
|
|
531
|
+
|
|
532
|
+
if not config_dir.exists():
|
|
533
|
+
print(f"No projects configured. Create configs in: {config_dir}")
|
|
534
|
+
return
|
|
535
|
+
|
|
536
|
+
print("Projects:")
|
|
537
|
+
|
|
538
|
+
items = sorted(config_dir.iterdir())
|
|
539
|
+
if not items:
|
|
540
|
+
print(" (none)")
|
|
541
|
+
return
|
|
542
|
+
|
|
543
|
+
for item in items:
|
|
544
|
+
if item.name.startswith("."):
|
|
545
|
+
continue
|
|
546
|
+
|
|
547
|
+
if item.is_dir():
|
|
548
|
+
# Check for valid config
|
|
549
|
+
config_file = item / "project.toml"
|
|
550
|
+
if config_file.exists():
|
|
551
|
+
# Try to load to show name
|
|
552
|
+
try:
|
|
553
|
+
with open(config_file, "rb") as f:
|
|
554
|
+
cfg = tomllib.load(f)
|
|
555
|
+
display_name = cfg.get("project", {}).get("name", item.name)
|
|
556
|
+
sandboxed = cfg.get("sandbox", {}).get("enabled", False)
|
|
557
|
+
marker = " [sandboxed]" if sandboxed else ""
|
|
558
|
+
print(f" {item.name}/{marker}")
|
|
559
|
+
if display_name != item.name:
|
|
560
|
+
print(f" → {display_name}")
|
|
561
|
+
except Exception:
|
|
562
|
+
print(f" {item.name}/ (invalid config)")
|
|
563
|
+
else:
|
|
564
|
+
print(f" {item.name}/ (missing project.toml)")
|
|
565
|
+
|
|
566
|
+
|