looky-cli 0.1.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.
- looky_cli-0.1.0/.gitignore +33 -0
- looky_cli-0.1.0/.gitkeep +1 -0
- looky_cli-0.1.0/PKG-INFO +9 -0
- looky_cli-0.1.0/looky_cli/__init__.py +0 -0
- looky_cli-0.1.0/looky_cli/client.py +85 -0
- looky_cli-0.1.0/looky_cli/commands/__init__.py +0 -0
- looky_cli-0.1.0/looky_cli/commands/auth.py +154 -0
- looky_cli-0.1.0/looky_cli/commands/billing.py +165 -0
- looky_cli-0.1.0/looky_cli/commands/create.py +159 -0
- looky_cli-0.1.0/looky_cli/commands/diff.py +229 -0
- looky_cli-0.1.0/looky_cli/commands/list_cmd.py +124 -0
- looky_cli-0.1.0/looky_cli/commands/pull.py +160 -0
- looky_cli-0.1.0/looky_cli/commands/push.py +238 -0
- looky_cli-0.1.0/looky_cli/commands/rollback.py +132 -0
- looky_cli-0.1.0/looky_cli/commands/sources.py +169 -0
- looky_cli-0.1.0/looky_cli/commands/status.py +147 -0
- looky_cli-0.1.0/looky_cli/commands/workspaces.py +53 -0
- looky_cli-0.1.0/looky_cli/config.py +233 -0
- looky_cli-0.1.0/looky_cli/main.py +30 -0
- looky_cli-0.1.0/looky_cli/validator.py +490 -0
- looky_cli-0.1.0/looky_cli/workspace.py +178 -0
- looky_cli-0.1.0/pyproject.toml +21 -0
- looky_cli-0.1.0/tests/test_workspace.py +194 -0
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Runtime-only source config and secrets
|
|
2
|
+
workspaces/**/runtime/sources.runtime.yml
|
|
3
|
+
workspaces/**/secrets/
|
|
4
|
+
ops/secrets/
|
|
5
|
+
# CLI local workspaces — runtime and secrets are push-only, never commit
|
|
6
|
+
cli/workspaces/*/runtime/sources.runtime.yml
|
|
7
|
+
cli/workspaces/*/secrets/
|
|
8
|
+
|
|
9
|
+
# Local environment files
|
|
10
|
+
.env
|
|
11
|
+
.env.*
|
|
12
|
+
!.env.example
|
|
13
|
+
|
|
14
|
+
# Generated artifacts
|
|
15
|
+
.DS_Store
|
|
16
|
+
__pycache__/
|
|
17
|
+
cli/.venv/
|
|
18
|
+
workspaces/**/.bk/
|
|
19
|
+
*.pyc
|
|
20
|
+
*.swp
|
|
21
|
+
app/__pycache__/
|
|
22
|
+
query-engine/node_modules/
|
|
23
|
+
app/node_modules/
|
|
24
|
+
docs-site/site/
|
|
25
|
+
|
|
26
|
+
# Optional local cache files
|
|
27
|
+
cache/
|
|
28
|
+
*.duckdb
|
|
29
|
+
*.duckdb.wal
|
|
30
|
+
workspaces/**/runtime/cache/
|
|
31
|
+
workspaces/**/runtime/exports/
|
|
32
|
+
workspaces/**/tmp/
|
|
33
|
+
workspaces/**/content/models/.bak/
|
looky_cli-0.1.0/.gitkeep
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
looky_cli-0.1.0/PKG-INFO
ADDED
|
File without changes
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
HTTP client for the looky API.
|
|
3
|
+
"""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class LookyClientError(Exception):
|
|
12
|
+
def __init__(self, message: str, *, status_code: int = 0):
|
|
13
|
+
super().__init__(message)
|
|
14
|
+
self.status_code = status_code
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LookyClient:
|
|
18
|
+
def __init__(self, *, url: str, token: str | None = None, timeout: float = 30.0):
|
|
19
|
+
self.base_url = url.rstrip("/")
|
|
20
|
+
self.token = token
|
|
21
|
+
self.timeout = timeout
|
|
22
|
+
|
|
23
|
+
def _headers(self) -> dict[str, str]:
|
|
24
|
+
headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
25
|
+
if self.token:
|
|
26
|
+
headers["Authorization"] = f"Bearer {self.token}"
|
|
27
|
+
return headers
|
|
28
|
+
|
|
29
|
+
def _request(self, method: str, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
30
|
+
url = f"{self.base_url}{path}"
|
|
31
|
+
try:
|
|
32
|
+
resp = httpx.request(
|
|
33
|
+
method,
|
|
34
|
+
url,
|
|
35
|
+
headers=self._headers(),
|
|
36
|
+
timeout=self.timeout,
|
|
37
|
+
**kwargs,
|
|
38
|
+
)
|
|
39
|
+
except httpx.ConnectError as exc:
|
|
40
|
+
raise LookyClientError(f"Cannot connect to {self.base_url}: {exc}") from exc
|
|
41
|
+
except httpx.TimeoutException as exc:
|
|
42
|
+
raise LookyClientError(f"Request timed out: {exc}") from exc
|
|
43
|
+
except httpx.RequestError as exc:
|
|
44
|
+
raise LookyClientError(f"Request error: {exc}") from exc
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
data = resp.json()
|
|
48
|
+
except Exception:
|
|
49
|
+
data = {}
|
|
50
|
+
|
|
51
|
+
if not resp.is_success:
|
|
52
|
+
msg = (data.get("error") if isinstance(data, dict) else None) or resp.reason_phrase or "request failed"
|
|
53
|
+
raise LookyClientError(str(msg), status_code=resp.status_code)
|
|
54
|
+
|
|
55
|
+
return data if isinstance(data, dict) else {}
|
|
56
|
+
|
|
57
|
+
def get(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
58
|
+
return self._request("GET", path, **kwargs)
|
|
59
|
+
|
|
60
|
+
def post(self, path: str, json: dict[str, Any] | None = None, **kwargs: Any) -> dict[str, Any]:
|
|
61
|
+
return self._request("POST", path, json=json, **kwargs)
|
|
62
|
+
|
|
63
|
+
def delete(self, path: str, **kwargs: Any) -> dict[str, Any]:
|
|
64
|
+
return self._request("DELETE", path, **kwargs)
|
|
65
|
+
|
|
66
|
+
def otp_start(self, email: str) -> dict[str, Any]:
|
|
67
|
+
return self.post("/api/auth/otp/email/start", json={"email": email, "expiration_minutes": 10})
|
|
68
|
+
|
|
69
|
+
def otp_authenticate(self, *, method_id: str, email: str, code: str) -> dict[str, Any]:
|
|
70
|
+
return self.post(
|
|
71
|
+
"/api/auth/otp/email/authenticate",
|
|
72
|
+
json={
|
|
73
|
+
"method_id": method_id,
|
|
74
|
+
"email": email,
|
|
75
|
+
"code": code,
|
|
76
|
+
"session_duration_minutes": 60,
|
|
77
|
+
},
|
|
78
|
+
params={"mode": "cli"},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
def whoami(self) -> dict[str, Any]:
|
|
82
|
+
return self.get("/api/cli/whoami")
|
|
83
|
+
|
|
84
|
+
def revoke_token(self) -> dict[str, Any]:
|
|
85
|
+
return self.delete("/api/cli/token")
|
|
File without changes
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from looky_cli import config as cfg
|
|
10
|
+
from looky_cli.client import LookyClient, LookyClientError
|
|
11
|
+
from looky_cli.commands.billing import relogin_command, require_root_context
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
app = typer.Typer(help="Authenticate with a looky instance.")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@app.command("login")
|
|
18
|
+
def login(
|
|
19
|
+
url: str = typer.Argument(..., help="Instance URL (e.g. https://looky.example.com)"),
|
|
20
|
+
root_path: Path = typer.Argument(..., help="Local root path linked to this instance"),
|
|
21
|
+
email: str = typer.Option(None, "--email", "-e", help="Email address registered in this looky instance"),
|
|
22
|
+
) -> None:
|
|
23
|
+
"""Log in to a looky instance via email OTP."""
|
|
24
|
+
url = url.rstrip("/")
|
|
25
|
+
linked_root = root_path.expanduser().resolve()
|
|
26
|
+
if not linked_root.is_dir():
|
|
27
|
+
console.print(f"[red]Root path not found: [bold]{linked_root}[/bold][/red]")
|
|
28
|
+
raise typer.Exit(1)
|
|
29
|
+
|
|
30
|
+
if not email:
|
|
31
|
+
email = typer.prompt("Email")
|
|
32
|
+
email = email.strip().lower()
|
|
33
|
+
|
|
34
|
+
client = LookyClient(url=url)
|
|
35
|
+
|
|
36
|
+
console.print(f"Sending verification code to [bold]{email}[/bold]…")
|
|
37
|
+
try:
|
|
38
|
+
start_result = client.otp_start(email)
|
|
39
|
+
except LookyClientError as exc:
|
|
40
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
41
|
+
raise typer.Exit(1) from exc
|
|
42
|
+
|
|
43
|
+
method_id: str = str(start_result.get("method_id") or "")
|
|
44
|
+
project_type: str = str(start_result.get("project_type") or "consumer")
|
|
45
|
+
|
|
46
|
+
console.print("Check your email for the verification code.")
|
|
47
|
+
code = typer.prompt("Verification code")
|
|
48
|
+
code = code.strip()
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
auth_result = client.otp_authenticate(
|
|
52
|
+
method_id=method_id,
|
|
53
|
+
email=email if project_type == "b2b" else "",
|
|
54
|
+
code=code,
|
|
55
|
+
)
|
|
56
|
+
except LookyClientError as exc:
|
|
57
|
+
console.print(f"[red]Authentication failed: {exc}[/red]")
|
|
58
|
+
raise typer.Exit(1) from exc
|
|
59
|
+
|
|
60
|
+
cli_token = str(auth_result.get("cli_token") or "")
|
|
61
|
+
cli_token_expires_at = str(auth_result.get("cli_token_expires_at") or "")
|
|
62
|
+
app_user = auth_result.get("app_user") or {}
|
|
63
|
+
|
|
64
|
+
if not cli_token:
|
|
65
|
+
console.print("[red]Server did not return a CLI token. Make sure the server supports CLI auth.[/red]")
|
|
66
|
+
raise typer.Exit(1)
|
|
67
|
+
|
|
68
|
+
cfg.save_session(
|
|
69
|
+
url,
|
|
70
|
+
token=cli_token,
|
|
71
|
+
expires_at=cli_token_expires_at,
|
|
72
|
+
user=app_user,
|
|
73
|
+
)
|
|
74
|
+
try:
|
|
75
|
+
cfg.bind_workspace_root(linked_root, url=url)
|
|
76
|
+
except ValueError as exc:
|
|
77
|
+
console.print(f"[red]{exc}[/red]")
|
|
78
|
+
raise typer.Exit(1) from exc
|
|
79
|
+
|
|
80
|
+
console.print(f"[green]Logged in as [bold]{app_user.get('email', email)}[/bold][/green]")
|
|
81
|
+
console.print(f"[dim]Linked root: [bold]{linked_root}[/bold] -> {url}[/dim]")
|
|
82
|
+
_print_user_table(app_user)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@app.command("logout")
|
|
86
|
+
def logout() -> None:
|
|
87
|
+
"""Revoke the CLI token and clear local credentials."""
|
|
88
|
+
root_context = require_root_context(Path.cwd(), exact_root=False)
|
|
89
|
+
url = root_context.url
|
|
90
|
+
token = root_context.token
|
|
91
|
+
|
|
92
|
+
if token and url:
|
|
93
|
+
client = LookyClient(url=url, token=token)
|
|
94
|
+
try:
|
|
95
|
+
client.revoke_token()
|
|
96
|
+
except LookyClientError:
|
|
97
|
+
pass
|
|
98
|
+
|
|
99
|
+
cfg.clear_session(url)
|
|
100
|
+
cfg.clear_root_billing_account_id(root_context.root_path)
|
|
101
|
+
console.print(
|
|
102
|
+
"[green]Logged out from "
|
|
103
|
+
f"[bold]{url}[/bold] for linked root [bold]{root_context.root_path}[/bold].[/green]"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@app.command("whoami")
|
|
108
|
+
def whoami() -> None:
|
|
109
|
+
"""Show the currently authenticated user."""
|
|
110
|
+
root_context = require_root_context(Path.cwd(), exact_root=False)
|
|
111
|
+
url = root_context.url
|
|
112
|
+
token = root_context.token
|
|
113
|
+
|
|
114
|
+
if not token:
|
|
115
|
+
console.print(
|
|
116
|
+
"[red]Not logged in for the linked root "
|
|
117
|
+
f"[bold]{root_context.root_path}[/bold]. Run "
|
|
118
|
+
f"[bold]looky login {url} {root_context.root_path}[/bold] first.[/red]"
|
|
119
|
+
)
|
|
120
|
+
raise typer.Exit(1)
|
|
121
|
+
|
|
122
|
+
client = LookyClient(url=url, token=token)
|
|
123
|
+
try:
|
|
124
|
+
result = client.whoami()
|
|
125
|
+
except LookyClientError as exc:
|
|
126
|
+
if exc.status_code == 401:
|
|
127
|
+
console.print(
|
|
128
|
+
"[red]Token expired or revoked. Run "
|
|
129
|
+
f"[bold]{relogin_command(root_context)}[/bold] again.[/red]"
|
|
130
|
+
)
|
|
131
|
+
else:
|
|
132
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
133
|
+
raise typer.Exit(1) from exc
|
|
134
|
+
|
|
135
|
+
user = result.get("user") or {}
|
|
136
|
+
console.print(
|
|
137
|
+
f"[green]Authenticated on [bold]{url}[/bold] "
|
|
138
|
+
f"for [bold]{root_context.root_path}[/bold][/green]"
|
|
139
|
+
)
|
|
140
|
+
_print_user_table(user)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _print_user_table(user: dict) -> None:
|
|
144
|
+
t = Table(show_header=False, box=None, padding=(0, 1))
|
|
145
|
+
t.add_column(style="dim")
|
|
146
|
+
t.add_column()
|
|
147
|
+
if user.get("first_name") or user.get("last_name"):
|
|
148
|
+
name = f"{user.get('first_name', '')} {user.get('last_name', '')}".strip()
|
|
149
|
+
t.add_row("name", name)
|
|
150
|
+
if user.get("email"):
|
|
151
|
+
t.add_row("email", str(user["email"]))
|
|
152
|
+
if user.get("role"):
|
|
153
|
+
t.add_row("role", str(user["role"]))
|
|
154
|
+
console.print(t)
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Billing account commands for the looky CLI."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
from rich.console import Console
|
|
8
|
+
from rich.table import Table
|
|
9
|
+
|
|
10
|
+
from looky_cli import config as cfg
|
|
11
|
+
from looky_cli.client import LookyClient, LookyClientError
|
|
12
|
+
from looky_cli.workspace import RootContext, WorkspaceResolutionError, resolve_root_context
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
app = typer.Typer(help="Manage billing account context.")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def relogin_command(root_context: RootContext) -> str:
|
|
19
|
+
return f"looky login {root_context.url} {root_context.root_path}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def require_root_context(path: Path, *, exact_root: bool) -> RootContext:
|
|
23
|
+
try:
|
|
24
|
+
root_context = resolve_root_context(path.resolve())
|
|
25
|
+
except WorkspaceResolutionError as exc:
|
|
26
|
+
console.print(f"[red]{exc}[/red]")
|
|
27
|
+
raise typer.Exit(1) from exc
|
|
28
|
+
|
|
29
|
+
if exact_root and root_context.current_path != root_context.root_path:
|
|
30
|
+
console.print(
|
|
31
|
+
f"[red]This command must be run from the linked root [bold]{root_context.root_path}[/bold].[/red]"
|
|
32
|
+
)
|
|
33
|
+
raise typer.Exit(1)
|
|
34
|
+
|
|
35
|
+
return root_context
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_authenticated_client(root_context: RootContext) -> LookyClient:
|
|
39
|
+
if not root_context.token:
|
|
40
|
+
console.print(
|
|
41
|
+
"[red]Not logged in for the linked root "
|
|
42
|
+
f"[bold]{root_context.root_path}[/bold]. Run "
|
|
43
|
+
f"[bold]looky login {root_context.url} {root_context.root_path}[/bold] first.[/red]"
|
|
44
|
+
)
|
|
45
|
+
raise typer.Exit(1)
|
|
46
|
+
return LookyClient(url=root_context.url, token=root_context.token)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def require_authenticated_client(path: Path, *, exact_root: bool) -> tuple[RootContext, LookyClient]:
|
|
50
|
+
root_context = require_root_context(path, exact_root=exact_root)
|
|
51
|
+
return root_context, build_authenticated_client(root_context)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def require_billing_context(
|
|
55
|
+
root_context: RootContext,
|
|
56
|
+
client: LookyClient,
|
|
57
|
+
*,
|
|
58
|
+
expected_billing_account_id: str | None = None,
|
|
59
|
+
) -> str:
|
|
60
|
+
"""
|
|
61
|
+
If billing is enabled, require an active billing account.
|
|
62
|
+
Prints error and raises typer.Exit(1) if not set.
|
|
63
|
+
"""
|
|
64
|
+
try:
|
|
65
|
+
client.get("/api/cli/whoami")
|
|
66
|
+
except LookyClientError:
|
|
67
|
+
pass
|
|
68
|
+
bid = root_context.billing_account_id
|
|
69
|
+
if not bid:
|
|
70
|
+
console.print(
|
|
71
|
+
"[red]No active billing account for linked root "
|
|
72
|
+
f"[bold]{root_context.root_path}[/bold]. Run "
|
|
73
|
+
"[bold]looky billing use <id>[/bold] first.[/red]"
|
|
74
|
+
)
|
|
75
|
+
raise typer.Exit(1)
|
|
76
|
+
if expected_billing_account_id and bid != expected_billing_account_id:
|
|
77
|
+
console.print(
|
|
78
|
+
"[red]Active billing account "
|
|
79
|
+
f"[bold]{bid}[/bold] does not match the current path. Expected "
|
|
80
|
+
f"[bold]{expected_billing_account_id}[/bold]. Run "
|
|
81
|
+
f"[bold]looky billing use {expected_billing_account_id}[/bold] "
|
|
82
|
+
f"from [bold]{root_context.root_path}[/bold] first.[/red]"
|
|
83
|
+
)
|
|
84
|
+
raise typer.Exit(1)
|
|
85
|
+
return bid
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@app.command("list")
|
|
89
|
+
def billing_list() -> None:
|
|
90
|
+
"""List billing accounts you have access to (owner, developer, or read)."""
|
|
91
|
+
root_context, client = require_authenticated_client(Path.cwd(), exact_root=True)
|
|
92
|
+
try:
|
|
93
|
+
result = client.get("/api/cli/billing/list")
|
|
94
|
+
except LookyClientError as exc:
|
|
95
|
+
if exc.status_code == 401:
|
|
96
|
+
console.print(
|
|
97
|
+
"[red]Token expired or revoked. Run "
|
|
98
|
+
f"[bold]{relogin_command(root_context)}[/bold] again.[/red]"
|
|
99
|
+
)
|
|
100
|
+
else:
|
|
101
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
102
|
+
raise typer.Exit(1) from exc
|
|
103
|
+
|
|
104
|
+
accounts = result.get("billing_accounts") or []
|
|
105
|
+
current = root_context.billing_account_id
|
|
106
|
+
|
|
107
|
+
if not accounts:
|
|
108
|
+
console.print("[yellow]No billing accounts available. You need access as owner, developer, or read.[/yellow]")
|
|
109
|
+
return
|
|
110
|
+
|
|
111
|
+
t = Table(show_header=True, header_style="bold")
|
|
112
|
+
t.add_column("ID")
|
|
113
|
+
t.add_column("Name")
|
|
114
|
+
t.add_column("Role")
|
|
115
|
+
t.add_column("", width=4)
|
|
116
|
+
|
|
117
|
+
for a in accounts:
|
|
118
|
+
bid = str(a.get("id") or "")
|
|
119
|
+
name = str(a.get("display_name") or bid)
|
|
120
|
+
role = str(a.get("role") or "")
|
|
121
|
+
marker = " *" if bid == current else ""
|
|
122
|
+
t.add_row(bid, name, role, marker)
|
|
123
|
+
|
|
124
|
+
console.print(t)
|
|
125
|
+
if current:
|
|
126
|
+
console.print(f"\n[dim]* = active billing account ([bold]{current}[/bold])[/dim]")
|
|
127
|
+
else:
|
|
128
|
+
console.print(
|
|
129
|
+
"\n[yellow]No active billing account. Run [bold]looky billing use <id>[/bold] to set one.[/yellow]"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.command("use")
|
|
134
|
+
def billing_use(
|
|
135
|
+
billing_account_id: str = typer.Argument(..., help="Billing account ID to activate"),
|
|
136
|
+
) -> None:
|
|
137
|
+
"""Set the active billing account. Validates you have access (SA, owner, developer, or read)."""
|
|
138
|
+
root_context, client = require_authenticated_client(Path.cwd(), exact_root=True)
|
|
139
|
+
|
|
140
|
+
bid = billing_account_id.strip()
|
|
141
|
+
if not bid:
|
|
142
|
+
console.print("[red]Billing account ID is required.[/red]")
|
|
143
|
+
raise typer.Exit(1)
|
|
144
|
+
|
|
145
|
+
try:
|
|
146
|
+
client.get("/api/cli/billing/validate", params={"billing_account_id": bid})
|
|
147
|
+
except LookyClientError as exc:
|
|
148
|
+
if exc.status_code == 401:
|
|
149
|
+
console.print(
|
|
150
|
+
"[red]Token expired or revoked. Run "
|
|
151
|
+
f"[bold]{relogin_command(root_context)}[/bold] again.[/red]"
|
|
152
|
+
)
|
|
153
|
+
elif exc.status_code == 403:
|
|
154
|
+
console.print(
|
|
155
|
+
f"[red]No access to billing account [bold]{bid}[/bold]. "
|
|
156
|
+
"Run [bold]looky billing list[/bold] to see available accounts.[/red]"
|
|
157
|
+
)
|
|
158
|
+
elif exc.status_code == 400:
|
|
159
|
+
console.print(f"[red]{exc}[/red]")
|
|
160
|
+
else:
|
|
161
|
+
console.print(f"[red]Error: {exc}[/red]")
|
|
162
|
+
raise typer.Exit(1) from exc
|
|
163
|
+
|
|
164
|
+
cfg.set_root_billing_account_id(root_context.root_path, bid)
|
|
165
|
+
console.print(f"[green]Active billing account set to [bold]{bid}[/bold].[/green]")
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import typer
|
|
6
|
+
from rich.console import Console
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from looky_cli.client import LookyClient, LookyClientError
|
|
10
|
+
from looky_cli.commands.billing import build_authenticated_client, relogin_command, require_billing_context
|
|
11
|
+
from looky_cli.workspace import WorkspaceResolutionError, resolve_billing_directory
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def create(
|
|
17
|
+
workspace_slug: str = typer.Argument(..., help="Workspace slug to create under the current billing directory"),
|
|
18
|
+
name: str = typer.Option(None, "--name", "-n", help="Human-readable workspace name"),
|
|
19
|
+
description: str = typer.Option("", "--description", "-d", help="Short description"),
|
|
20
|
+
local_only: bool = typer.Option(
|
|
21
|
+
False,
|
|
22
|
+
"--local-only",
|
|
23
|
+
help="Only create the local skeleton, skip server registration",
|
|
24
|
+
),
|
|
25
|
+
) -> None:
|
|
26
|
+
"""
|
|
27
|
+
Register a new workspace on the server and scaffold it locally.
|
|
28
|
+
|
|
29
|
+
The workspace is registered on the server first (fails if it already exists),
|
|
30
|
+
then the local directory skeleton is created. The calling user is automatically
|
|
31
|
+
granted write access.
|
|
32
|
+
"""
|
|
33
|
+
slug = _normalize_workspace_slug(workspace_slug)
|
|
34
|
+
try:
|
|
35
|
+
billing_dir = resolve_billing_directory(Path.cwd())
|
|
36
|
+
except WorkspaceResolutionError as exc:
|
|
37
|
+
console.print(f"[red]{exc}[/red]")
|
|
38
|
+
raise typer.Exit(1) from exc
|
|
39
|
+
|
|
40
|
+
workspace_id = f"{billing_dir.billing_account_id}/{slug}"
|
|
41
|
+
resolved_name = name or slug
|
|
42
|
+
dest = billing_dir.billing_path / slug
|
|
43
|
+
|
|
44
|
+
client: LookyClient | None = None
|
|
45
|
+
if not local_only:
|
|
46
|
+
client = build_authenticated_client(billing_dir.root_context)
|
|
47
|
+
require_billing_context(
|
|
48
|
+
billing_dir.root_context,
|
|
49
|
+
client,
|
|
50
|
+
expected_billing_account_id=billing_dir.billing_account_id,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
if not local_only:
|
|
54
|
+
with console.status(f"Registering [bold]{workspace_id}[/bold] on {billing_dir.root_context.url}…"):
|
|
55
|
+
try:
|
|
56
|
+
result = client.post( # type: ignore[union-attr]
|
|
57
|
+
"/api/cli/workspaces",
|
|
58
|
+
json={
|
|
59
|
+
"workspace_id": workspace_id,
|
|
60
|
+
"name": resolved_name,
|
|
61
|
+
"description": description,
|
|
62
|
+
},
|
|
63
|
+
)
|
|
64
|
+
except LookyClientError as exc:
|
|
65
|
+
if exc.status_code == 409:
|
|
66
|
+
console.print(
|
|
67
|
+
f"[red]Workspace [bold]{workspace_id}[/bold] already exists on the server. "
|
|
68
|
+
f"Use [bold]looky pull {slug}[/bold] from [bold]{billing_dir.billing_path}[/bold] "
|
|
69
|
+
"to get its content.[/red]"
|
|
70
|
+
)
|
|
71
|
+
elif exc.status_code == 401:
|
|
72
|
+
console.print(
|
|
73
|
+
"[red]Token expired or revoked. Run "
|
|
74
|
+
f"[bold]{relogin_command(billing_dir.root_context)}[/bold] again.[/red]"
|
|
75
|
+
)
|
|
76
|
+
elif exc.status_code == 400:
|
|
77
|
+
console.print(f"[red]Invalid input: {exc}[/red]")
|
|
78
|
+
else:
|
|
79
|
+
console.print(f"[red]Failed to create workspace: {exc}[/red]")
|
|
80
|
+
raise typer.Exit(1) from exc
|
|
81
|
+
|
|
82
|
+
console.print(
|
|
83
|
+
f"[green]✓ Workspace [bold]{workspace_id}[/bold] registered on server "
|
|
84
|
+
f"(access: {result.get('access_mode', 'write')})[/green]"
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# -----------------------------------------------------------------------
|
|
88
|
+
# Local skeleton
|
|
89
|
+
# -----------------------------------------------------------------------
|
|
90
|
+
if dest.exists() and any(dest.iterdir()):
|
|
91
|
+
console.print(
|
|
92
|
+
f"[yellow]Local directory [bold]{dest}[/bold] already exists and is not empty — skipping skeleton.[/yellow]"
|
|
93
|
+
)
|
|
94
|
+
else:
|
|
95
|
+
_scaffold_local(dest, workspace_slug=slug, name=resolved_name, description=description)
|
|
96
|
+
console.print(f"[green]✓ Local skeleton created at [bold]{dest}[/bold][/green]")
|
|
97
|
+
|
|
98
|
+
_print_next_steps(workspace_id, dest)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _normalize_workspace_slug(workspace_slug: str) -> str:
|
|
102
|
+
slug = workspace_slug.strip().strip("/")
|
|
103
|
+
if not slug or "/" in slug or slug in {".", ".."}:
|
|
104
|
+
console.print("[red]Workspace slug must be a single path segment.[/red]")
|
|
105
|
+
raise typer.Exit(1)
|
|
106
|
+
return slug
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _scaffold_local(dest: Path, *, workspace_slug: str, name: str, description: str) -> None:
|
|
110
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
|
|
112
|
+
ws_yml = dest / "workspace.yml"
|
|
113
|
+
ws_yml.write_text(
|
|
114
|
+
f"id: {workspace_slug}\n"
|
|
115
|
+
f"name: {name}\n"
|
|
116
|
+
+ (f"description: {description}\n" if description else "")
|
|
117
|
+
+ "ui:\n"
|
|
118
|
+
" default_locale: en\n"
|
|
119
|
+
" supported_locales: [en, es]\n",
|
|
120
|
+
encoding="utf-8",
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
for subdir in (
|
|
124
|
+
"content/models",
|
|
125
|
+
"content/visualizations",
|
|
126
|
+
"content/dashboards",
|
|
127
|
+
"content/exports",
|
|
128
|
+
"runtime",
|
|
129
|
+
):
|
|
130
|
+
(dest / subdir).mkdir(parents=True, exist_ok=True)
|
|
131
|
+
|
|
132
|
+
runtime_example = dest / "runtime" / "sources.runtime.yml"
|
|
133
|
+
runtime_example.write_text(
|
|
134
|
+
"# Runtime source configuration — DO NOT commit secrets.\n"
|
|
135
|
+
"# See docs for supported types: bigquery, postgres.\n\n"
|
|
136
|
+
"sources:\n"
|
|
137
|
+
" my_source:\n"
|
|
138
|
+
" type: bigquery\n"
|
|
139
|
+
" project_id: \"my-gcp-project\"\n"
|
|
140
|
+
" credentials_file: \"secrets/my-sa.json\"\n",
|
|
141
|
+
encoding="utf-8",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
secrets_gitkeep = dest / "secrets" / ".gitkeep"
|
|
145
|
+
secrets_gitkeep.parent.mkdir(parents=True, exist_ok=True)
|
|
146
|
+
secrets_gitkeep.touch()
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _print_next_steps(workspace_id: str, dest: Path) -> None:
|
|
150
|
+
console.print(f"\n[bold]Next steps:[/bold]")
|
|
151
|
+
t = Table(show_header=False, box=None, padding=(0, 1))
|
|
152
|
+
t.add_column(style="dim", no_wrap=True)
|
|
153
|
+
t.add_column()
|
|
154
|
+
t.add_row("1.", f"Add your source config in [bold]{dest}/runtime/sources.runtime.yml[/bold]")
|
|
155
|
+
t.add_row("2.", f"Add credentials to [bold]{dest}/secrets/[/bold] (never commit these)")
|
|
156
|
+
t.add_row("3.", f"Write your Malloy models in [bold]{dest}/content/models/[/bold]")
|
|
157
|
+
t.add_row("4.", f"Change into [bold]{dest}[/bold] and run [bold]looky validate[/bold]")
|
|
158
|
+
t.add_row("5.", f"From [bold]{dest}[/bold], run [bold]looky push[/bold] when ready")
|
|
159
|
+
console.print(t)
|