Fix all issues from spec review
- Add TargetConfig model to remote domain with normalization rules - Add SetupModuleDef model to bootstrap domain - Fix domain purity: Package carries pre-walked file lists, parse_module_ref takes parsed dict not file path, discover_packages moved to service layer - Clarify conflict detection: cross-package collisions (pure) vs filesystem conflicts (injected callback in plan_link) - Add dry_run to init, repos_pull, repos_push, stop, remove, respawn - Document interactive commands (edit, attach, exec) as dry_run exceptions - Document ProjectService as read-only (no dry_run needed) - Fix ContainerState -> ContainerInfo naming consistency - Add post-install and allow-sudo fields to config YAML example - Document core/paths.py constants including MODULES_DIR - Add target config normalization rules - Clarify validate_env as eager precondition check, not a plan action - Clarify setup show as effectively setup run --dry-run Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -30,8 +30,8 @@ core/ Layer 1: Runtime primitives + config loading
|
|||||||
|
|
||||||
### Rules
|
### Rules
|
||||||
|
|
||||||
- `domain/` never imports `core/runtime`, `services/`, or `commands/`. It only imports `core/template.py`, `core/errors.py`, `core/paths.py` (constants only), and stdlib.
|
- `domain/` never imports `core/runtime`, `services/`, or `commands/`. It only imports `core/template.py`, `core/errors.py`, `core/paths.py` (constants only), and stdlib. Domain functions that need filesystem information (directory listings, file existence) receive it as pre-built data or injected callbacks -- never by reading the filesystem directly.
|
||||||
- `services/` receives all dependencies via constructor (runtime, config, console). No module-level singletons.
|
- `services/` receives all dependencies via constructor (runtime, config, console). No module-level singletons. Cross-service calls use the shared `FlowContext` (e.g. `BootstrapService` constructs `DotfilesService(self.ctx)` internally). This is the intended pattern since all services share the same runtime context.
|
||||||
- `commands/` are trivial: parse args into a typed namespace, call one service method.
|
- `commands/` are trivial: parse args into a typed namespace, call one service method.
|
||||||
- Every plan is a frozen dataclass returned by domain functions. Services decide whether to execute or dry-run print them.
|
- Every plan is a frozen dataclass returned by domain functions. Services decide whether to execute or dry-run print them.
|
||||||
|
|
||||||
@@ -72,11 +72,11 @@ src/flow/
|
|||||||
modules.py # Built-in setup module definitions (hostname, locale, shell, ssh-keygen, runcmd)
|
modules.py # Built-in setup module definitions (hostname, locale, shell, ssh-keygen, runcmd)
|
||||||
|
|
||||||
remote/
|
remote/
|
||||||
models.py # Target, SSHCommand
|
models.py # Target, TargetConfig, SSHCommand
|
||||||
resolution.py # Target parsing, host template expansion
|
resolution.py # Target parsing, host template expansion
|
||||||
|
|
||||||
containers/
|
containers/
|
||||||
models.py # ContainerSpec, ContainerState
|
models.py # ContainerSpec, ContainerInfo, ImageRef
|
||||||
resolution.py # Image ref parsing, name normalization, mount computation
|
resolution.py # Image ref parsing, name normalization, mount computation
|
||||||
|
|
||||||
services/
|
services/
|
||||||
@@ -278,8 +278,11 @@ class Package:
|
|||||||
"""A dotfiles package: a named set of files mapping to home-relative targets."""
|
"""A dotfiles package: a named set of files mapping to home-relative targets."""
|
||||||
name: str # e.g. "zsh", "nvim"
|
name: str # e.g. "zsh", "nvim"
|
||||||
layer: str # "_shared" or profile name
|
layer: str # "_shared" or profile name
|
||||||
|
package_id: str # Qualified: "layer/name" (e.g. "_shared/nvim")
|
||||||
source_dir: Path # Absolute path in dotfiles repo
|
source_dir: Path # Absolute path in dotfiles repo
|
||||||
module: ModuleRef | None # If backed by external repo
|
module: ModuleRef | None # If backed by external repo
|
||||||
|
local_files: list[tuple[Path, Path]] # (absolute_source, relative_to_package_root)
|
||||||
|
# Pre-walked by the service layer (no I/O in domain)
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ModuleRef:
|
class ModuleRef:
|
||||||
@@ -289,7 +292,9 @@ class ModuleRef:
|
|||||||
ref_value: str # e.g. "main", "v1.0", "abc123"
|
ref_value: str # e.g. "main", "v1.0", "abc123"
|
||||||
mount_path: Path # Relative path within package to _module.yaml parent
|
mount_path: Path # Relative path within package to _module.yaml parent
|
||||||
# e.g. Path(".config/nvim")
|
# e.g. Path(".config/nvim")
|
||||||
cache_dir: Path # ~/.local/share/flow/modules/<name>
|
cache_dir: Path # ~/.local/share/flow/modules/<package_id>
|
||||||
|
module_files: list[tuple[Path, Path]] # (absolute_source, relative_to_cache_root)
|
||||||
|
# Pre-walked by the service layer
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class LinkTarget:
|
class LinkTarget:
|
||||||
@@ -370,15 +375,19 @@ Resolution steps:
|
|||||||
|
|
||||||
```python
|
```python
|
||||||
# domain/dotfiles/resolution.py
|
# domain/dotfiles/resolution.py
|
||||||
|
# All functions are PURE: they operate on pre-built Package data (including
|
||||||
def discover_packages(dotfiles_dir: Path, profile: str | None) -> list[Package]: ...
|
# pre-walked file lists), never touching the filesystem directly.
|
||||||
|
|
||||||
def resolve_package_targets(
|
def resolve_package_targets(
|
||||||
package: Package,
|
package: Package,
|
||||||
home: Path,
|
home: Path,
|
||||||
skip: set[str],
|
skip: set[str],
|
||||||
) -> list[LinkTarget]:
|
) -> list[LinkTarget]:
|
||||||
"""Resolve all LinkTargets for a package, handling modules correctly."""
|
"""Resolve all LinkTargets for a package, handling modules correctly.
|
||||||
|
|
||||||
|
Uses package.local_files and package.module.module_files (pre-walked
|
||||||
|
by service layer) to compute targets. No filesystem I/O.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
def resolve_all_targets(
|
def resolve_all_targets(
|
||||||
@@ -386,28 +395,62 @@ def resolve_all_targets(
|
|||||||
home: Path,
|
home: Path,
|
||||||
skip: set[str],
|
skip: set[str],
|
||||||
) -> list[LinkTarget]:
|
) -> list[LinkTarget]:
|
||||||
"""Resolve targets for all packages. Raises on conflicts."""
|
"""Resolve targets for all packages. Raises PlanConflict on duplicate targets
|
||||||
|
across packages (e.g. _shared/zsh and linux-work/zsh both targeting ~/.zshrc).
|
||||||
|
This is a pure cross-package collision check, not a filesystem conflict check.
|
||||||
|
"""
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# domain/dotfiles/modules.py
|
# domain/dotfiles/modules.py
|
||||||
|
# Pure functions for module metadata. I/O (reading YAML, walking dirs) is done
|
||||||
|
# by the service layer, which passes parsed data into these functions.
|
||||||
|
|
||||||
def parse_module_yaml(path: Path) -> ModuleRef: ...
|
def parse_module_ref(
|
||||||
|
raw: dict, # Pre-loaded YAML content (dict, not file path)
|
||||||
|
package_id: str,
|
||||||
|
mount_path: Path,
|
||||||
|
modules_base: Path,
|
||||||
|
) -> ModuleRef:
|
||||||
|
"""Build a ModuleRef from parsed _module.yaml content.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
raw: The parsed YAML dict (service reads the file and passes content in).
|
||||||
|
package_id: Qualified name like "_shared/nvim".
|
||||||
|
mount_path: Relative path from package root to _module.yaml parent.
|
||||||
|
modules_base: Base directory for module caches (from core/paths.py).
|
||||||
|
"""
|
||||||
|
...
|
||||||
|
|
||||||
def compute_mount_path(module_yaml: Path, package_dir: Path) -> Path:
|
def compute_mount_path(module_yaml: Path, package_dir: Path) -> Path:
|
||||||
"""The key function: relative path from package root to _module.yaml parent."""
|
"""The key function: relative path from package root to _module.yaml parent."""
|
||||||
return module_yaml.parent.relative_to(package_dir)
|
return module_yaml.parent.relative_to(package_dir)
|
||||||
|
|
||||||
def module_cache_dir(package_name: str, modules_base: Path) -> Path:
|
def module_cache_dir(package_id: str, modules_base: Path) -> Path:
|
||||||
return modules_base / package_name.replace("/", "--")
|
"""Compute cache dir for a module. Uses package_id (e.g. '_shared/nvim')
|
||||||
|
with '/' replaced by '--' to avoid collisions between same-named packages
|
||||||
|
in different layers."""
|
||||||
|
return modules_base / package_id.replace("/", "--")
|
||||||
|
|
||||||
def normalize_source(source: str) -> str:
|
def normalize_source(source: str) -> str:
|
||||||
"""github:org/repo -> https://github.com/org/repo.git"""
|
"""github:org/repo -> https://github.com/org/repo.git"""
|
||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.3 Planning
|
**I/O boundary note:** The service layer (`DotfilesService`) is responsible for:
|
||||||
|
1. Walking the dotfiles dir to find packages and `_module.yaml` files
|
||||||
|
2. Reading `_module.yaml` files and passing parsed dicts to `parse_module_ref`
|
||||||
|
3. Walking module cache dirs to build file lists
|
||||||
|
4. Constructing `Package` objects with pre-populated `local_files` and `ModuleRef.module_files`
|
||||||
|
5. Passing these fully-built objects to pure domain functions
|
||||||
|
|
||||||
|
### 4.3 Planning and Conflict Detection
|
||||||
|
|
||||||
|
Conflict detection and planning are integrated into a single flow. There are two kinds of conflicts:
|
||||||
|
|
||||||
|
1. **Cross-package collisions** (pure): two packages want the same target path. Detected by `resolve_all_targets` which raises `PlanConflict`.
|
||||||
|
2. **Filesystem conflicts** (requires I/O): a target path already exists on disk and is not managed by flow. Detected by `plan_link` via an injected callback.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
# domain/dotfiles/planning.py
|
# domain/dotfiles/planning.py
|
||||||
@@ -415,13 +458,21 @@ def normalize_source(source: str) -> str:
|
|||||||
def plan_link(
|
def plan_link(
|
||||||
desired: list[LinkTarget],
|
desired: list[LinkTarget],
|
||||||
current: LinkedState,
|
current: LinkedState,
|
||||||
|
filesystem_check: Callable[[Path], str | None],
|
||||||
|
# ^^ Injected by service layer. Returns "file", "dir", "symlink", or None.
|
||||||
|
# This is the ONLY I/O dependency in the planning layer.
|
||||||
|
# Tests provide a fake (e.g., lambda p: None).
|
||||||
) -> LinkPlan:
|
) -> LinkPlan:
|
||||||
"""Compare desired targets with current state, produce reconciliation plan.
|
"""Compare desired targets with current state, produce reconciliation plan.
|
||||||
|
|
||||||
- New targets: create_link ops
|
- New targets: create_link ops
|
||||||
- Removed targets: remove_link ops
|
- Removed targets (in current but not desired): remove_link ops
|
||||||
- Existing correct symlinks: unchanged (no op)
|
- Existing correct symlinks: unchanged (no op)
|
||||||
- Conflicts (target exists, not managed): listed in plan.conflicts
|
- Filesystem conflicts: listed in plan.conflicts (target exists, not managed)
|
||||||
|
- Directory conflicts: listed in plan.conflicts (target is a dir, cannot overwrite)
|
||||||
|
|
||||||
|
The service checks plan.conflicts and raises PlanConflict if non-empty and
|
||||||
|
--force was not passed.
|
||||||
"""
|
"""
|
||||||
...
|
...
|
||||||
|
|
||||||
@@ -433,24 +484,6 @@ def plan_unlink(
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4.4 Conflict Detection
|
|
||||||
|
|
||||||
```python
|
|
||||||
# domain/dotfiles/conflicts.py
|
|
||||||
|
|
||||||
def detect_conflicts(
|
|
||||||
desired: list[LinkTarget],
|
|
||||||
current: LinkedState,
|
|
||||||
filesystem_check: Callable[[Path], str | None],
|
|
||||||
# Returns "file", "dir", "symlink", or None
|
|
||||||
# This is the ONLY I/O dependency, injected from service layer
|
|
||||||
) -> list[str]:
|
|
||||||
"""Return human-readable conflict descriptions."""
|
|
||||||
...
|
|
||||||
```
|
|
||||||
|
|
||||||
The `filesystem_check` callback is the boundary between pure domain and I/O. The service injects it. Tests provide a fake.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. Packages Domain
|
## 5. Packages Domain
|
||||||
@@ -564,22 +597,35 @@ class Profile:
|
|||||||
os: str # "linux" | "macos"
|
os: str # "linux" | "macos"
|
||||||
package_manager: str | None # None = auto-detect
|
package_manager: str | None # None = auto-detect
|
||||||
packages: list[Any] # Raw entries, normalized via packages domain
|
packages: list[Any] # Raw entries, normalized via packages domain
|
||||||
modules: list[dict] # Setup module definitions
|
setup_modules: list[SetupModuleDef] # Parsed setup module definitions
|
||||||
requires: list[str] # Required env vars
|
requires: list[str] # Required env vars
|
||||||
shell: str | None # e.g. "zsh"
|
shell: str | None # e.g. "zsh"
|
||||||
dotfiles_profile: str | None # Profile name for dotfiles linking
|
dotfiles_profile: str | None # Profile name for dotfiles linking
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SetupModuleDef:
|
||||||
|
"""Configuration for a single setup module step, parsed from YAML.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
SetupModuleDef(type="hostname", config={"value": "my-host"})
|
||||||
|
SetupModuleDef(type="ssh-keygen", config={"keys": [{"type": "ed25519", ...}]})
|
||||||
|
SetupModuleDef(type="runcmd", config={"commands": ["sudo groupadd docker || true"]})
|
||||||
|
"""
|
||||||
|
type: str # "hostname" | "locale" | "shell" | "ssh-keygen" | "runcmd"
|
||||||
|
config: dict # Type-specific configuration from YAML
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class BootstrapAction:
|
class BootstrapAction:
|
||||||
"""A single step in the bootstrap plan."""
|
"""A single step in the bootstrap plan."""
|
||||||
type: str # "validate_env" | "install_packages" | "run_module"
|
type: str # "install_packages" | "run_setup_module"
|
||||||
# | "link_dotfiles" | "set_shell"
|
# | "link_dotfiles" | "set_shell"
|
||||||
description: str
|
description: str
|
||||||
payload: Any # Type-specific data:
|
payload: Any # Type-specific data:
|
||||||
# install_packages -> PackagePlan
|
# install_packages -> PackagePlan
|
||||||
# run_module -> SetupModuleDef
|
# run_setup_module -> tuple[SetupModuleDef, list[str]]
|
||||||
# link_dotfiles -> profile name
|
# (the module def + pre-computed shell commands)
|
||||||
# set_shell -> shell name
|
# link_dotfiles -> profile name (str)
|
||||||
|
# set_shell -> shell name (str)
|
||||||
critical: bool # Stop on failure?
|
critical: bool # Stop on failure?
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -589,6 +635,8 @@ class BootstrapPlan:
|
|||||||
summary: str
|
summary: str
|
||||||
```
|
```
|
||||||
|
|
||||||
|
**Note on env var validation:** `plan_bootstrap` validates required env vars eagerly and raises `ConfigError` if any are missing. This is a pure check (comparing `profile.requires` against the provided `env` dict) and happens before any actions are generated. It is not an action in the plan -- missing env vars are a precondition failure that prevents plan creation.
|
||||||
|
|
||||||
### 6.2 Setup Modules
|
### 6.2 Setup Modules
|
||||||
|
|
||||||
Each module type is a small class with a plan method (pure) that returns shell commands to execute.
|
Each module type is a small class with a plan method (pure) that returns shell commands to execute.
|
||||||
@@ -673,9 +721,24 @@ profiles:
|
|||||||
### 7.1 Models
|
### 7.1 Models
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class TargetConfig:
|
||||||
|
"""A target as defined in config YAML. Parsed from the 'targets' section.
|
||||||
|
|
||||||
|
Supports two YAML formats:
|
||||||
|
personal@orb: personal.orb # shorthand: key is namespace@platform, value is host
|
||||||
|
work@ec2: # dict form
|
||||||
|
host: work.ec2.internal
|
||||||
|
identity: ~/.ssh/id_work
|
||||||
|
"""
|
||||||
|
namespace: str
|
||||||
|
platform: str
|
||||||
|
host: str
|
||||||
|
identity: str | None = None
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class Target:
|
class Target:
|
||||||
"""A resolved SSH target."""
|
"""A fully resolved SSH target (after merging CLI args + config + templates)."""
|
||||||
user: str
|
user: str
|
||||||
namespace: str
|
namespace: str
|
||||||
platform: str
|
platform: str
|
||||||
@@ -797,8 +860,9 @@ Each service is a class receiving dependencies via constructor. All mutating met
|
|||||||
class DotfilesService:
|
class DotfilesService:
|
||||||
def __init__(self, ctx: FlowContext): ...
|
def __init__(self, ctx: FlowContext): ...
|
||||||
|
|
||||||
def init(self, repo_url: str) -> None:
|
def init(self, repo_url: str, *, dry_run: bool) -> None:
|
||||||
"""Clone dotfiles repo + discover and clone all module repos."""
|
"""Clone dotfiles repo + discover and clone all module repos.
|
||||||
|
dry_run: prints repos that would be cloned without cloning."""
|
||||||
|
|
||||||
def link(self, *, profile: str | None, packages: list[str] | None,
|
def link(self, *, profile: str | None, packages: list[str] | None,
|
||||||
force: bool, dry_run: bool) -> None:
|
force: bool, dry_run: bool) -> None:
|
||||||
@@ -808,25 +872,44 @@ class DotfilesService:
|
|||||||
"""Remove managed links."""
|
"""Remove managed links."""
|
||||||
|
|
||||||
def status(self, packages: list[str] | None) -> None:
|
def status(self, packages: list[str] | None) -> None:
|
||||||
"""Show package list, link health, module info."""
|
"""Show package list, link health, module info. Read-only."""
|
||||||
|
|
||||||
def edit(self, target: str) -> None:
|
def edit(self, target: str, *, no_commit: bool) -> None:
|
||||||
"""Pull relevant repo -> open editor -> commit + push."""
|
"""Pull relevant repo -> open editor -> commit + push.
|
||||||
|
Interactive command -- dry_run not applicable (editor is the point).
|
||||||
|
no_commit: skip the auto-commit/push after editing."""
|
||||||
|
|
||||||
def repos_list(self) -> None: ...
|
def repos_list(self) -> None:
|
||||||
def repos_status(self, repo_filter: str | None) -> None: ...
|
"""List all managed repos. Read-only."""
|
||||||
def repos_pull(self, repo_filter: str | None) -> None: ...
|
|
||||||
def repos_push(self, repo_filter: str | None) -> None: ...
|
def repos_status(self, repo_filter: str | None) -> None:
|
||||||
|
"""Git status for repos. Read-only."""
|
||||||
|
|
||||||
|
def repos_pull(self, repo_filter: str | None, *, dry_run: bool) -> None:
|
||||||
|
"""Pull one or all repos. dry_run: shows what would be pulled."""
|
||||||
|
|
||||||
|
def repos_push(self, repo_filter: str | None, *, dry_run: bool) -> None:
|
||||||
|
"""Push one or all repos. dry_run: shows what would be pushed."""
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Link flow detail
|
#### Link flow detail
|
||||||
|
|
||||||
|
The service layer performs all I/O (directory walking, YAML reading, filesystem checks) and passes pre-built data structures to pure domain functions:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def link(self, ...):
|
def link(self, ...):
|
||||||
packages = discover_packages(dotfiles_dir, profile) # domain
|
# I/O: walk dirs, read _module.yaml files, build Package objects
|
||||||
targets = resolve_all_targets(packages, home, skip) # domain
|
packages = self._discover_packages(profile)
|
||||||
current = self._load_linked_state() # I/O
|
|
||||||
plan = plan_link(targets, current) # domain
|
# Pure: resolve file paths to LinkTargets
|
||||||
|
targets = resolve_all_targets(packages, home, skip)
|
||||||
|
|
||||||
|
# I/O: load persisted state
|
||||||
|
current = self._load_linked_state()
|
||||||
|
|
||||||
|
# Pure (with injected callback): build plan with filesystem checks
|
||||||
|
plan = plan_link(targets, current,
|
||||||
|
filesystem_check=self._check_path_on_disk)
|
||||||
|
|
||||||
if plan.conflicts and not force:
|
if plan.conflicts and not force:
|
||||||
raise PlanConflict(...)
|
raise PlanConflict(...)
|
||||||
@@ -860,8 +943,13 @@ class BootstrapService:
|
|||||||
dry_run: bool) -> None:
|
dry_run: bool) -> None:
|
||||||
"""Parse profile -> build plan -> execute actions sequentially."""
|
"""Parse profile -> build plan -> execute actions sequentially."""
|
||||||
|
|
||||||
def list(self) -> None: ...
|
def list(self) -> None:
|
||||||
def show(self, profile: str) -> None: ...
|
"""List available profiles with summary info. Read-only."""
|
||||||
|
|
||||||
|
def show(self, profile: str, *, dry_run: bool) -> None:
|
||||||
|
"""Show profile details. When dry_run=True (or always, effectively),
|
||||||
|
builds and prints the full BootstrapPlan without executing.
|
||||||
|
'setup show' is conceptually 'setup run --dry-run' scoped to one profile."""
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Run flow detail
|
#### Run flow detail
|
||||||
@@ -909,12 +997,15 @@ class ContainerService:
|
|||||||
|
|
||||||
def create(self, name: str, image: str, project: str | None, *,
|
def create(self, name: str, image: str, project: str | None, *,
|
||||||
dry_run: bool) -> None: ...
|
dry_run: bool) -> None: ...
|
||||||
def attach(self, name: str) -> None: ...
|
def attach(self, name: str) -> None:
|
||||||
def exec(self, name: str, cmd: list[str] | None) -> None: ...
|
"""Interactive -- attach to tmux session. No dry_run."""
|
||||||
def list(self) -> None: ...
|
def exec(self, name: str, cmd: list[str] | None) -> None:
|
||||||
def stop(self, name: str, *, kill: bool) -> None: ...
|
"""Interactive -- exec into container. No dry_run."""
|
||||||
def remove(self, name: str, *, force: bool) -> None: ...
|
def list(self) -> None:
|
||||||
def respawn(self, name: str) -> None: ...
|
"""Read-only."""
|
||||||
|
def stop(self, name: str, *, kill: bool, dry_run: bool) -> None: ...
|
||||||
|
def remove(self, name: str, *, force: bool, dry_run: bool) -> None: ...
|
||||||
|
def respawn(self, name: str, *, dry_run: bool) -> None: ...
|
||||||
```
|
```
|
||||||
|
|
||||||
### 9.6 ProjectService
|
### 9.6 ProjectService
|
||||||
@@ -923,9 +1014,14 @@ class ContainerService:
|
|||||||
class ProjectService:
|
class ProjectService:
|
||||||
def __init__(self, ctx: FlowContext): ...
|
def __init__(self, ctx: FlowContext): ...
|
||||||
|
|
||||||
def check(self, *, fetch: bool) -> None: ...
|
def check(self, *, fetch: bool) -> None:
|
||||||
def fetch(self) -> None: ...
|
"""Read-only (fetch is network I/O but does not mutate local state
|
||||||
def summary(self) -> None: ...
|
beyond FETCH_HEAD -- acceptable for a check command)."""
|
||||||
|
def fetch(self) -> None:
|
||||||
|
"""Fetch all remotes. Network I/O only, no local mutations beyond
|
||||||
|
remote tracking refs. dry_run not applicable."""
|
||||||
|
def summary(self) -> None:
|
||||||
|
"""Read-only."""
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -1011,6 +1107,9 @@ defaults:
|
|||||||
container-tag: latest
|
container-tag: latest
|
||||||
tmux-session: default
|
tmux-session: default
|
||||||
|
|
||||||
|
# Targets: two formats supported
|
||||||
|
# Shorthand: "namespace@platform: host [identity]"
|
||||||
|
# Dict form: "namespace@platform: {host: ..., identity: ...}"
|
||||||
targets:
|
targets:
|
||||||
personal@orb: personal.orb
|
personal@orb: personal.orb
|
||||||
work@ec2:
|
work@ec2:
|
||||||
@@ -1037,6 +1136,15 @@ packages:
|
|||||||
bin: [bin/nvim]
|
bin: [bin/nvim]
|
||||||
share: [share/nvim]
|
share: [share/nvim]
|
||||||
|
|
||||||
|
- name: docker
|
||||||
|
type: pkg
|
||||||
|
sources:
|
||||||
|
apt: docker-ce
|
||||||
|
allow-sudo: true # Allow sudo in post-install hook
|
||||||
|
post-install: | # Shell script, runs after install
|
||||||
|
sudo groupadd docker || true
|
||||||
|
sudo usermod -aG docker $USER
|
||||||
|
|
||||||
profiles:
|
profiles:
|
||||||
linux-work:
|
linux-work:
|
||||||
os: linux
|
os: linux
|
||||||
@@ -1052,10 +1160,49 @@ profiles:
|
|||||||
keys:
|
keys:
|
||||||
- type: ed25519
|
- type: ed25519
|
||||||
comment: "{{ env.USER_EMAIL }}"
|
comment: "{{ env.USER_EMAIL }}"
|
||||||
|
- type: runcmd
|
||||||
|
commands:
|
||||||
|
- "sudo groupadd docker || true"
|
||||||
packages:
|
packages:
|
||||||
- git
|
- git
|
||||||
- fd
|
- fd
|
||||||
- binary/neovim
|
- binary/neovim
|
||||||
|
- name: docker
|
||||||
|
allow-sudo: true
|
||||||
|
```
|
||||||
|
|
||||||
|
### Path constants (`core/paths.py`)
|
||||||
|
|
||||||
|
All XDG-compliant, hardcoded in `core/paths.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
CONFIG_DIR = XDG_CONFIG_HOME / "flow" # ~/.config/flow/
|
||||||
|
DATA_DIR = XDG_DATA_HOME / "flow" # ~/.local/share/flow/
|
||||||
|
STATE_DIR = XDG_STATE_HOME / "flow" # ~/.local/state/flow/
|
||||||
|
|
||||||
|
DOTFILES_DIR = DATA_DIR / "dotfiles" # Cloned dotfiles repo
|
||||||
|
MODULES_DIR = DATA_DIR / "modules" # Module cache (each module cloned here)
|
||||||
|
PACKAGES_DIR = DATA_DIR / "packages" # Binary package scratch
|
||||||
|
|
||||||
|
LINKED_STATE = STATE_DIR / "linked.json" # Current link state
|
||||||
|
INSTALLED_STATE = STATE_DIR / "installed.json" # Installed packages state
|
||||||
|
|
||||||
|
# Self-hosted config (from dotfiles repo, takes priority over CONFIG_DIR)
|
||||||
|
DOTFILES_FLOW_CONFIG = DOTFILES_DIR / "_shared" / "flow" / ".config" / "flow"
|
||||||
|
```
|
||||||
|
|
||||||
|
`MODULES_DIR` is the `modules_base` passed to `module_cache_dir()` and other domain functions.
|
||||||
|
|
||||||
|
### Target config normalization
|
||||||
|
|
||||||
|
The config parser in `core/config.py` normalizes both target formats into `TargetConfig` objects:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# Shorthand: "personal@orb: personal.orb"
|
||||||
|
# -> TargetConfig(namespace="personal", platform="orb", host="personal.orb", identity=None)
|
||||||
|
|
||||||
|
# Dict: "work@ec2: {host: ..., identity: ...}"
|
||||||
|
# -> TargetConfig(namespace="work", platform="ec2", host="work.ec2.internal", identity="~/.ssh/id_work")
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
Reference in New Issue
Block a user