wraith-cli 1.4.0__tar.gz → 1.5.0__tar.gz
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.
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/CHANGELOG.md +2 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/PKG-INFO +3 -1
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/docs/usage.md +44 -7
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/mkdocs.yml +0 -1
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/pyproject.toml +3 -1
- wraith_cli-1.5.0/src/wraith_cli/config.py +134 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/src/wraith_cli/main.py +190 -54
- wraith_cli-1.5.0/src/wraith_cli/providers.py +74 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/src/wraith_cli/qol.py +10 -4
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/src/wraith_cli/shield.py +13 -36
- wraith_cli-1.5.0/tests/conftest.py +39 -0
- wraith_cli-1.5.0/tests/test_cli.py +484 -0
- wraith_cli-1.5.0/tests/test_config.py +117 -0
- wraith_cli-1.5.0/tests/test_providers.py +126 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/tests/test_repo_make.py +8 -2
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/tests/test_shield.py +59 -70
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/uv.lock +135 -1
- wraith_cli-1.4.0/tests/test_cli.py +0 -301
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.cz.yaml +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.env.example +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.gitea/CODEOWNERS.md +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.gitea/PULL_REQUEST_TEMPLATE.md +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.gitea/workflows/pages.yml +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.gitea/workflows/release.yml +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.gitea/workflows/test.yml +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.gitignore +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.pre-commit-config.yaml +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/.secrets.baseline +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/CONTRIBUTING.md +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/LICENSE +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/README.md +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/bin/build.sh +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/bin/run_tests.sh +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/bin/setup_venv.sh +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/docs/architecture.md +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/docs/index.md +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/docs/reference.md +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/docs/security.md +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/src/wraith_cli/__init__.py +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/src/wraith_cli/assets.py +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/src/wraith_cli/repo_make.py +0 -0
- {wraith_cli-1.4.0 → wraith_cli-1.5.0}/tests/test_qol.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: wraith-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: Sovereign Command Centre for a Ghost Stack
|
|
5
5
|
Project-URL: Homepage, https://git.thomaspeoples.com/thomaspeoples/wraith-cli
|
|
6
6
|
Project-URL: Documentation, https://www.thomaspeoples.com/gitea-repos/wraith-cli/
|
|
@@ -32,7 +32,9 @@ Classifier: Programming Language :: Python :: 3
|
|
|
32
32
|
Classifier: Programming Language :: Python :: 3.12
|
|
33
33
|
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
34
34
|
Requires-Python: >=3.12
|
|
35
|
+
Requires-Dist: pydantic-settings>=2.0.0
|
|
35
36
|
Requires-Dist: python-dotenv
|
|
37
|
+
Requires-Dist: pyyaml>=6.0.1
|
|
36
38
|
Requires-Dist: requests
|
|
37
39
|
Requires-Dist: rich>=13.0.0
|
|
38
40
|
Requires-Dist: typer>=0.9.0
|
|
@@ -17,22 +17,41 @@ $ [OPTIONS] COMMAND [ARGS]...
|
|
|
17
17
|
|
|
18
18
|
**Commands**:
|
|
19
19
|
|
|
20
|
+
* `init`: Bootstrap a new Wraith environment.
|
|
20
21
|
* `status`: Check the heartbeat of the OpenViking Stack.
|
|
22
|
+
* `health`: Visualise health of all configured services.
|
|
21
23
|
* `update`: Sync Wraith with the latest PyPI release.
|
|
22
24
|
* `ps`: List running containers with Sovereign...
|
|
23
25
|
* `tail`: Stream live logs from a specific service.
|
|
24
26
|
* `runner-reset`: Repair hung or offline Gitea Action Runners.
|
|
25
|
-
* `spawn`:
|
|
27
|
+
* `spawn`: Scaffold a new repository instantly.
|
|
26
28
|
* `logs`: Unified hardware health and logging portal.
|
|
27
29
|
* `mesh`: Map the Tailscale Ghost Mesh network.
|
|
28
30
|
* `audit`: Execute a Sovereign security and...
|
|
29
31
|
|
|
32
|
+
## `init`
|
|
33
|
+
|
|
34
|
+
Bootstrap a new Wraith environment.
|
|
35
|
+
|
|
36
|
+
Interactive wizard to configure the stack name, API URLs,
|
|
37
|
+
and auto-probe for Docker and Tailscale presence.
|
|
38
|
+
|
|
39
|
+
**Usage**:
|
|
40
|
+
|
|
41
|
+
```console
|
|
42
|
+
$ init [OPTIONS]
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
**Options**:
|
|
46
|
+
|
|
47
|
+
* `--help`: Show this message and exit.
|
|
48
|
+
|
|
30
49
|
## `status`
|
|
31
50
|
|
|
32
51
|
Check the heartbeat of the OpenViking Stack.
|
|
33
52
|
|
|
34
|
-
|
|
35
|
-
|
|
53
|
+
Polls the configured VIKING_BASE_URL health
|
|
54
|
+
endpoint to verify if the orchestrator API is responsive.
|
|
36
55
|
|
|
37
56
|
**Usage**:
|
|
38
57
|
|
|
@@ -44,6 +63,23 @@ $ status [OPTIONS]
|
|
|
44
63
|
|
|
45
64
|
* `--help`: Show this message and exit.
|
|
46
65
|
|
|
66
|
+
## `health`
|
|
67
|
+
|
|
68
|
+
Visualise health of all configured services.
|
|
69
|
+
|
|
70
|
+
Polls the health_url for each service defined in .wraith.yaml.
|
|
71
|
+
Automatically parses OpenAPI/Swagger specs to find /health endpoints.
|
|
72
|
+
|
|
73
|
+
**Usage**:
|
|
74
|
+
|
|
75
|
+
```console
|
|
76
|
+
$ health [OPTIONS]
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
**Options**:
|
|
80
|
+
|
|
81
|
+
* `--help`: Show this message and exit.
|
|
82
|
+
|
|
47
83
|
## `update`
|
|
48
84
|
|
|
49
85
|
Sync Wraith with the latest PyPI release.
|
|
@@ -91,8 +127,9 @@ environment variables, or local directory discovery.
|
|
|
91
127
|
|
|
92
128
|
Priority:
|
|
93
129
|
1. Passed flag --path
|
|
94
|
-
2.
|
|
95
|
-
3.
|
|
130
|
+
2. Configured compose_path
|
|
131
|
+
3. Discovered via search_roots
|
|
132
|
+
4. Current Directory
|
|
96
133
|
|
|
97
134
|
**Usage**:
|
|
98
135
|
|
|
@@ -106,7 +143,7 @@ $ tail [OPTIONS] SERVICE
|
|
|
106
143
|
|
|
107
144
|
**Options**:
|
|
108
145
|
|
|
109
|
-
* `-p, --path PATH`: Path to directory with docker-compose.yml.
|
|
146
|
+
* `-p, --path PATH`: Path to directory with docker-compose.yml.
|
|
110
147
|
* `--help`: Show this message and exit.
|
|
111
148
|
|
|
112
149
|
## `runner-reset`
|
|
@@ -131,7 +168,7 @@ $ runner-reset [OPTIONS]
|
|
|
131
168
|
|
|
132
169
|
## `spawn`
|
|
133
170
|
|
|
134
|
-
|
|
171
|
+
Scaffold a new repository instantly.
|
|
135
172
|
|
|
136
173
|
Atomic scaffolding for new Ghost Stack repositories.
|
|
137
174
|
Clones a template, bleaches git history,
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "wraith-cli"
|
|
7
|
-
version = "1.
|
|
7
|
+
version = "1.5.0"
|
|
8
8
|
description = "Sovereign Command Centre for a Ghost Stack"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.12"
|
|
@@ -27,6 +27,8 @@ dependencies = [
|
|
|
27
27
|
"python-dotenv",
|
|
28
28
|
"rich>=13.0.0",
|
|
29
29
|
"typer>=0.9.0",
|
|
30
|
+
"pydantic-settings>=2.0.0",
|
|
31
|
+
"pyyaml>=6.0.1",
|
|
30
32
|
]
|
|
31
33
|
|
|
32
34
|
[project.urls]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
from functools import lru_cache
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
import yaml
|
|
6
|
+
from pydantic import AliasChoices, BaseModel, Field, HttpUrl
|
|
7
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ServiceModel(BaseModel):
|
|
11
|
+
path: Path
|
|
12
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
13
|
+
health_url: HttpUrl | None = None
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class TemplateModel(BaseModel):
|
|
17
|
+
url: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class WraithSettings(BaseSettings):
|
|
21
|
+
stack_name: str = "Ghost Stack"
|
|
22
|
+
viking_api: HttpUrl | None = None
|
|
23
|
+
search_roots: list[Path] = Field(default_factory=list)
|
|
24
|
+
services: dict[str, ServiceModel] = Field(default_factory=dict)
|
|
25
|
+
docker_enabled: bool = False
|
|
26
|
+
tailscale_enabled: bool = False
|
|
27
|
+
|
|
28
|
+
# Provider Settings
|
|
29
|
+
forge_api_url: str | None = None
|
|
30
|
+
forge_token: str | None = None
|
|
31
|
+
forge_type: str = "gitea"
|
|
32
|
+
templates: dict[str, TemplateModel] = Field(default_factory=dict)
|
|
33
|
+
|
|
34
|
+
# Legacy .env mappings
|
|
35
|
+
gitea_api_url: str | None = Field(
|
|
36
|
+
default=None,
|
|
37
|
+
validation_alias=AliasChoices("WRAITH_GITEA_API_URL", "GITEA_API_URL"),
|
|
38
|
+
)
|
|
39
|
+
gitea_token: str | None = Field(
|
|
40
|
+
default=None,
|
|
41
|
+
validation_alias=AliasChoices("WRAITH_GITEA_TOKEN", "GITEA_TOKEN"),
|
|
42
|
+
)
|
|
43
|
+
gitea_template_url: str | None = Field(
|
|
44
|
+
default=None,
|
|
45
|
+
validation_alias=AliasChoices(
|
|
46
|
+
"WRAITH_GITEA_TEMPLATE_URL", "GITEA_TEMPLATE_URL"
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
gitea_compose_path: Path | None = Field(
|
|
50
|
+
default=None,
|
|
51
|
+
validation_alias=AliasChoices(
|
|
52
|
+
"WRAITH_GITEA_COMPOSE_PATH", "GITEA_COMPOSE_PATH"
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
compose_path: Path | None = Field(
|
|
56
|
+
default=None,
|
|
57
|
+
validation_alias=AliasChoices("WRAITH_COMPOSE_PATH", "COMPOSE_PATH"),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
# Ingests .env variables as fallback, prioritizing WRAITH_ prefix
|
|
61
|
+
model_config = SettingsConfigDict(
|
|
62
|
+
env_prefix="WRAITH_",
|
|
63
|
+
env_file=".env",
|
|
64
|
+
env_file_encoding="utf-8",
|
|
65
|
+
extra="ignore",
|
|
66
|
+
populate_by_name=True,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@lru_cache
|
|
71
|
+
def get_settings() -> WraithSettings:
|
|
72
|
+
"""
|
|
73
|
+
Load settings with priority:
|
|
74
|
+
1. Local .wraith.yaml (CWD)
|
|
75
|
+
2. User ~/.config/wraith/config.yaml
|
|
76
|
+
3. Environment variables (WRAITH_ prefix & .env fallback)
|
|
77
|
+
"""
|
|
78
|
+
yaml_data: dict[str, Any] = {}
|
|
79
|
+
|
|
80
|
+
# 2. User-level config
|
|
81
|
+
user_config = Path.home() / ".config" / "wraith" / "config.yaml"
|
|
82
|
+
if user_config.exists(): # pragma: no cover
|
|
83
|
+
with open(user_config) as f:
|
|
84
|
+
parsed = yaml.safe_load(f)
|
|
85
|
+
if isinstance(parsed, dict):
|
|
86
|
+
yaml_data.update(parsed)
|
|
87
|
+
|
|
88
|
+
# 1. Local config (overrides user-level)
|
|
89
|
+
local_config = Path.cwd() / ".wraith.yaml"
|
|
90
|
+
if local_config.exists(): # pragma: no cover
|
|
91
|
+
with open(local_config) as f:
|
|
92
|
+
parsed = yaml.safe_load(f)
|
|
93
|
+
if isinstance(parsed, dict):
|
|
94
|
+
yaml_data.update(parsed)
|
|
95
|
+
|
|
96
|
+
# Pydantic BaseSettings kwargs override environment variables.
|
|
97
|
+
return WraithSettings(**yaml_data)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def discover_service(
|
|
101
|
+
service_name: str, search_roots: list[Path]
|
|
102
|
+
) -> Path | None: # pragma: no cover
|
|
103
|
+
"""
|
|
104
|
+
Recursively search for docker-compose.yml files in search_roots.
|
|
105
|
+
Matches if the directory name or container_name equals the service_name.
|
|
106
|
+
"""
|
|
107
|
+
for root in search_roots:
|
|
108
|
+
if not root.exists() or not root.is_dir():
|
|
109
|
+
continue
|
|
110
|
+
|
|
111
|
+
for compose_file in root.rglob("docker-compose.yml"):
|
|
112
|
+
# Match by directory name
|
|
113
|
+
if compose_file.parent.name == service_name:
|
|
114
|
+
return compose_file
|
|
115
|
+
|
|
116
|
+
# Match by container_name or service key in yaml
|
|
117
|
+
try:
|
|
118
|
+
with open(compose_file) as f:
|
|
119
|
+
compose_data = yaml.safe_load(f)
|
|
120
|
+
if compose_data and isinstance(compose_data, dict):
|
|
121
|
+
services = compose_data.get("services", {})
|
|
122
|
+
for svc_key, svc_def in services.items():
|
|
123
|
+
if svc_key == service_name:
|
|
124
|
+
return compose_file
|
|
125
|
+
if (
|
|
126
|
+
isinstance(svc_def, dict)
|
|
127
|
+
and svc_def.get("container_name")
|
|
128
|
+
== service_name
|
|
129
|
+
):
|
|
130
|
+
return compose_file
|
|
131
|
+
except Exception:
|
|
132
|
+
pass # Ignore unparsable files during discovery
|
|
133
|
+
|
|
134
|
+
return None
|
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
import os
|
|
2
1
|
import subprocess
|
|
3
2
|
from importlib.metadata import version as get_local_version
|
|
4
3
|
from pathlib import Path
|
|
5
4
|
from typing import Annotated
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
6
|
|
|
7
7
|
import requests
|
|
8
8
|
import typer
|
|
9
|
-
|
|
9
|
+
import yaml
|
|
10
10
|
from rich.align import Align
|
|
11
11
|
from rich.markdown import Markdown
|
|
12
12
|
from rich.panel import Panel
|
|
13
|
+
from rich.prompt import Confirm, Prompt
|
|
14
|
+
from rich.table import Table
|
|
13
15
|
from rich.text import Text
|
|
14
16
|
|
|
15
|
-
from wraith_cli import assets, qol, repo_make, shield
|
|
17
|
+
from wraith_cli import assets, config, qol, repo_make, shield
|
|
16
18
|
|
|
17
19
|
SOVEREIGN_ADVISORY = """
|
|
18
20
|
## Sovereign Security Advisory – Please Read
|
|
@@ -29,23 +31,13 @@ Recommended baseline:
|
|
|
29
31
|
- Audit environment variable scope (`GITEA_COMPOSE_PATH`, etc.)
|
|
30
32
|
"""
|
|
31
33
|
|
|
32
|
-
# Load environment variables from .env
|
|
33
|
-
load_dotenv()
|
|
34
|
-
|
|
35
34
|
# Initialise Typer App
|
|
36
35
|
app = typer.Typer(
|
|
37
36
|
help="Wraith Sovereign CLI: Ghost Stack Orchestrator",
|
|
38
37
|
rich_markup_mode="rich",
|
|
39
38
|
)
|
|
40
39
|
|
|
41
|
-
|
|
42
|
-
VIKING_URL = os.getenv(
|
|
43
|
-
"VIKING_BASE_URL", "http://127.0.0.1:1933/api/v1"
|
|
44
|
-
).rstrip("/")
|
|
45
|
-
GITEA_PATH = os.getenv("GITEA_COMPOSE_PATH")
|
|
46
|
-
GITEA_API_URL = os.getenv("GITEA_API_URL")
|
|
47
|
-
GITEA_TOKEN = os.getenv("GITEA_TOKEN")
|
|
48
|
-
GITEA_TEMPLATE_URL = os.getenv("GITEA_TEMPLATE_URL")
|
|
40
|
+
SETTINGS = config.get_settings()
|
|
49
41
|
|
|
50
42
|
|
|
51
43
|
# --- Callbacks ---
|
|
@@ -63,7 +55,7 @@ def version_callback(value: bool):
|
|
|
63
55
|
|
|
64
56
|
|
|
65
57
|
def print_wraith_welcome(): # pragma: no cover
|
|
66
|
-
"""Renders the definitive
|
|
58
|
+
"""Renders the definitive Sovereign splash."""
|
|
67
59
|
console = qol.console
|
|
68
60
|
local_v = get_local_version("wraith-cli")
|
|
69
61
|
|
|
@@ -121,15 +113,73 @@ def main(
|
|
|
121
113
|
# --- Commands ---
|
|
122
114
|
|
|
123
115
|
|
|
116
|
+
@app.command()
|
|
117
|
+
def init(): # pragma: no cover
|
|
118
|
+
"""
|
|
119
|
+
Bootstrap a new Wraith environment.
|
|
120
|
+
|
|
121
|
+
Interactive wizard to configure the stack name, API URLs,
|
|
122
|
+
and auto-probe for Docker and Tailscale presence.
|
|
123
|
+
"""
|
|
124
|
+
typer.echo("Initialising Wraith Sovereign Environment...")
|
|
125
|
+
stack_name = Prompt.ask("Stack Name", default="Ghost Stack")
|
|
126
|
+
viking_api = Prompt.ask("Viking API URL (optional)", default="")
|
|
127
|
+
|
|
128
|
+
if viking_api:
|
|
129
|
+
try:
|
|
130
|
+
typer.echo(f"🔍 Validating API: {viking_api}")
|
|
131
|
+
resp = requests.head(viking_api, timeout=3)
|
|
132
|
+
resp.raise_for_status()
|
|
133
|
+
except Exception as e:
|
|
134
|
+
typer.secho(f"⚠️ API Validation failed: {e}", fg="yellow")
|
|
135
|
+
if not Confirm.ask("Proceed anyway?", default=True):
|
|
136
|
+
raise typer.Exit(1) from e
|
|
137
|
+
|
|
138
|
+
typer.echo("🔍 Probing environment...")
|
|
139
|
+
docker_res = subprocess.run(
|
|
140
|
+
["docker", "info"], capture_output=True, check=False
|
|
141
|
+
)
|
|
142
|
+
docker_enabled = docker_res.returncode == 0
|
|
143
|
+
d_msg = "✅ Found" if docker_enabled else "❌ Not Found"
|
|
144
|
+
typer.echo(f" └── Docker: {d_msg}")
|
|
145
|
+
|
|
146
|
+
tailscale_res = subprocess.run(
|
|
147
|
+
["tailscale", "status"], capture_output=True, check=False
|
|
148
|
+
)
|
|
149
|
+
tailscale_enabled = tailscale_res.returncode == 0
|
|
150
|
+
ts_msg = "✅ Found" if tailscale_enabled else "❌ Not Found"
|
|
151
|
+
typer.echo(f" └── Tailscale: {ts_msg}")
|
|
152
|
+
|
|
153
|
+
yaml_data = {
|
|
154
|
+
"stack_name": stack_name,
|
|
155
|
+
"docker_enabled": docker_enabled,
|
|
156
|
+
"tailscale_enabled": tailscale_enabled,
|
|
157
|
+
}
|
|
158
|
+
if viking_api:
|
|
159
|
+
yaml_data["viking_api"] = viking_api
|
|
160
|
+
|
|
161
|
+
out_path = Path.cwd() / ".wraith.yaml"
|
|
162
|
+
with open(out_path, "w") as f:
|
|
163
|
+
yaml.dump(yaml_data, f, sort_keys=False)
|
|
164
|
+
|
|
165
|
+
typer.secho(f"✨ Configuration saved to {out_path}", fg="green", bold=True)
|
|
166
|
+
|
|
167
|
+
|
|
124
168
|
@app.command()
|
|
125
169
|
def status():
|
|
126
170
|
"""
|
|
127
|
-
|
|
171
|
+
Check the heartbeat of the OpenViking Stack.
|
|
128
172
|
|
|
129
|
-
|
|
130
|
-
|
|
173
|
+
Polls the configured VIKING_BASE_URL health
|
|
174
|
+
endpoint to verify if the orchestrator API is responsive.
|
|
131
175
|
"""
|
|
132
|
-
|
|
176
|
+
settings = config.get_settings()
|
|
177
|
+
viking_url = (
|
|
178
|
+
str(settings.viking_api).rstrip("/")
|
|
179
|
+
if settings.viking_api
|
|
180
|
+
else "http://127.0.0.1:1933/api/v1"
|
|
181
|
+
)
|
|
182
|
+
url = f"{viking_url}/health"
|
|
133
183
|
try:
|
|
134
184
|
response = requests.get(url, timeout=3)
|
|
135
185
|
if response.status_code == 200:
|
|
@@ -143,6 +193,67 @@ def status():
|
|
|
143
193
|
typer.secho("🔴 OpenViking: Offline", fg=typer.colors.RED)
|
|
144
194
|
|
|
145
195
|
|
|
196
|
+
@app.command()
|
|
197
|
+
def health(): # pragma: no cover
|
|
198
|
+
"""
|
|
199
|
+
Visualise health of all configured services.
|
|
200
|
+
|
|
201
|
+
Polls the health_url for each service defined in .wraith.yaml.
|
|
202
|
+
Automatically parses OpenAPI/Swagger specs to find /health endpoints.
|
|
203
|
+
"""
|
|
204
|
+
table = Table(title=f"{SETTINGS.stack_name} - Service Health")
|
|
205
|
+
table.add_column("Service", style="cyan")
|
|
206
|
+
table.add_column("Status", style="bold")
|
|
207
|
+
table.add_column("Details")
|
|
208
|
+
|
|
209
|
+
if not SETTINGS.services:
|
|
210
|
+
typer.secho("No services configured in .wraith.yaml", fg="yellow")
|
|
211
|
+
return
|
|
212
|
+
|
|
213
|
+
for name, svc in SETTINGS.services.items():
|
|
214
|
+
if not svc.health_url:
|
|
215
|
+
table.add_row(
|
|
216
|
+
name, "[yellow]Unknown[/yellow]", "No health_url configured"
|
|
217
|
+
)
|
|
218
|
+
continue
|
|
219
|
+
|
|
220
|
+
url = str(svc.health_url)
|
|
221
|
+
try:
|
|
222
|
+
resp = requests.get(url, timeout=5)
|
|
223
|
+
|
|
224
|
+
# OpenAPI Schema Intelligence
|
|
225
|
+
if "application/json" in resp.headers.get("Content-Type", ""):
|
|
226
|
+
data = resp.json()
|
|
227
|
+
is_spec = isinstance(data, dict) and (
|
|
228
|
+
"openapi" in data or "swagger" in data
|
|
229
|
+
)
|
|
230
|
+
if is_spec:
|
|
231
|
+
paths = data.get("paths", {})
|
|
232
|
+
health_endpoint = None
|
|
233
|
+
for ep in ["/health", "/status"]:
|
|
234
|
+
if ep in paths:
|
|
235
|
+
health_endpoint = ep
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
if health_endpoint:
|
|
239
|
+
actual_url = urljoin(url, health_endpoint)
|
|
240
|
+
resp = requests.get(actual_url, timeout=5)
|
|
241
|
+
url = actual_url
|
|
242
|
+
|
|
243
|
+
if resp.status_code == 200:
|
|
244
|
+
table.add_row(
|
|
245
|
+
name, "[green]Online[/green]", f"HTTP 200 ({url})"
|
|
246
|
+
)
|
|
247
|
+
else:
|
|
248
|
+
table.add_row(
|
|
249
|
+
name, f"[yellow]Warning ({resp.status_code})[/yellow]", url
|
|
250
|
+
)
|
|
251
|
+
except Exception as e:
|
|
252
|
+
table.add_row(name, "[red]Offline[/red]", str(e))
|
|
253
|
+
|
|
254
|
+
qol.console.print(table)
|
|
255
|
+
|
|
256
|
+
|
|
146
257
|
@app.command()
|
|
147
258
|
def update():
|
|
148
259
|
"""
|
|
@@ -167,7 +278,7 @@ def update():
|
|
|
167
278
|
typer.secho(f"❌ Update check failed: {e}", fg="red")
|
|
168
279
|
|
|
169
280
|
|
|
170
|
-
@app.command()
|
|
281
|
+
@app.command(hidden=not SETTINGS.docker_enabled)
|
|
171
282
|
def ps(
|
|
172
283
|
all: Annotated[
|
|
173
284
|
bool, typer.Option("--all", "-a", help="Show all containers")
|
|
@@ -180,19 +291,16 @@ def ps(
|
|
|
180
291
|
readable table summarising service names,
|
|
181
292
|
container status, and source images.
|
|
182
293
|
"""
|
|
183
|
-
|
|
184
|
-
if all:
|
|
185
|
-
cmd.append("-a")
|
|
186
|
-
|
|
187
|
-
# Custom format for our Rich table conversion
|
|
188
|
-
cmd.extend(["--format", "table {{.Names}}\t{{.Status}}\t{{.Image}}"])
|
|
294
|
+
from wraith_cli.providers import get_docker_provider
|
|
189
295
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
296
|
+
docker = get_docker_provider()
|
|
297
|
+
try:
|
|
298
|
+
stdout = docker.ps(all=all)
|
|
299
|
+
except RuntimeError as e:
|
|
300
|
+
typer.secho(f"❌ {e}", fg="red")
|
|
193
301
|
return
|
|
194
302
|
|
|
195
|
-
rich_table = qol.format_docker_table(
|
|
303
|
+
rich_table = qol.format_docker_table(stdout)
|
|
196
304
|
qol.console.print(rich_table)
|
|
197
305
|
|
|
198
306
|
|
|
@@ -206,7 +314,6 @@ def tail(
|
|
|
206
314
|
typer.Option(
|
|
207
315
|
"--path",
|
|
208
316
|
"-p",
|
|
209
|
-
envvar="WRAITH_COMPOSE_PATH",
|
|
210
317
|
help="Path to directory with docker-compose.yml.",
|
|
211
318
|
),
|
|
212
319
|
] = None,
|
|
@@ -220,10 +327,19 @@ def tail(
|
|
|
220
327
|
|
|
221
328
|
Priority:
|
|
222
329
|
1. Passed flag --path
|
|
223
|
-
2.
|
|
224
|
-
3.
|
|
330
|
+
2. Configured compose_path
|
|
331
|
+
3. Discovered via search_roots
|
|
332
|
+
4. Current Directory
|
|
225
333
|
"""
|
|
226
|
-
|
|
334
|
+
settings = config.get_settings()
|
|
335
|
+
target_path = path or settings.compose_path
|
|
336
|
+
|
|
337
|
+
if not target_path and settings.search_roots:
|
|
338
|
+
discovered = config.discover_service(service, settings.search_roots)
|
|
339
|
+
if discovered:
|
|
340
|
+
target_path = discovered.parent
|
|
341
|
+
|
|
342
|
+
target_path = target_path or Path.cwd()
|
|
227
343
|
|
|
228
344
|
typer.echo(f"🔍 Searching for {service} in {target_path}...")
|
|
229
345
|
success = qol.run_tail(service, target_path)
|
|
@@ -246,11 +362,20 @@ def runner_reset():
|
|
|
246
362
|
|
|
247
363
|
Requires GITEA_COMPOSE_PATH as an envelope variable
|
|
248
364
|
"""
|
|
249
|
-
|
|
250
|
-
|
|
365
|
+
settings = config.get_settings()
|
|
366
|
+
compose_path = settings.gitea_compose_path
|
|
367
|
+
|
|
368
|
+
if not compose_path and settings.search_roots:
|
|
369
|
+
discovered = config.discover_service(
|
|
370
|
+
"act_runner", settings.search_roots
|
|
371
|
+
)
|
|
372
|
+
if discovered:
|
|
373
|
+
compose_path = discovered.parent
|
|
374
|
+
|
|
375
|
+
if not compose_path:
|
|
376
|
+
typer.secho("❌ Error: GITEA_COMPOSE_PATH not set in config", fg="red")
|
|
251
377
|
raise typer.Exit(1)
|
|
252
378
|
|
|
253
|
-
compose_path = Path(GITEA_PATH)
|
|
254
379
|
docker_file = compose_path / "docker-compose.yml"
|
|
255
380
|
runner_data = compose_path / "data/act_runner/.runner"
|
|
256
381
|
|
|
@@ -277,43 +402,54 @@ def spawn(
|
|
|
277
402
|
),
|
|
278
403
|
):
|
|
279
404
|
"""
|
|
280
|
-
|
|
405
|
+
Scaffold a new repository instantly.
|
|
281
406
|
|
|
282
407
|
Atomic scaffolding for new Ghost Stack repositories.
|
|
283
408
|
Clones a template, bleaches git history,
|
|
284
409
|
provisions a remote repository via API, and
|
|
285
410
|
pushes the initial commit in one sequence.
|
|
286
411
|
"""
|
|
287
|
-
|
|
412
|
+
settings = config.get_settings()
|
|
413
|
+
if not all([
|
|
414
|
+
settings.forge_api_url,
|
|
415
|
+
settings.forge_token,
|
|
416
|
+
settings.templates,
|
|
417
|
+
]):
|
|
288
418
|
typer.secho(
|
|
289
|
-
"""❌ Missing
|
|
290
|
-
Check
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
and
|
|
419
|
+
"""❌ Missing Configuration!
|
|
420
|
+
Check config for
|
|
421
|
+
forge_api_url,
|
|
422
|
+
forge_token,
|
|
423
|
+
and templates.
|
|
294
424
|
""",
|
|
295
425
|
fg="red",
|
|
296
426
|
)
|
|
297
427
|
raise typer.Exit(1)
|
|
298
428
|
|
|
299
|
-
assert
|
|
300
|
-
assert
|
|
301
|
-
assert GITEA_TEMPLATE_URL is not None
|
|
429
|
+
assert settings.forge_api_url is not None
|
|
430
|
+
assert settings.forge_token is not None
|
|
302
431
|
|
|
303
432
|
target_dir = Path.cwd() / repo_name
|
|
304
433
|
typer.echo(f"👻 Spawning '{repo_name}' from Repo Factory...")
|
|
305
434
|
|
|
306
435
|
try:
|
|
307
436
|
typer.echo(" └── 🧬 Cloning template and bleaching history...")
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
remote_url = repo_make.create_gitea_repo(
|
|
312
|
-
repo_name, GITEA_API_URL, GITEA_TOKEN
|
|
437
|
+
# Use default template or fallback to first available
|
|
438
|
+
template = settings.templates.get(
|
|
439
|
+
"default", next(iter(settings.templates.values()))
|
|
313
440
|
)
|
|
441
|
+
repo_make.clone_and_bleach(
|
|
442
|
+
template.url, target_dir, settings.forge_token
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
typer.echo(" └── 🌐 Provisioning repository on Forge...")
|
|
446
|
+
from wraith_cli.providers import get_forge_provider
|
|
447
|
+
|
|
448
|
+
forge = get_forge_provider(settings)
|
|
449
|
+
remote_url = forge.create_repo(repo_name)
|
|
314
450
|
|
|
315
451
|
typer.echo(f" └── 🚀 Initialising and pushing to {remote_url}...")
|
|
316
|
-
repo_make.init_and_push(target_dir, remote_url,
|
|
452
|
+
repo_make.init_and_push(target_dir, remote_url, settings.forge_token)
|
|
317
453
|
|
|
318
454
|
typer.secho(
|
|
319
455
|
f"\n✅ Success! Gitea Repo '{repo_name}' is alive.",
|
|
@@ -356,7 +492,7 @@ def logs(
|
|
|
356
492
|
)
|
|
357
493
|
|
|
358
494
|
|
|
359
|
-
@app.command()
|
|
495
|
+
@app.command(hidden=not SETTINGS.tailscale_enabled)
|
|
360
496
|
def mesh():
|
|
361
497
|
"""
|
|
362
498
|
Map the Tailscale Ghost Mesh network.
|