chore: import upstream snapshot with attribution
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
Integ / changes (push) Has been skipped
Pre-commit / pre-commit (push) Failing after 1s
CLI exit codes / changes (push) Has been skipped
Test (Install) / changes (push) Has been skipped
Test (Python) / changes (push) Has been skipped
Test (TypeScript) / changes (push) Has been skipped
CLI exit codes / cli-gate (push) Has been cancelled
Test (Install) / test-install-gate (push) Has been cancelled
Integ / integ-gate (push) Has been cancelled
Test (Python) / test-python-gate (push) Has been cancelled
Test (TypeScript) / test-typescript-gate (push) Has been cancelled
Test (Install) / python-minimal (3.12) (push) Has been cancelled
Test (Install) / python-minimal (3.11) (push) Has been cancelled
Test (Install) / python-extra (agno, mirage.agents.agno) (push) Has been cancelled
Test (Install) / python-extra (chroma, mirage.resource.chroma) (push) Has been cancelled
Test (Install) / python-extra (pdf, mirage.core.filetype.pdf) (push) Has been cancelled
Integ / integ (push) Has been cancelled
Integ / integ-database (push) Has been cancelled
Integ / integ-database-ts (push) Has been cancelled
Integ / integ-data (push) Has been cancelled
Integ / integ-ssh (push) Has been cancelled
Integ / integ-ssh-ts (push) Has been cancelled
Test (Python) / audit (push) Has been cancelled
Test (TypeScript) / test (push) Has been cancelled
Test (TypeScript) / python-fs-shim (push) Has been cancelled
CLI exit codes / Python CLI (push) Has been cancelled
CLI exit codes / TypeScript CLI (push) Has been cancelled
CLI exit codes / Cross-language snapshot interop (push) Has been cancelled
Test (Python) / test (push) Has been cancelled
Test (Python) / import-isolation (deepagents, openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Python) / import-isolation (deepagents, pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Integ / integ-ts (push) Has been cancelled
Integ / integ-fuse (push) Has been cancelled
Test (Install) / python-extra (databricks, mirage.resource.databricks_volume) (push) Has been cancelled
Test (Install) / python-extra (deepagents, mirage.agents.langchain) (push) Has been cancelled
Test (Install) / python-extra (email, mirage.resource.email) (push) Has been cancelled
Test (Install) / python-extra (fuse, mirage.fuse.mount) (push) Has been cancelled
Test (Install) / python-extra (hdf5, mirage.core.filetype.hdf5) (push) Has been cancelled
Test (Install) / python-extra (hf, mirage.resource.hf_buckets) (push) Has been cancelled
Test (Install) / python-extra (lancedb, mirage.resource.lancedb) (push) Has been cancelled
Test (Install) / python-extra (langfuse, mirage.resource.langfuse) (push) Has been cancelled
Test (Install) / python-extra (mongodb, mirage.resource.mongodb) (push) Has been cancelled
Test (Install) / python-extra (nextcloud, mirage.resource.nextcloud) (push) Has been cancelled
Test (Install) / python-extra (openai, mirage.agents.openai_agents) (push) Has been cancelled
Test (Install) / python-extra (openhands, mirage.agents.openhands, 3.12) (push) Has been cancelled
Test (Install) / python-extra (parquet, mirage.core.filetype.parquet) (push) Has been cancelled
Test (Install) / python-extra (postgres, mirage.resource.postgres) (push) Has been cancelled
Test (Install) / python-extra (pydantic-ai, mirage.agents.pydantic_ai) (push) Has been cancelled
Test (Install) / python-extra (qdrant, mirage.resource.qdrant) (push) Has been cancelled
Test (Install) / python-extra (redis, mirage.resource.redis) (push) Has been cancelled
Test (Install) / python-extra (s3, mirage.resource.s3) (push) Has been cancelled
Test (Install) / python-extra (ssh, mirage.resource.ssh) (push) Has been cancelled
Test (Install) / ts-minimal (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,55 @@
|
||||
# Mirage CLI
|
||||
|
||||
`mirage` is the command-line client for the Mirage daemon. It talks to a local
|
||||
daemon over HTTP and auto-spawns one on first use if none is running.
|
||||
|
||||
## Basic usage
|
||||
|
||||
```bash
|
||||
# A minimal workspace config (workspace.yaml)
|
||||
cat > workspace.yaml <<'YAML'
|
||||
mounts:
|
||||
/:
|
||||
resource: ram
|
||||
mode: WRITE
|
||||
YAML
|
||||
|
||||
# Create a workspace (spawns the daemon if needed)
|
||||
mirage workspace create ./workspace.yaml --id myws
|
||||
|
||||
# Run a command in it
|
||||
mirage execute -w myws -c 'echo hello'
|
||||
|
||||
# Inspect / clean up
|
||||
mirage workspace list
|
||||
mirage workspace get myws
|
||||
mirage workspace delete myws
|
||||
|
||||
# Stop the daemon
|
||||
mirage daemon stop
|
||||
```
|
||||
|
||||
Other command groups: `session`, `provision`, `daemon`, plus
|
||||
`workspace clone|snapshot|load`. Run `mirage <command> --help` for details.
|
||||
|
||||
## Environment variables
|
||||
|
||||
| Variable | Default | Purpose |
|
||||
| --------------------------- | ------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `MIRAGE_DAEMON_URL` | `http://127.0.0.1:8765` | Daemon address the CLI connects to |
|
||||
| `MIRAGE_TOKEN` | (none) | Bearer token the CLI sends to the daemon |
|
||||
| `MIRAGE_AUTH_MODE` | `local` | Daemon auth mode: `local`, `token`, or `jwt` |
|
||||
| `MIRAGE_AUTH_TOKEN` | (auto-minted in `local`) | Token the daemon accepts |
|
||||
| `MIRAGE_IDLE_GRACE_SECONDS` | `30` | Seconds the daemon waits after its last workspace is removed before shutting down |
|
||||
| `MIRAGE_ALLOWED_HOSTS` | `127.0.0.1,localhost,::1` | Daemon Host-header allowlist (CSV; `*` disables the check) |
|
||||
| `MIRAGE_DAEMON_PORT` | `8765` | Port the spawned daemon binds |
|
||||
| `MIRAGE_PID_FILE` | `~/.mirage/daemon.pid` | Daemon pid file location |
|
||||
| `MIRAGE_VERSION_ROOT` | `~/.mirage/repos` | Bare git repos backing workspace versioning |
|
||||
| `MIRAGE_SNAPSHOT_ROOT` | `~/.mirage/snapshots` | Snapshot storage root |
|
||||
|
||||
Every daemon setting (except the bootstrap `MIRAGE_HOME` and raw
|
||||
secrets) can also live in `~/.mirage/config.toml` under `[daemon]`,
|
||||
managed with `mirage config list|get|set|unset`. Per key the
|
||||
precedence is: env var > config.toml > default. `mirage config list --resolved` prints the effective value of every key and where it came
|
||||
from. The daemon validates the `[daemon]` table at startup and
|
||||
refuses to start on unknown keys or malformed TOML; `mirage config unset <key>` accepts unknown keys so a broken file can be repaired.
|
||||
@@ -0,0 +1,17 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.cli.main import app
|
||||
|
||||
__all__ = ["app"]
|
||||
@@ -0,0 +1,167 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from mirage.cli.env import ENV_AUTH_MODE, ENV_AUTH_TOKEN
|
||||
from mirage.cli.settings import DaemonSettings, load_daemon_settings
|
||||
from mirage.server.auth import AuthMode
|
||||
from mirage.server.auth import storage as auth_storage
|
||||
from mirage.server.daemon_config import (read_daemon_table,
|
||||
validate_daemon_table)
|
||||
from mirage.server.env import ENV_DAEMON_PORT
|
||||
from mirage.server.paths import mirage_home
|
||||
|
||||
|
||||
class DaemonUnreachable(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class DaemonClient:
|
||||
"""Thin httpx wrapper around the Mirage daemon REST API.
|
||||
|
||||
Constructs once per CLI invocation and is reused across calls.
|
||||
Handles auth header injection, daemon auto-spawn on first
|
||||
``workspace --create``, and discovery via the settings chain.
|
||||
"""
|
||||
|
||||
def __init__(self, settings: DaemonSettings) -> None:
|
||||
self.settings = settings
|
||||
self._client = httpx.Client(base_url=settings.url, timeout=60.0)
|
||||
|
||||
def __enter__(self) -> "DaemonClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info) -> None:
|
||||
self._client.close()
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
if self.settings.auth_token:
|
||||
return {"Authorization": f"Bearer {self.settings.auth_token}"}
|
||||
return {}
|
||||
|
||||
def request(self, method: str, path: str, **kwargs) -> httpx.Response:
|
||||
headers = {**self._headers(), **kwargs.pop("headers", {})}
|
||||
return self._client.request(method, path, headers=headers, **kwargs)
|
||||
|
||||
def is_reachable(self, timeout: float = 0.5) -> bool:
|
||||
try:
|
||||
r = self._client.get("/v1/health",
|
||||
timeout=timeout,
|
||||
headers=self._headers())
|
||||
return r.status_code == 200
|
||||
except httpx.RequestError:
|
||||
return False
|
||||
|
||||
def ensure_running(self,
|
||||
startup_timeout: float = 5.0,
|
||||
allow_spawn: bool = True) -> None:
|
||||
"""Ensure the daemon is reachable, optionally spawning it.
|
||||
|
||||
Args:
|
||||
startup_timeout (float): seconds to wait for the spawned
|
||||
daemon to answer ``/v1/health``.
|
||||
allow_spawn (bool): if True and the daemon is unreachable,
|
||||
fork-execs ``uvicorn mirage.server.app:app`` detached.
|
||||
|
||||
Raises:
|
||||
DaemonUnreachable: daemon is not reachable and either
|
||||
``allow_spawn=False`` or the spawned daemon failed to
|
||||
come up within ``startup_timeout``.
|
||||
"""
|
||||
if self.is_reachable():
|
||||
return
|
||||
if not allow_spawn:
|
||||
raise DaemonUnreachable(
|
||||
f"daemon not reachable at {self.settings.url}; "
|
||||
"run `mirage workspace --create CONFIG.yaml` to spawn one")
|
||||
self._spawn_daemon()
|
||||
deadline = time.monotonic() + startup_timeout
|
||||
while time.monotonic() < deadline:
|
||||
if self.is_reachable(timeout=0.3):
|
||||
return
|
||||
time.sleep(0.1)
|
||||
raise DaemonUnreachable(
|
||||
f"daemon spawned but did not answer /v1/health within "
|
||||
f"{startup_timeout:.1f}s")
|
||||
|
||||
def _spawn_daemon(self) -> None:
|
||||
table = read_daemon_table(mirage_home())
|
||||
validate_daemon_table(table)
|
||||
port = self._resolve_port(table)
|
||||
env = dict(os.environ)
|
||||
if not self.settings.auth_token:
|
||||
self.settings.auth_token = auth_storage.ensure_token_file(
|
||||
auth_storage.default_token_file())
|
||||
env[ENV_AUTH_TOKEN] = self.settings.auth_token
|
||||
if ENV_AUTH_MODE not in env and not table.get("auth_mode"):
|
||||
env[ENV_AUTH_MODE] = AuthMode.LOCAL.value
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"uvicorn",
|
||||
"mirage.server.daemon:app",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--log-level",
|
||||
"warning",
|
||||
]
|
||||
log_dir = mirage_home()
|
||||
log_dir.mkdir(parents=True, exist_ok=True)
|
||||
log_file = log_dir / "daemon.log"
|
||||
with open(log_file, "ab") as f:
|
||||
subprocess.Popen(
|
||||
cmd,
|
||||
env=env,
|
||||
stdout=f,
|
||||
stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
|
||||
def _resolve_port(self, table: dict) -> int:
|
||||
"""Resolve the listen port for a spawned daemon.
|
||||
|
||||
Priority: ``$MIRAGE_DAEMON_PORT``, then the ``port`` key in
|
||||
``config.toml`` ``[daemon]``, then the port embedded in the
|
||||
client ``url`` setting, then 8765.
|
||||
|
||||
Args:
|
||||
table (dict): the already-read ``[daemon]`` table.
|
||||
|
||||
Returns:
|
||||
int: the port to bind.
|
||||
"""
|
||||
env_port = os.environ.get(ENV_DAEMON_PORT)
|
||||
if env_port:
|
||||
return int(env_port)
|
||||
config_port = table.get("port")
|
||||
if config_port:
|
||||
return int(config_port)
|
||||
return self._port_from_url()
|
||||
|
||||
def _port_from_url(self) -> int:
|
||||
parsed = urlparse(self.settings.url)
|
||||
return parsed.port or 8765
|
||||
|
||||
|
||||
def make_client() -> DaemonClient:
|
||||
return DaemonClient(load_daemon_settings())
|
||||
@@ -0,0 +1,120 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import typer
|
||||
|
||||
from mirage.cli.output import emit, fail
|
||||
from mirage.cli.settings import (get_config, list_config, resolved_config,
|
||||
set_config, unset_config)
|
||||
from mirage.server.daemon_config import ALLOWED_KEYS, DaemonConfigError
|
||||
|
||||
app = typer.Typer(no_args_is_help=True,
|
||||
help="Read and write daemon settings in config.toml.")
|
||||
|
||||
|
||||
def _mask(key: str, value: str) -> str:
|
||||
if key == "auth_token" and value:
|
||||
return "***"
|
||||
return value
|
||||
|
||||
|
||||
def _human_table(table: dict) -> str:
|
||||
return "\n".join(f"{k} = {v}" for k, v in table.items())
|
||||
|
||||
|
||||
def _human_resolved(table: dict) -> str:
|
||||
return "\n".join(f"{k} = {e['value']} ({e['origin']})"
|
||||
for k, e in table.items())
|
||||
|
||||
|
||||
def _list_resolved() -> None:
|
||||
try:
|
||||
resolved = resolved_config()
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
payload = {}
|
||||
for key, (value, origin) in resolved.items():
|
||||
payload[key] = {"value": _mask(key, value), "origin": origin}
|
||||
emit(payload, human=_human_resolved)
|
||||
|
||||
|
||||
def _list_file() -> None:
|
||||
try:
|
||||
table = list_config()
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
unknown = sorted(set(table) - ALLOWED_KEYS)
|
||||
if unknown:
|
||||
typer.echo(
|
||||
"warning: unknown [daemon] keys (daemon will refuse to "
|
||||
f"start): {', '.join(unknown)}",
|
||||
err=True)
|
||||
emit(table, human=_human_table)
|
||||
|
||||
|
||||
_RESOLVED_OPTION = typer.Option(False,
|
||||
"--resolved",
|
||||
help="show effective values and their origins")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_cmd(resolved: bool = _RESOLVED_OPTION) -> None:
|
||||
"""Print every key in the config.toml [daemon] table.
|
||||
|
||||
With --resolved, print the effective value of every key after
|
||||
applying precedence (env var > config file > default) and where
|
||||
each value came from. auth_token is masked.
|
||||
"""
|
||||
if resolved:
|
||||
_list_resolved()
|
||||
else:
|
||||
_list_file()
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def get_cmd(key: str = typer.Argument(..., help="config key")) -> None:
|
||||
"""Print one [daemon] key's value from config.toml."""
|
||||
try:
|
||||
value = get_config(key)
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
if value is None:
|
||||
fail(f"{key} is not set", exit_code=1)
|
||||
emit({key: value}, human=lambda d: str(d[key]))
|
||||
|
||||
|
||||
@app.command("set")
|
||||
def set_cmd(
|
||||
key: str = typer.Argument(..., help="config key"),
|
||||
value: str = typer.Argument(..., help="value to store"),
|
||||
) -> None:
|
||||
"""Write a [daemon] key to config.toml.
|
||||
|
||||
Path settings take effect on the next daemon start/restart.
|
||||
"""
|
||||
try:
|
||||
set_config(key, value)
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
emit({key: value, "written": True}, human=lambda d: f"{key} = {value}")
|
||||
|
||||
|
||||
@app.command("unset")
|
||||
def unset_cmd(key: str = typer.Argument(..., help="config key")) -> None:
|
||||
"""Remove a [daemon] key from config.toml."""
|
||||
try:
|
||||
unset_config(key)
|
||||
except DaemonConfigError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
emit({key: None, "unset": True}, human=lambda d: f"unset {key}")
|
||||
@@ -0,0 +1,232 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import signal
|
||||
import time
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
|
||||
from mirage.cli.client import make_client
|
||||
from mirage.cli.output import emit, fail, format_age
|
||||
from mirage.server.paths import pid_file_path
|
||||
|
||||
app = typer.Typer(no_args_is_help=True,
|
||||
help="Manage the daemon process lifecycle.")
|
||||
|
||||
|
||||
def _format_status(d: dict) -> str:
|
||||
if not d.get("running"):
|
||||
return f"Daemon not running. URL: {d['url']}"
|
||||
parts = [f"Running. PID {d.get('pid', '?')}"]
|
||||
uptime = d.get("uptime_s")
|
||||
if uptime is not None:
|
||||
parts.append(f"uptime {format_age(time.time() - uptime)}")
|
||||
ws_count = d.get("workspaces")
|
||||
if ws_count is not None:
|
||||
parts.append(f"{ws_count} workspace{'s' if ws_count != 1 else ''}")
|
||||
return ", ".join(parts) + f". URL: {d['url']}"
|
||||
|
||||
|
||||
def _format_stop(d: dict) -> str:
|
||||
via = d.get("via", "?")
|
||||
pid = d.get("pid")
|
||||
return f"Stopped (via {via}{f', PID {pid}' if pid else ''})."
|
||||
|
||||
|
||||
def _format_restart(d: dict) -> str:
|
||||
if d.get("spawned_fresh"):
|
||||
return "Restarted (eager spawn)."
|
||||
return "Restarted; next CLI command will auto-spawn."
|
||||
|
||||
|
||||
def _format_kill(d: dict) -> str:
|
||||
if d.get("killed"):
|
||||
return f"Killed PID {d['pid']}."
|
||||
pid = d.get("pid")
|
||||
if pid is None:
|
||||
return "Daemon not running."
|
||||
return f"Already gone (PID {pid})."
|
||||
|
||||
|
||||
def _read_pid() -> int | None:
|
||||
pid_file = pid_file_path()
|
||||
if not pid_file.exists():
|
||||
return None
|
||||
try:
|
||||
return int(pid_file.read_text().strip())
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
|
||||
def _process_alive(pid: int) -> bool:
|
||||
try:
|
||||
os.kill(pid, 0)
|
||||
except ProcessLookupError:
|
||||
return False
|
||||
except PermissionError:
|
||||
return True
|
||||
return True
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def status_cmd() -> None:
|
||||
"""Show daemon health, PID, uptime, workspace count."""
|
||||
pid = _read_pid()
|
||||
with make_client() as client:
|
||||
url = client.settings.url
|
||||
try:
|
||||
r = client.request("GET", "/v1/health")
|
||||
health = r.json()
|
||||
except httpx.RequestError:
|
||||
health = None
|
||||
if health is None:
|
||||
emit({"running": False, "pid": pid, "url": url}, human=_format_status)
|
||||
raise typer.Exit(code=1)
|
||||
emit({
|
||||
"running": True,
|
||||
"pid": pid,
|
||||
"url": url,
|
||||
**health
|
||||
},
|
||||
human=_format_status)
|
||||
|
||||
|
||||
@app.command("stop")
|
||||
def stop_cmd(
|
||||
timeout: float = typer.Option(
|
||||
5.0,
|
||||
"--timeout",
|
||||
help="Seconds to wait for graceful exit before SIGTERM."),
|
||||
) -> None:
|
||||
"""Gracefully stop the daemon.
|
||||
|
||||
Calls ``POST /v1/shutdown`` which trips the daemon's exit event.
|
||||
The daemon closes active workspaces and exits. If the daemon
|
||||
doesn't exit within ``--timeout``, fall back to SIGTERM via the
|
||||
PID file.
|
||||
"""
|
||||
with make_client() as client:
|
||||
try:
|
||||
r = client.request("POST", "/v1/shutdown")
|
||||
except httpx.RequestError as e:
|
||||
fail(f"daemon not reachable: {e}", exit_code=1)
|
||||
if r.status_code >= 400:
|
||||
fail(f"shutdown failed: {r.text}", exit_code=2)
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with make_client() as client:
|
||||
if not client.is_reachable(timeout=0.3):
|
||||
emit({"stopped": True, "via": "graceful"}, human=_format_stop)
|
||||
return
|
||||
time.sleep(0.2)
|
||||
|
||||
pid = _read_pid()
|
||||
if pid and _process_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
emit({
|
||||
"stopped": True,
|
||||
"via": "sigterm",
|
||||
"pid": pid
|
||||
},
|
||||
human=_format_stop)
|
||||
return
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
fail(f"daemon did not exit within {timeout}s and no live PID found",
|
||||
exit_code=2)
|
||||
|
||||
|
||||
@app.command("restart")
|
||||
def restart_cmd(
|
||||
timeout: float = typer.Option(5.0, "--timeout"),
|
||||
eager: bool = typer.Option(
|
||||
False,
|
||||
"--eager",
|
||||
help="Spawn a fresh daemon immediately rather than waiting for "
|
||||
"the next CLI command to auto-spawn."),
|
||||
) -> None:
|
||||
"""Stop the daemon. Next CLI command auto-spawns a fresh one.
|
||||
|
||||
Workspaces are LOST on restart. Save any you want to keep first
|
||||
with ``mirage workspace snapshot <id> <path>`` and bring them
|
||||
back with ``mirage workspace load <path>``.
|
||||
"""
|
||||
with make_client() as client:
|
||||
try:
|
||||
client.request("POST", "/v1/shutdown")
|
||||
except httpx.RequestError:
|
||||
pass
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
with make_client() as client:
|
||||
if not client.is_reachable(timeout=0.3):
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
pid = _read_pid()
|
||||
if pid and _process_alive(pid):
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
if eager:
|
||||
with make_client() as client:
|
||||
client.ensure_running()
|
||||
emit({"restarted": True, "spawned_fresh": True}, human=_format_restart)
|
||||
return
|
||||
emit(
|
||||
{
|
||||
"restarted": True,
|
||||
"spawned_fresh": False,
|
||||
"note": "next workspace --create will auto-spawn",
|
||||
},
|
||||
human=_format_restart,
|
||||
)
|
||||
|
||||
|
||||
@app.command("kill")
|
||||
def kill_cmd() -> None:
|
||||
"""SIGKILL the daemon. Last resort -- skips graceful shutdown."""
|
||||
pid = _read_pid()
|
||||
if pid is None:
|
||||
emit({
|
||||
"killed": False,
|
||||
"reason": "no daemon running",
|
||||
"pid": None
|
||||
},
|
||||
human=_format_kill)
|
||||
return
|
||||
if not _process_alive(pid):
|
||||
emit({
|
||||
"killed": False,
|
||||
"reason": "process already gone",
|
||||
"pid": pid
|
||||
},
|
||||
human=_format_kill)
|
||||
return
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
emit({
|
||||
"killed": False,
|
||||
"reason": "process gone",
|
||||
"pid": pid
|
||||
},
|
||||
human=_format_kill)
|
||||
return
|
||||
emit({"killed": True, "pid": pid}, human=_format_kill)
|
||||
@@ -0,0 +1,38 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
from mirage.server.auth.config import (ENV_AUTH_MODE, ENV_AUTH_TOKEN,
|
||||
ENV_JWT_ALG, ENV_JWT_AUDIENCE,
|
||||
ENV_JWT_AUTHORIZED_PARTIES,
|
||||
ENV_JWT_CLOCK_SKEW, ENV_JWT_ISSUER,
|
||||
ENV_JWT_PUBKEY, ENV_JWT_PUBKEY_FILE)
|
||||
from mirage.server.env import ENV_IDLE_GRACE_SECONDS
|
||||
|
||||
ENV_DAEMON_URL = "MIRAGE_DAEMON_URL"
|
||||
ENV_TOKEN = "MIRAGE_TOKEN"
|
||||
|
||||
__all__ = [
|
||||
"ENV_AUTH_MODE",
|
||||
"ENV_AUTH_TOKEN",
|
||||
"ENV_DAEMON_URL",
|
||||
"ENV_IDLE_GRACE_SECONDS",
|
||||
"ENV_JWT_ALG",
|
||||
"ENV_JWT_AUDIENCE",
|
||||
"ENV_JWT_AUTHORIZED_PARTIES",
|
||||
"ENV_JWT_CLOCK_SKEW",
|
||||
"ENV_JWT_ISSUER",
|
||||
"ENV_JWT_PUBKEY",
|
||||
"ENV_JWT_PUBKEY_FILE",
|
||||
"ENV_TOKEN",
|
||||
]
|
||||
@@ -0,0 +1,75 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import sys
|
||||
|
||||
import typer
|
||||
|
||||
from mirage.cli.client import make_client
|
||||
from mirage.cli.output import emit, exit_code_from_response, handle_response
|
||||
|
||||
app = typer.Typer(invoke_without_command=True, help="Execute a command.")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def execute_cmd(
|
||||
workspace_id: str = typer.Option(...,
|
||||
"--workspace_id",
|
||||
"--workspace",
|
||||
"-w",
|
||||
help="Workspace id."),
|
||||
command: str = typer.Option(...,
|
||||
"--command",
|
||||
"-c",
|
||||
help="Shell command to execute."),
|
||||
session_id: str | None = typer.Option(None,
|
||||
"--session_id",
|
||||
"--session",
|
||||
"-s",
|
||||
help="Session id."),
|
||||
background: bool = typer.Option(
|
||||
False,
|
||||
"--background",
|
||||
"--bg",
|
||||
help="Don't wait; return job_id immediately.",
|
||||
),
|
||||
) -> None:
|
||||
"""Execute a command in a workspace.
|
||||
|
||||
For dry-run / cost-estimate output, use ``mirage provision``
|
||||
instead.
|
||||
"""
|
||||
payload: dict = {"command": command, "provision": False}
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
path = f"/v1/workspaces/{workspace_id}/execute"
|
||||
if background:
|
||||
path += "?background=true"
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
if not sys.stdin.isatty() and not background:
|
||||
stdin_bytes = sys.stdin.buffer.read()
|
||||
files = {
|
||||
"request":
|
||||
("request.json", json.dumps(payload), "application/json"),
|
||||
"stdin":
|
||||
("stdin.bin", stdin_bytes, "application/octet-stream"),
|
||||
}
|
||||
r = client.request("POST", path, files=files)
|
||||
else:
|
||||
r = client.request("POST", path, json=payload)
|
||||
response = handle_response(r)
|
||||
emit(response)
|
||||
raise typer.Exit(code=exit_code_from_response(response))
|
||||
@@ -0,0 +1,77 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import typer
|
||||
|
||||
from mirage.cli.client import make_client
|
||||
from mirage.cli.output import emit, exit_code_from_response, handle_response
|
||||
|
||||
app = typer.Typer(no_args_is_help=True, help="Manage daemon jobs.")
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_cmd(
|
||||
workspace_id: str | None = typer.Option(
|
||||
None,
|
||||
"--workspace_id",
|
||||
"--workspace",
|
||||
"-w",
|
||||
help="Filter to one workspace.",
|
||||
),
|
||||
) -> None:
|
||||
path = "/v1/jobs"
|
||||
if workspace_id:
|
||||
path += f"?workspace_id={workspace_id}"
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("GET", path)
|
||||
emit(handle_response(r))
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def get_cmd(job_id: str = typer.Argument(...)) -> None:
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("GET", f"/v1/jobs/{job_id}")
|
||||
response = handle_response(r)
|
||||
emit(response)
|
||||
raise typer.Exit(code=exit_code_from_response(response))
|
||||
|
||||
|
||||
@app.command("wait")
|
||||
def wait_cmd(
|
||||
job_id: str = typer.Argument(...),
|
||||
timeout: float | None = typer.Option(
|
||||
None,
|
||||
"--timeout",
|
||||
help="Seconds to wait before returning a still-running snapshot.",
|
||||
),
|
||||
) -> None:
|
||||
body: dict = {}
|
||||
if timeout is not None:
|
||||
body["timeout_s"] = timeout
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("POST", f"/v1/jobs/{job_id}/wait", json=body)
|
||||
response = handle_response(r)
|
||||
emit(response)
|
||||
raise typer.Exit(code=exit_code_from_response(response))
|
||||
|
||||
|
||||
@app.command("cancel")
|
||||
def cancel_cmd(job_id: str = typer.Argument(...)) -> None:
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("DELETE", f"/v1/jobs/{job_id}")
|
||||
emit(handle_response(r))
|
||||
@@ -0,0 +1,50 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import typer
|
||||
|
||||
from mirage.cli import config as config_module
|
||||
from mirage.cli import daemon as daemon_module
|
||||
from mirage.cli import execute as execute_module
|
||||
from mirage.cli import job as job_module
|
||||
from mirage.cli import provision as provision_module
|
||||
from mirage.cli import session as session_module
|
||||
from mirage.cli import workspace as workspace_module
|
||||
from mirage.server.daemon_config import DaemonConfigError
|
||||
|
||||
app = typer.Typer(
|
||||
name="mirage",
|
||||
help="Mirage daemon CLI: manage workspaces and execute commands.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
app.add_typer(workspace_module.app, name="workspace")
|
||||
app.add_typer(session_module.app, name="session")
|
||||
app.add_typer(job_module.app, name="job")
|
||||
app.add_typer(execute_module.app, name="execute")
|
||||
app.add_typer(provision_module.app, name="provision")
|
||||
app.add_typer(daemon_module.app, name="daemon")
|
||||
app.add_typer(config_module.app, name="config")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Entry point that turns config errors into clean exit-2 lines."""
|
||||
try:
|
||||
app()
|
||||
except DaemonConfigError as e:
|
||||
typer.echo(str(e), err=True)
|
||||
raise SystemExit(2) from e
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,103 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
import time
|
||||
from typing import Any, Callable
|
||||
|
||||
import httpx
|
||||
import typer
|
||||
|
||||
|
||||
def emit(obj: Any, human: Callable[[Any], str] | None = None) -> None:
|
||||
"""Print ``obj`` to stdout.
|
||||
|
||||
Renders ``human(obj)`` when stdout is a TTY and a formatter is
|
||||
given; otherwise pretty JSON, so ``| jq`` keeps working.
|
||||
|
||||
Args:
|
||||
obj (Any): Value to print.
|
||||
human (Callable | None): Optional table/describe formatter.
|
||||
"""
|
||||
if human is not None and sys.stdout.isatty():
|
||||
typer.echo(human(obj))
|
||||
return
|
||||
typer.echo(json.dumps(obj, indent=2, default=str))
|
||||
|
||||
|
||||
def fail(message: str, exit_code: int = 1) -> None:
|
||||
typer.echo(message, err=True)
|
||||
raise typer.Exit(code=exit_code)
|
||||
|
||||
|
||||
def exit_code_from_response(r: Any) -> int:
|
||||
if not isinstance(r, dict):
|
||||
return 0
|
||||
if r.get("kind") == "io":
|
||||
inner: dict | None = r
|
||||
else:
|
||||
result = r.get("result")
|
||||
inner = result if isinstance(result, dict) else None
|
||||
if inner is not None and inner.get("kind") == "io":
|
||||
code = inner.get("exit_code")
|
||||
if isinstance(code, bool) or not isinstance(code, (int, float)):
|
||||
return 0
|
||||
if not math.isfinite(code):
|
||||
return 0
|
||||
return max(0, min(255, int(code)))
|
||||
status = r.get("status")
|
||||
if status in ("failed", "canceled"):
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def handle_response(r: httpx.Response) -> dict | list:
|
||||
if r.status_code >= 400:
|
||||
try:
|
||||
detail = r.json().get("detail", r.text)
|
||||
except ValueError:
|
||||
detail = r.text
|
||||
fail(f"daemon error {r.status_code}: {detail}", exit_code=2)
|
||||
if not r.content:
|
||||
return {}
|
||||
return r.json()
|
||||
|
||||
|
||||
def format_age(epoch: float, now: float | None = None) -> str:
|
||||
"""Render a Unix timestamp as a short relative age (``2m``, ``3h``)."""
|
||||
delta = max(0, (now if now is not None else time.time()) - epoch)
|
||||
if delta < 60:
|
||||
return f"{int(delta)}s"
|
||||
if delta < 3600:
|
||||
return f"{int(delta // 60)}m"
|
||||
if delta < 86400:
|
||||
return f"{int(delta // 3600)}h"
|
||||
return f"{int(delta // 86400)}d"
|
||||
|
||||
|
||||
def format_table(headers: list[str], rows: list[list[str]]) -> str:
|
||||
"""Format a left-aligned table with two-space column gutters."""
|
||||
if not rows:
|
||||
return " ".join(headers)
|
||||
widths = [len(h) for h in headers]
|
||||
for row in rows:
|
||||
for i, cell in enumerate(row):
|
||||
widths[i] = max(widths[i], len(cell))
|
||||
lines = [" ".join(h.ljust(w) for h, w in zip(headers, widths)).rstrip()]
|
||||
for row in rows:
|
||||
lines.append(" ".join(c.ljust(w)
|
||||
for c, w in zip(row, widths)).rstrip())
|
||||
return "\n".join(lines)
|
||||
@@ -0,0 +1,56 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import typer
|
||||
|
||||
from mirage.cli.client import make_client
|
||||
from mirage.cli.output import emit, handle_response
|
||||
|
||||
app = typer.Typer(invoke_without_command=True,
|
||||
help="Estimate cost / preview a command without running it.")
|
||||
|
||||
|
||||
@app.callback(invoke_without_command=True)
|
||||
def provision_cmd(
|
||||
workspace_id: str = typer.Option(...,
|
||||
"--workspace_id",
|
||||
"--workspace",
|
||||
"-w",
|
||||
help="Workspace id."),
|
||||
command: str = typer.Option(...,
|
||||
"--command",
|
||||
"-c",
|
||||
help="Shell command to estimate."),
|
||||
session_id: str | None = typer.Option(None,
|
||||
"--session_id",
|
||||
"--session",
|
||||
"-s",
|
||||
help="Session id."),
|
||||
) -> None:
|
||||
"""Dry-run a command and return its cost estimate.
|
||||
|
||||
Returns a ``ProvisionResult`` shape (network bytes, cache hits,
|
||||
estimated cost) instead of actually running the command.
|
||||
"""
|
||||
payload: dict = {"command": command, "provision": True}
|
||||
if session_id:
|
||||
payload["session_id"] = session_id
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request(
|
||||
"POST",
|
||||
f"/v1/workspaces/{workspace_id}/execute",
|
||||
json=payload,
|
||||
)
|
||||
emit(handle_response(r))
|
||||
@@ -0,0 +1,67 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import typer
|
||||
|
||||
from mirage.cli.client import make_client
|
||||
from mirage.cli.output import emit, handle_response
|
||||
|
||||
app = typer.Typer(no_args_is_help=True, help="Manage workspace sessions.")
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def create_cmd(
|
||||
workspace_id: str = typer.Argument(...),
|
||||
session_id: str | None = typer.Option(None,
|
||||
"--id",
|
||||
help="Explicit session id."),
|
||||
mount: list[str] = typer.Option(
|
||||
[],
|
||||
"--mount",
|
||||
"-m",
|
||||
help=("Restrict this session to the listed mount prefix. "
|
||||
"Repeat to allow multiple mounts; omit for unrestricted."),
|
||||
),
|
||||
) -> None:
|
||||
body: dict = {}
|
||||
if session_id:
|
||||
body["session_id"] = session_id
|
||||
if mount:
|
||||
body["allowed_mounts"] = mount
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("POST",
|
||||
f"/v1/workspaces/{workspace_id}/sessions",
|
||||
json=body)
|
||||
emit(handle_response(r))
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_cmd(workspace_id: str = typer.Argument(...)) -> None:
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("GET", f"/v1/workspaces/{workspace_id}/sessions")
|
||||
emit(handle_response(r))
|
||||
|
||||
|
||||
@app.command("delete")
|
||||
def delete_cmd(
|
||||
workspace_id: str = typer.Argument(...),
|
||||
session_id: str = typer.Argument(...),
|
||||
) -> None:
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request(
|
||||
"DELETE", f"/v1/workspaces/{workspace_id}/sessions/{session_id}")
|
||||
emit(handle_response(r))
|
||||
@@ -0,0 +1,292 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
import tomllib
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from mirage.cli.env import ENV_DAEMON_URL, ENV_TOKEN
|
||||
from mirage.server.auth import storage as auth_storage
|
||||
from mirage.server.auth.config import (ENV_AUTH_MODE, ENV_JWT_ALG,
|
||||
ENV_JWT_AUDIENCE,
|
||||
ENV_JWT_AUTHORIZED_PARTIES,
|
||||
ENV_JWT_CLOCK_SKEW, ENV_JWT_ISSUER,
|
||||
ENV_JWT_PUBKEY_FILE)
|
||||
from mirage.server.daemon_config import (ALLOWED_KEYS, NUMERIC_KEYS,
|
||||
DaemonConfigError, read_daemon_table)
|
||||
from mirage.server.env import (ENV_ALLOWED_HOSTS, ENV_DAEMON_PORT,
|
||||
ENV_IDLE_GRACE_SECONDS, ENV_PID_FILE,
|
||||
ENV_SNAPSHOT_ROOT, ENV_VERSION_ROOT)
|
||||
from mirage.server.host_validation_constants import DEFAULT_ALLOWED_HOSTS
|
||||
from mirage.server.paths import mirage_home
|
||||
|
||||
DEFAULT_DAEMON_URL = "http://127.0.0.1:8765"
|
||||
|
||||
_ENV_FOR_KEY = {
|
||||
"url": ENV_DAEMON_URL,
|
||||
"allowed_hosts": ENV_ALLOWED_HOSTS,
|
||||
"auth_mode": ENV_AUTH_MODE,
|
||||
"jwt_alg": ENV_JWT_ALG,
|
||||
"jwt_issuer": ENV_JWT_ISSUER,
|
||||
"jwt_audience": ENV_JWT_AUDIENCE,
|
||||
"jwt_pubkey_file": ENV_JWT_PUBKEY_FILE,
|
||||
"jwt_clock_skew": ENV_JWT_CLOCK_SKEW,
|
||||
"jwt_authorized_parties": ENV_JWT_AUTHORIZED_PARTIES,
|
||||
"auth_token": ENV_TOKEN,
|
||||
"idle_grace_seconds": ENV_IDLE_GRACE_SECONDS,
|
||||
"port": ENV_DAEMON_PORT,
|
||||
"pid_file": ENV_PID_FILE,
|
||||
"version_root": ENV_VERSION_ROOT,
|
||||
"snapshot_root": ENV_SNAPSHOT_ROOT,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class DaemonSettings:
|
||||
url: str = DEFAULT_DAEMON_URL
|
||||
socket: str = ""
|
||||
auth_token: str = ""
|
||||
idle_grace_seconds: float = 30.0
|
||||
|
||||
|
||||
def config_path() -> Path:
|
||||
return mirage_home() / "config.toml"
|
||||
|
||||
|
||||
def load_daemon_settings(path: Path | None = None) -> DaemonSettings:
|
||||
"""Load daemon settings, applying the override chain.
|
||||
|
||||
Order of precedence (highest first):
|
||||
1. ``MIRAGE_DAEMON_URL`` env var
|
||||
2. ``MIRAGE_TOKEN`` env var
|
||||
3. values in ``$MIRAGE_HOME/config.toml`` (default
|
||||
``~/.mirage/config.toml``) ``[daemon]`` table
|
||||
4. defaults
|
||||
|
||||
Args:
|
||||
path (Path | None): config file location. Defaults to
|
||||
``config_path()``.
|
||||
|
||||
Returns:
|
||||
DaemonSettings: resolved settings.
|
||||
"""
|
||||
use_path = path or config_path()
|
||||
if path is not None:
|
||||
if use_path.exists():
|
||||
with open(use_path, "rb") as f:
|
||||
table = tomllib.load(f).get("daemon", {})
|
||||
else:
|
||||
table = {}
|
||||
else:
|
||||
table = read_daemon_table(mirage_home())
|
||||
settings = DaemonSettings(
|
||||
url=str(table.get("url", DEFAULT_DAEMON_URL)),
|
||||
socket=str(table.get("socket", "")),
|
||||
auth_token=str(table.get("auth_token", "")),
|
||||
idle_grace_seconds=float(table.get("idle_grace_seconds", 30.0)),
|
||||
)
|
||||
env_url = os.environ.get(ENV_DAEMON_URL)
|
||||
if env_url:
|
||||
settings.url = env_url
|
||||
env_token = os.environ.get(ENV_TOKEN)
|
||||
if env_token:
|
||||
settings.auth_token = env_token
|
||||
if not settings.auth_token:
|
||||
file_token = auth_storage.read_token_file(
|
||||
auth_storage.default_token_file())
|
||||
if file_token:
|
||||
settings.auth_token = file_token
|
||||
return settings
|
||||
|
||||
|
||||
def _default_for_key(key: str, home: Path) -> str:
|
||||
defaults = {
|
||||
"url": DEFAULT_DAEMON_URL,
|
||||
"allowed_hosts": ",".join(DEFAULT_ALLOWED_HOSTS),
|
||||
"auth_mode": "local",
|
||||
"jwt_alg": "",
|
||||
"jwt_issuer": "",
|
||||
"jwt_audience": "",
|
||||
"jwt_pubkey_file": "",
|
||||
"jwt_clock_skew": "5",
|
||||
"jwt_authorized_parties": "",
|
||||
"socket": "",
|
||||
"auth_token": "",
|
||||
"idle_grace_seconds": "30",
|
||||
"port": "8765",
|
||||
"pid_file": str(home / "daemon.pid"),
|
||||
"version_root": str(home / "repos"),
|
||||
"snapshot_root": str(home / "snapshots"),
|
||||
}
|
||||
return defaults[key]
|
||||
|
||||
|
||||
def resolved_config() -> dict[str, tuple[str, str]]:
|
||||
"""Resolve every config key to its effective value and origin.
|
||||
|
||||
Origin is ``"env <NAME>"``, ``"file"``, or ``"default"``, applying
|
||||
the same precedence the daemon and CLI use (env > file > default;
|
||||
explicit per-call arguments are not represented here).
|
||||
|
||||
Returns:
|
||||
dict[str, tuple[str, str]]: key to (effective value, origin).
|
||||
"""
|
||||
home = mirage_home()
|
||||
table = read_daemon_table(home)
|
||||
out: dict[str, tuple[str, str]] = {}
|
||||
for key in sorted(ALLOWED_KEYS):
|
||||
env_name = _ENV_FOR_KEY.get(key)
|
||||
env_value = os.environ.get(env_name) if env_name else None
|
||||
if env_value:
|
||||
out[key] = (env_value, f"env {env_name}")
|
||||
elif str(table.get(key, "")):
|
||||
out[key] = (str(table[key]), "file")
|
||||
else:
|
||||
out[key] = (_default_for_key(key, home), "default")
|
||||
return out
|
||||
|
||||
|
||||
def _check_key(key: str) -> None:
|
||||
if key not in ALLOWED_KEYS:
|
||||
raise DaemonConfigError(f"unknown config key: {key!r}; allowed: "
|
||||
f"{', '.join(sorted(ALLOWED_KEYS))}")
|
||||
|
||||
|
||||
def _format_value(key: str, value: str) -> str:
|
||||
if key in NUMERIC_KEYS:
|
||||
return value
|
||||
escaped = value.replace("\\", "\\\\").replace('"', '\\"')
|
||||
return f'"{escaped}"'
|
||||
|
||||
|
||||
def _config_lines(path: Path) -> list[str]:
|
||||
if not path.exists():
|
||||
return []
|
||||
return path.read_text().splitlines()
|
||||
|
||||
|
||||
def list_config(path: Path | None = None) -> dict:
|
||||
"""Return the ``[daemon]`` table as written in the config file.
|
||||
|
||||
Args:
|
||||
path (Path | None): config file. Defaults to ``config_path()``.
|
||||
|
||||
Returns:
|
||||
dict: file-level key/value strings (no env or default folding).
|
||||
|
||||
Raises:
|
||||
DaemonConfigError: the file exists but is not valid TOML.
|
||||
"""
|
||||
use_path = path or config_path()
|
||||
if not use_path.exists():
|
||||
return {}
|
||||
try:
|
||||
with open(use_path, "rb") as f:
|
||||
table = tomllib.load(f).get("daemon", {})
|
||||
except tomllib.TOMLDecodeError as e:
|
||||
raise DaemonConfigError(f"malformed {use_path}: {e}") from e
|
||||
return {k: str(v) for k, v in table.items()}
|
||||
|
||||
|
||||
def get_config(key: str, path: Path | None = None) -> str | None:
|
||||
"""Return one ``[daemon]`` key's file value, or ``None`` if unset.
|
||||
|
||||
Args:
|
||||
key (str): a key in :data:`ALLOWED_KEYS`.
|
||||
path (Path | None): config file. Defaults to ``config_path()``.
|
||||
|
||||
Returns:
|
||||
str | None: the value, or ``None`` if absent.
|
||||
"""
|
||||
_check_key(key)
|
||||
return list_config(path).get(key)
|
||||
|
||||
|
||||
def set_config(key: str, value: str, path: Path | None = None) -> None:
|
||||
"""Write ``key = value`` into the ``[daemon]`` table, in place.
|
||||
|
||||
Creates the file and ``[daemon]`` header if missing, updates the key
|
||||
line if present, otherwise appends it inside ``[daemon]``. Comments
|
||||
and unrelated lines are preserved.
|
||||
|
||||
Args:
|
||||
key (str): a key in :data:`ALLOWED_KEYS`.
|
||||
value (str): the value to store.
|
||||
path (Path | None): config file. Defaults to ``config_path()``.
|
||||
"""
|
||||
_check_key(key)
|
||||
use_path = path or config_path()
|
||||
lines = _config_lines(use_path)
|
||||
rendered = f"{key} = {_format_value(key, value)}"
|
||||
header_idx = None
|
||||
for i, line in enumerate(lines):
|
||||
if line.strip() == "[daemon]":
|
||||
header_idx = i
|
||||
break
|
||||
if header_idx is None:
|
||||
if lines and lines[-1].strip() != "":
|
||||
lines.append("")
|
||||
lines.append("[daemon]")
|
||||
lines.append(rendered)
|
||||
else:
|
||||
end = len(lines)
|
||||
for i in range(header_idx + 1, len(lines)):
|
||||
if lines[i].strip().startswith("["):
|
||||
end = i
|
||||
break
|
||||
for i in range(header_idx + 1, end):
|
||||
stripped = lines[i].strip()
|
||||
if stripped.startswith("#") or "=" not in stripped:
|
||||
continue
|
||||
if stripped.split("=", 1)[0].strip() == key:
|
||||
lines[i] = rendered
|
||||
break
|
||||
else:
|
||||
lines.insert(end, rendered)
|
||||
use_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
use_path.write_text("\n".join(lines) + "\n")
|
||||
os.chmod(use_path, 0o600)
|
||||
|
||||
|
||||
def unset_config(key: str, path: Path | None = None) -> None:
|
||||
"""Remove ``key`` from the ``[daemon]`` table if present.
|
||||
|
||||
Unknown keys are allowed so a file the daemon refuses to load can
|
||||
be repaired from the CLI.
|
||||
|
||||
Args:
|
||||
key (str): any key present in the file.
|
||||
path (Path | None): config file. Defaults to ``config_path()``.
|
||||
"""
|
||||
use_path = path or config_path()
|
||||
if not use_path.exists():
|
||||
return
|
||||
lines = _config_lines(use_path)
|
||||
kept = []
|
||||
in_daemon = False
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if stripped == "[daemon]":
|
||||
in_daemon = True
|
||||
kept.append(line)
|
||||
continue
|
||||
if stripped.startswith("["):
|
||||
in_daemon = False
|
||||
if (in_daemon and "=" in stripped and not stripped.startswith("#")
|
||||
and stripped.split("=", 1)[0].strip() == key):
|
||||
continue
|
||||
kept.append(line)
|
||||
use_path.write_text("\n".join(kept) + "\n")
|
||||
os.chmod(use_path, 0o600)
|
||||
@@ -0,0 +1,346 @@
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
# ========= Copyright 2026 @ Strukto.AI All Rights Reserved. =========
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
|
||||
from mirage.cli.client import make_client
|
||||
from mirage.cli.output import (emit, fail, format_age, format_table,
|
||||
handle_response)
|
||||
from mirage.config import _interpolate_env, load_config
|
||||
|
||||
app = typer.Typer(no_args_is_help=True, help="Manage workspaces.")
|
||||
|
||||
|
||||
def _load_yaml(path: Path) -> dict:
|
||||
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
||||
|
||||
|
||||
def _resolve_config(path: Path) -> dict:
|
||||
"""Load + validate + interpolate env vars from the CLI's environment.
|
||||
|
||||
Env interpolation runs client-side so the user's shell env (where
|
||||
they sourced ``.env.development`` etc.) is the source of truth.
|
||||
Missing vars fail fast here rather than producing a confusing
|
||||
error after a network round-trip.
|
||||
"""
|
||||
try:
|
||||
cfg = load_config(path)
|
||||
except ValueError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
return cfg.model_dump()
|
||||
|
||||
|
||||
def _resolve_config_arg(path: Path) -> dict:
|
||||
"""Read a workspace YAML/JSON config and interpolate ``${VAR}`` from
|
||||
the CLI's env. Skips validation because load/clone may only need a
|
||||
subset of mounts.
|
||||
"""
|
||||
raw = _load_yaml(path)
|
||||
try:
|
||||
return _interpolate_env(raw, dict(os.environ))
|
||||
except ValueError as e:
|
||||
fail(str(e), exit_code=2)
|
||||
|
||||
|
||||
def _format_workspace_list(items: list[dict[str, Any]]) -> str:
|
||||
if not items:
|
||||
return "No active workspaces."
|
||||
rows = [[
|
||||
item["id"],
|
||||
item["mode"],
|
||||
str(item["mount_count"]),
|
||||
str(item["session_count"]),
|
||||
format_age(item["created_at"]),
|
||||
] for item in items]
|
||||
return format_table(["ID", "MODE", "MOUNTS", "SESSIONS", "AGE"], rows)
|
||||
|
||||
|
||||
def _format_workspace_detail(detail: dict[str, Any]) -> str:
|
||||
lines = [
|
||||
f"ID: {detail['id']}",
|
||||
f"Mode: {detail['mode']}",
|
||||
f"Created: {format_age(detail['created_at'])} ago",
|
||||
]
|
||||
mounts = detail.get("mounts") or []
|
||||
if mounts:
|
||||
rows = [[m["prefix"], m["resource"], m["mode"]] for m in mounts]
|
||||
lines.append("")
|
||||
lines.append("Mounts:")
|
||||
table = format_table(["PREFIX", "RESOURCE", "MODE"], rows)
|
||||
lines.extend(" " + ln for ln in table.splitlines())
|
||||
sessions = detail.get("sessions") or []
|
||||
if sessions:
|
||||
rows = [[s["session_id"], s["cwd"]] for s in sessions]
|
||||
lines.append("")
|
||||
lines.append("Sessions:")
|
||||
table = format_table(["SESSION", "CWD"], rows)
|
||||
lines.extend(" " + ln for ln in table.splitlines())
|
||||
internals = detail.get("internals")
|
||||
if internals:
|
||||
lines.append("")
|
||||
lines.append("Internals:")
|
||||
for key in ("cache_bytes", "cache_entries", "history_length",
|
||||
"in_flight_jobs"):
|
||||
value = internals[key]
|
||||
shown = "n/a (not tracked)" if value is None else value
|
||||
lines.append(f" {key:<16} {shown}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _format_version_log(versions: list[dict[str, Any]]) -> str:
|
||||
if not versions:
|
||||
return "No versions."
|
||||
rows = [[v["id"][:12], v["message"]] for v in versions]
|
||||
return format_table(["VERSION", "MESSAGE"], rows)
|
||||
|
||||
|
||||
def _format_diff(changes: dict[str, list[str]]) -> str:
|
||||
lines: list[str] = []
|
||||
for kind in ("added", "modified", "deleted"):
|
||||
for path in changes.get(kind, []):
|
||||
lines.append(f"{kind:<9} {path}")
|
||||
return "\n".join(lines) if lines else "No changes."
|
||||
|
||||
|
||||
@app.command("create")
|
||||
def create_cmd(
|
||||
config_path: Path = typer.Argument(...,
|
||||
exists=True,
|
||||
readable=True,
|
||||
help="YAML/JSON workspace config."),
|
||||
workspace_id: str
|
||||
| None = typer.Option(None, "--id", help="Explicit workspace id."),
|
||||
) -> None:
|
||||
"""Create a workspace; daemon auto-spawns if not running."""
|
||||
body: dict = {"config": _resolve_config(config_path)}
|
||||
if workspace_id:
|
||||
body["id"] = workspace_id
|
||||
with make_client() as client:
|
||||
client.ensure_running()
|
||||
r = client.request("POST", "/v1/workspaces", json=body)
|
||||
emit(handle_response(r), human=_format_workspace_detail)
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_cmd() -> None:
|
||||
"""List active workspaces."""
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("GET", "/v1/workspaces")
|
||||
emit(handle_response(r), human=_format_workspace_list)
|
||||
|
||||
|
||||
@app.command("get")
|
||||
def get_cmd(
|
||||
workspace_id: str = typer.Argument(..., help="Workspace id."),
|
||||
verbose: bool = typer.Option(
|
||||
False,
|
||||
"--verbose",
|
||||
help="Include cache / dirty / history internals.",
|
||||
),
|
||||
) -> None:
|
||||
"""Show full details for one workspace."""
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
path = f"/v1/workspaces/{workspace_id}"
|
||||
if verbose:
|
||||
path += "?verbose=true"
|
||||
r = client.request("GET", path)
|
||||
emit(handle_response(r), human=_format_workspace_detail)
|
||||
|
||||
|
||||
@app.command("delete")
|
||||
def delete_cmd(workspace_id: str = typer.Argument(...)) -> None:
|
||||
"""Stop and remove a workspace."""
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("DELETE", f"/v1/workspaces/{workspace_id}")
|
||||
emit(handle_response(r), human=lambda d: f"Deleted workspace {d['id']}.")
|
||||
|
||||
|
||||
@app.command("clone")
|
||||
def clone_cmd(
|
||||
source_id: str = typer.Argument(..., help="Source workspace id."),
|
||||
new_id: str
|
||||
| None = typer.Option(None, "--id", help="Explicit id for the clone."),
|
||||
at: str | None = typer.Option(
|
||||
None,
|
||||
"--at",
|
||||
help="Clone from a past version (id or branch) not the live state."),
|
||||
) -> None:
|
||||
"""Clone a workspace, optionally from one of its past versions."""
|
||||
body: dict = {"source_id": source_id}
|
||||
if new_id:
|
||||
body["id"] = new_id
|
||||
if at:
|
||||
body["at"] = at
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("POST", "/v1/workspaces/clone", json=body)
|
||||
emit(handle_response(r), human=_format_workspace_detail)
|
||||
|
||||
|
||||
@app.command("snapshot")
|
||||
def snapshot_cmd(
|
||||
workspace_id: str = typer.Argument(...),
|
||||
output: Path = typer.Argument(..., help="Path to write the .tar to."),
|
||||
) -> None:
|
||||
"""Snapshot a workspace to a tar file.
|
||||
|
||||
The path is resolved to an absolute path and sent to the daemon,
|
||||
which writes the tar itself. With the default local daemon that is
|
||||
your filesystem; against a remote daemon the tar lands on the
|
||||
daemon host.
|
||||
"""
|
||||
body = {"path": str(output.expanduser().resolve())}
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("POST",
|
||||
f"/v1/workspaces/{workspace_id}/snapshot",
|
||||
json=body)
|
||||
emit(
|
||||
handle_response(r),
|
||||
human=lambda d:
|
||||
f"Snapshot {d['id']} -> {d['path']} ({d['size']:,} bytes).",
|
||||
)
|
||||
|
||||
|
||||
@app.command("load")
|
||||
def load_cmd(
|
||||
tar_path: Path = typer.Argument(..., exists=True, readable=True),
|
||||
config_path: Path | None = typer.Argument(
|
||||
None,
|
||||
exists=True,
|
||||
readable=True,
|
||||
help="Optional workspace YAML/JSON config.",
|
||||
),
|
||||
new_id: str | None = typer.Option(
|
||||
None, "--id", help="Explicit id for the restored workspace."),
|
||||
) -> None:
|
||||
"""Load a workspace from a tar file.
|
||||
|
||||
The path is resolved to an absolute path and sent to the daemon,
|
||||
which reads the tar itself.
|
||||
"""
|
||||
body: dict = {"path": str(tar_path.expanduser().resolve())}
|
||||
if new_id:
|
||||
body["id"] = new_id
|
||||
if config_path:
|
||||
body["override"] = _resolve_config_arg(config_path)
|
||||
with make_client() as client:
|
||||
client.ensure_running()
|
||||
r = client.request("POST", "/v1/workspaces/load", json=body)
|
||||
emit(handle_response(r), human=_format_workspace_detail)
|
||||
|
||||
|
||||
@app.command("commit")
|
||||
def commit_cmd(
|
||||
workspace_id: str = typer.Argument(..., help="Workspace id."),
|
||||
message: str = typer.Option("", "-m", "--message",
|
||||
help="Version message."),
|
||||
branch: str = typer.Option("main",
|
||||
"-b",
|
||||
"--branch",
|
||||
help="Branch to commit on."),
|
||||
) -> None:
|
||||
"""Commit the workspace's current state as a version."""
|
||||
body = {"message": message, "branch": branch}
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("POST",
|
||||
f"/v1/workspaces/{workspace_id}/commit",
|
||||
json=body)
|
||||
emit(handle_response(r),
|
||||
human=lambda d: f"Committed {d['version'][:12]} on {d['branch']}.")
|
||||
|
||||
|
||||
@app.command("branch")
|
||||
def branch_cmd(
|
||||
workspace_id: str = typer.Argument(..., help="Workspace id."),
|
||||
name: str = typer.Argument(..., help="New branch name."),
|
||||
from_branch: str = typer.Option("main",
|
||||
"--from",
|
||||
help="Branch to fork from."),
|
||||
) -> None:
|
||||
"""Create a branch at another branch's current version."""
|
||||
body = {"name": name, "from_branch": from_branch}
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("POST",
|
||||
f"/v1/workspaces/{workspace_id}/branch",
|
||||
json=body)
|
||||
emit(handle_response(r),
|
||||
human=lambda d:
|
||||
f"Created branch {d['branch']} at {d['version'][:12]}.")
|
||||
|
||||
|
||||
@app.command("log")
|
||||
def log_cmd(
|
||||
workspace_id: str = typer.Argument(..., help="Workspace id."),
|
||||
branch: str = typer.Option("main", "-b", "--branch"),
|
||||
) -> None:
|
||||
"""List a workspace's versions (newest first)."""
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request(
|
||||
"GET", f"/v1/workspaces/{workspace_id}/versions?branch={branch}")
|
||||
emit(handle_response(r), human=_format_version_log)
|
||||
|
||||
|
||||
@app.command("diff")
|
||||
def diff_cmd(
|
||||
workspace_id: str = typer.Argument(..., help="Workspace id."),
|
||||
a: str | None = typer.Argument(
|
||||
None, help="Base ref; omit to use live state."),
|
||||
b: str | None = typer.Argument(
|
||||
None, help="Compare ref; omit to use live state."),
|
||||
branch: str = typer.Option("main", "-b", "--branch"),
|
||||
) -> None:
|
||||
"""Show changed files (git-style).
|
||||
|
||||
diff <id> live vs HEAD
|
||||
diff <id> <a> live vs <a>
|
||||
diff <id> <a> <b> <a> vs <b>
|
||||
"""
|
||||
params: dict[str, str] = {"branch": branch}
|
||||
if a is not None:
|
||||
params["a"] = a
|
||||
if b is not None:
|
||||
params["b"] = b
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("GET",
|
||||
f"/v1/workspaces/{workspace_id}/diff",
|
||||
params=params)
|
||||
emit(handle_response(r), human=_format_diff)
|
||||
|
||||
|
||||
@app.command("checkout")
|
||||
def checkout_cmd(
|
||||
workspace_id: str = typer.Argument(..., help="Workspace id."),
|
||||
ref: str = typer.Argument(..., help="Version id or branch to restore."),
|
||||
) -> None:
|
||||
"""Restore a workspace in place to one of its versions."""
|
||||
body = {"ref": ref}
|
||||
with make_client() as client:
|
||||
client.ensure_running(allow_spawn=False)
|
||||
r = client.request("POST",
|
||||
f"/v1/workspaces/{workspace_id}/checkout",
|
||||
json=body)
|
||||
emit(handle_response(r), human=_format_workspace_detail)
|
||||
Reference in New Issue
Block a user