cinchdb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cinchdb/__init__.py +7 -0
- cinchdb/__main__.py +6 -0
- cinchdb/api/__init__.py +5 -0
- cinchdb/api/app.py +76 -0
- cinchdb/api/auth.py +290 -0
- cinchdb/api/main.py +137 -0
- cinchdb/api/routers/__init__.py +25 -0
- cinchdb/api/routers/auth.py +135 -0
- cinchdb/api/routers/branches.py +368 -0
- cinchdb/api/routers/codegen.py +164 -0
- cinchdb/api/routers/columns.py +290 -0
- cinchdb/api/routers/data.py +479 -0
- cinchdb/api/routers/databases.py +177 -0
- cinchdb/api/routers/projects.py +133 -0
- cinchdb/api/routers/query.py +156 -0
- cinchdb/api/routers/tables.py +349 -0
- cinchdb/api/routers/tenants.py +216 -0
- cinchdb/api/routers/views.py +219 -0
- cinchdb/cli/__init__.py +0 -0
- cinchdb/cli/commands/__init__.py +1 -0
- cinchdb/cli/commands/branch.py +479 -0
- cinchdb/cli/commands/codegen.py +176 -0
- cinchdb/cli/commands/column.py +308 -0
- cinchdb/cli/commands/database.py +212 -0
- cinchdb/cli/commands/query.py +136 -0
- cinchdb/cli/commands/remote.py +144 -0
- cinchdb/cli/commands/table.py +289 -0
- cinchdb/cli/commands/tenant.py +173 -0
- cinchdb/cli/commands/view.py +189 -0
- cinchdb/cli/handlers/__init__.py +5 -0
- cinchdb/cli/handlers/codegen_handler.py +189 -0
- cinchdb/cli/main.py +137 -0
- cinchdb/cli/utils.py +182 -0
- cinchdb/config.py +177 -0
- cinchdb/core/__init__.py +5 -0
- cinchdb/core/connection.py +175 -0
- cinchdb/core/database.py +537 -0
- cinchdb/core/maintenance.py +73 -0
- cinchdb/core/path_utils.py +153 -0
- cinchdb/managers/__init__.py +26 -0
- cinchdb/managers/branch.py +167 -0
- cinchdb/managers/change_applier.py +414 -0
- cinchdb/managers/change_comparator.py +194 -0
- cinchdb/managers/change_tracker.py +182 -0
- cinchdb/managers/codegen.py +523 -0
- cinchdb/managers/column.py +579 -0
- cinchdb/managers/data.py +455 -0
- cinchdb/managers/merge_manager.py +429 -0
- cinchdb/managers/query.py +214 -0
- cinchdb/managers/table.py +383 -0
- cinchdb/managers/tenant.py +258 -0
- cinchdb/managers/view.py +252 -0
- cinchdb/models/__init__.py +27 -0
- cinchdb/models/base.py +44 -0
- cinchdb/models/branch.py +26 -0
- cinchdb/models/change.py +47 -0
- cinchdb/models/database.py +20 -0
- cinchdb/models/project.py +20 -0
- cinchdb/models/table.py +86 -0
- cinchdb/models/tenant.py +19 -0
- cinchdb/models/view.py +15 -0
- cinchdb/utils/__init__.py +15 -0
- cinchdb/utils/sql_validator.py +137 -0
- cinchdb-0.1.0.dist-info/METADATA +195 -0
- cinchdb-0.1.0.dist-info/RECORD +68 -0
- cinchdb-0.1.0.dist-info/WHEEL +4 -0
- cinchdb-0.1.0.dist-info/entry_points.txt +3 -0
- cinchdb-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,144 @@
|
|
1
|
+
"""Remote configuration management commands."""
|
2
|
+
|
3
|
+
import typer
|
4
|
+
from rich.console import Console
|
5
|
+
from rich.table import Table
|
6
|
+
from typing import Optional
|
7
|
+
|
8
|
+
from cinchdb.cli.utils import get_config_with_data, validate_required_arg
|
9
|
+
from cinchdb.config import RemoteConfig
|
10
|
+
|
11
|
+
app = typer.Typer()
|
12
|
+
console = Console()
|
13
|
+
|
14
|
+
|
15
|
+
@app.command("add")
|
16
|
+
def add_remote(
|
17
|
+
ctx: typer.Context,
|
18
|
+
alias: Optional[str] = typer.Argument(None, help="Alias for the remote"),
|
19
|
+
url: str = typer.Option(None, "--url", "-u", help="Remote API URL"),
|
20
|
+
key: str = typer.Option(None, "--key", "-k", help="API key"),
|
21
|
+
):
|
22
|
+
"""Add a remote CinchDB instance configuration."""
|
23
|
+
alias = validate_required_arg(alias, "alias", ctx)
|
24
|
+
|
25
|
+
if not url:
|
26
|
+
console.print("[red]❌ Error: --url is required[/red]")
|
27
|
+
raise typer.Exit(1)
|
28
|
+
|
29
|
+
if not key:
|
30
|
+
console.print("[red]❌ Error: --key is required[/red]")
|
31
|
+
raise typer.Exit(1)
|
32
|
+
|
33
|
+
config, config_data = get_config_with_data()
|
34
|
+
|
35
|
+
# Check if alias already exists
|
36
|
+
if alias in config_data.remotes:
|
37
|
+
console.print(f"[yellow]⚠️ Remote '{alias}' already exists. Updating...[/yellow]")
|
38
|
+
|
39
|
+
# Add or update the remote
|
40
|
+
config_data.remotes[alias] = RemoteConfig(url=url.rstrip("/"), key=key)
|
41
|
+
config.save(config_data)
|
42
|
+
|
43
|
+
console.print(f"[green]✓ Remote '{alias}' configured successfully[/green]")
|
44
|
+
|
45
|
+
|
46
|
+
@app.command("list")
|
47
|
+
def list_remotes():
|
48
|
+
"""List all configured remote instances."""
|
49
|
+
config, config_data = get_config_with_data()
|
50
|
+
|
51
|
+
if not config_data.remotes:
|
52
|
+
console.print("[yellow]No remotes configured[/yellow]")
|
53
|
+
return
|
54
|
+
|
55
|
+
table = Table(title="Configured Remotes")
|
56
|
+
table.add_column("Alias", style="cyan")
|
57
|
+
table.add_column("URL", style="green")
|
58
|
+
table.add_column("Active", style="yellow")
|
59
|
+
|
60
|
+
for alias, remote in config_data.remotes.items():
|
61
|
+
is_active = "✓" if alias == config_data.active_remote else ""
|
62
|
+
table.add_row(alias, remote.url, is_active)
|
63
|
+
|
64
|
+
console.print(table)
|
65
|
+
|
66
|
+
|
67
|
+
@app.command("remove")
|
68
|
+
def remove_remote(
|
69
|
+
ctx: typer.Context,
|
70
|
+
alias: Optional[str] = typer.Argument(None, help="Alias of the remote to remove"),
|
71
|
+
):
|
72
|
+
"""Remove a remote configuration."""
|
73
|
+
alias = validate_required_arg(alias, "alias", ctx)
|
74
|
+
|
75
|
+
config, config_data = get_config_with_data()
|
76
|
+
|
77
|
+
if alias not in config_data.remotes:
|
78
|
+
console.print(f"[red]❌ Remote '{alias}' not found[/red]")
|
79
|
+
raise typer.Exit(1)
|
80
|
+
|
81
|
+
# Remove the remote
|
82
|
+
del config_data.remotes[alias]
|
83
|
+
|
84
|
+
# If this was the active remote, clear it
|
85
|
+
if config_data.active_remote == alias:
|
86
|
+
config_data.active_remote = None
|
87
|
+
|
88
|
+
config.save(config_data)
|
89
|
+
console.print(f"[green]✓ Remote '{alias}' removed[/green]")
|
90
|
+
|
91
|
+
|
92
|
+
@app.command("use")
|
93
|
+
def use_remote(
|
94
|
+
ctx: typer.Context,
|
95
|
+
alias: Optional[str] = typer.Argument(None, help="Alias of the remote to use"),
|
96
|
+
):
|
97
|
+
"""Set the active remote instance."""
|
98
|
+
alias = validate_required_arg(alias, "alias", ctx)
|
99
|
+
|
100
|
+
config, config_data = get_config_with_data()
|
101
|
+
|
102
|
+
if alias not in config_data.remotes:
|
103
|
+
console.print(f"[red]❌ Remote '{alias}' not found[/red]")
|
104
|
+
raise typer.Exit(1)
|
105
|
+
|
106
|
+
config_data.active_remote = alias
|
107
|
+
config.save(config_data)
|
108
|
+
|
109
|
+
console.print(f"[green]✓ Now using remote '{alias}'[/green]")
|
110
|
+
console.print(f"[dim]URL: {config_data.remotes[alias].url}[/dim]")
|
111
|
+
|
112
|
+
|
113
|
+
@app.command("clear")
|
114
|
+
def clear_remote():
|
115
|
+
"""Clear the active remote (switch back to local mode)."""
|
116
|
+
config, config_data = get_config_with_data()
|
117
|
+
|
118
|
+
if not config_data.active_remote:
|
119
|
+
console.print("[yellow]No active remote set[/yellow]")
|
120
|
+
return
|
121
|
+
|
122
|
+
config_data.active_remote = None
|
123
|
+
config.save(config_data)
|
124
|
+
|
125
|
+
console.print("[green]✓ Cleared active remote. Now using local mode.[/green]")
|
126
|
+
|
127
|
+
|
128
|
+
@app.command("show")
|
129
|
+
def show_remote():
|
130
|
+
"""Show the currently active remote."""
|
131
|
+
config, config_data = get_config_with_data()
|
132
|
+
|
133
|
+
if not config_data.active_remote:
|
134
|
+
console.print("[yellow]No active remote. Using local mode.[/yellow]")
|
135
|
+
return
|
136
|
+
|
137
|
+
alias = config_data.active_remote
|
138
|
+
if alias not in config_data.remotes:
|
139
|
+
console.print(f"[red]❌ Active remote '{alias}' not found in configuration[/red]")
|
140
|
+
return
|
141
|
+
|
142
|
+
remote = config_data.remotes[alias]
|
143
|
+
console.print(f"[green]Active remote:[/green] {alias}")
|
144
|
+
console.print(f"[dim]URL: {remote.url}[/dim]")
|
@@ -0,0 +1,289 @@
|
|
1
|
+
"""Table management commands for CinchDB CLI."""
|
2
|
+
|
3
|
+
import typer
|
4
|
+
from typing import List, Optional
|
5
|
+
from rich.console import Console
|
6
|
+
from rich.table import Table as RichTable
|
7
|
+
|
8
|
+
from cinchdb.managers.table import TableManager
|
9
|
+
from cinchdb.managers.change_applier import ChangeApplier
|
10
|
+
from cinchdb.models import Column, ForeignKeyRef
|
11
|
+
from cinchdb.cli.utils import get_config_with_data, validate_required_arg
|
12
|
+
|
13
|
+
app = typer.Typer(help="Table management commands", invoke_without_command=True)
|
14
|
+
console = Console()
|
15
|
+
|
16
|
+
|
17
|
+
@app.callback()
|
18
|
+
def callback(ctx: typer.Context):
|
19
|
+
"""Show help when no subcommand is provided."""
|
20
|
+
if ctx.invoked_subcommand is None:
|
21
|
+
console.print(ctx.get_help())
|
22
|
+
raise typer.Exit(0)
|
23
|
+
|
24
|
+
|
25
|
+
@app.command(name="list")
|
26
|
+
def list_tables():
|
27
|
+
"""List all tables in the current branch."""
|
28
|
+
config, config_data = get_config_with_data()
|
29
|
+
db_name = config_data.active_database
|
30
|
+
branch_name = config_data.active_branch
|
31
|
+
|
32
|
+
table_mgr = TableManager(config.project_dir, db_name, branch_name, "main")
|
33
|
+
tables = table_mgr.list_tables()
|
34
|
+
|
35
|
+
if not tables:
|
36
|
+
console.print("[yellow]No tables found[/yellow]")
|
37
|
+
return
|
38
|
+
|
39
|
+
# Create a table
|
40
|
+
table = RichTable(
|
41
|
+
title=f"Tables db={db_name} branch={branch_name}", title_justify="left"
|
42
|
+
)
|
43
|
+
table.add_column("Name", style="cyan")
|
44
|
+
table.add_column("Columns", style="green")
|
45
|
+
table.add_column("Created", style="yellow")
|
46
|
+
|
47
|
+
for tbl in tables:
|
48
|
+
# Count user-defined columns (exclude automatic ones)
|
49
|
+
user_columns = len(
|
50
|
+
[c for c in tbl.columns if c.name not in ["id", "created_at", "updated_at"]]
|
51
|
+
)
|
52
|
+
tbl.columns[0].name # Placeholder - we don't track table creation time
|
53
|
+
table.add_row(tbl.name, str(user_columns), "-")
|
54
|
+
|
55
|
+
console.print(table)
|
56
|
+
|
57
|
+
|
58
|
+
@app.command()
|
59
|
+
def create(
|
60
|
+
ctx: typer.Context,
|
61
|
+
name: Optional[str] = typer.Argument(None, help="Name of the table"),
|
62
|
+
columns: Optional[List[str]] = typer.Argument(
|
63
|
+
None, help="Column definitions (format: name:type[:nullable][:fk=table[.column][:action]])"
|
64
|
+
),
|
65
|
+
apply: bool = typer.Option(
|
66
|
+
True, "--apply/--no-apply", help="Apply changes to all tenants"
|
67
|
+
),
|
68
|
+
):
|
69
|
+
"""Create a new table.
|
70
|
+
|
71
|
+
Column format: name:type[:nullable][:fk=table[.column][:action]]
|
72
|
+
Types: TEXT, INTEGER, REAL, BLOB, NUMERIC
|
73
|
+
FK Actions: CASCADE, SET NULL, RESTRICT, NO ACTION
|
74
|
+
|
75
|
+
Examples:
|
76
|
+
cinch table create users name:TEXT email:TEXT:nullable age:INTEGER:nullable
|
77
|
+
cinch table create posts title:TEXT content:TEXT author_id:TEXT:fk=users
|
78
|
+
cinch table create comments content:TEXT post_id:TEXT:fk=posts.id:cascade
|
79
|
+
"""
|
80
|
+
name = validate_required_arg(name, "name", ctx)
|
81
|
+
if not columns:
|
82
|
+
console.print(ctx.get_help())
|
83
|
+
console.print("\n[red]❌ Error: Missing argument 'COLUMNS'.[/red]")
|
84
|
+
raise typer.Exit(1)
|
85
|
+
config, config_data = get_config_with_data()
|
86
|
+
db_name = config_data.active_database
|
87
|
+
branch_name = config_data.active_branch
|
88
|
+
|
89
|
+
# Parse columns
|
90
|
+
parsed_columns = []
|
91
|
+
for col_def in columns:
|
92
|
+
parts = col_def.split(":")
|
93
|
+
if len(parts) < 2:
|
94
|
+
console.print(f"[red]❌ Invalid column definition: '{col_def}'[/red]")
|
95
|
+
console.print("[yellow]Format: name:type[:nullable][:fk=table[.column][:action]][/yellow]")
|
96
|
+
raise typer.Exit(1)
|
97
|
+
|
98
|
+
col_name = parts[0]
|
99
|
+
col_type = parts[1].upper()
|
100
|
+
nullable = False
|
101
|
+
foreign_key = None
|
102
|
+
|
103
|
+
# Parse additional parts
|
104
|
+
for i in range(2, len(parts)):
|
105
|
+
part = parts[i]
|
106
|
+
if part.lower() == "nullable":
|
107
|
+
nullable = True
|
108
|
+
elif part.startswith("fk="):
|
109
|
+
# Parse foreign key definition
|
110
|
+
fk_def = part[3:] # Remove "fk=" prefix
|
111
|
+
|
112
|
+
# Handle actions with spaces (e.g., "set null", "no action")
|
113
|
+
# Check for known actions at the end
|
114
|
+
fk_action = "RESTRICT" # Default
|
115
|
+
for action in ["cascade", "set null", "restrict", "no action"]:
|
116
|
+
if fk_def.lower().endswith("." + action):
|
117
|
+
fk_action = action.upper()
|
118
|
+
# Remove the action part from fk_def
|
119
|
+
fk_def = fk_def[:-len("." + action)]
|
120
|
+
break
|
121
|
+
|
122
|
+
# Now split the remaining parts
|
123
|
+
fk_parts = fk_def.split(".")
|
124
|
+
|
125
|
+
if len(fk_parts) == 1:
|
126
|
+
# Just table name, column defaults to "id"
|
127
|
+
fk_table = fk_parts[0]
|
128
|
+
fk_column = "id"
|
129
|
+
elif len(fk_parts) == 2:
|
130
|
+
# table.column format
|
131
|
+
fk_table = fk_parts[0]
|
132
|
+
fk_column = fk_parts[1]
|
133
|
+
else:
|
134
|
+
console.print(f"[red]❌ Invalid foreign key format: '{fk_def}'[/red]")
|
135
|
+
console.print("[yellow]Format: fk=table[.column][:action][/yellow]")
|
136
|
+
raise typer.Exit(1)
|
137
|
+
|
138
|
+
foreign_key = ForeignKeyRef(
|
139
|
+
table=fk_table,
|
140
|
+
column=fk_column,
|
141
|
+
on_delete=fk_action,
|
142
|
+
on_update="RESTRICT" # Default to RESTRICT for updates
|
143
|
+
)
|
144
|
+
|
145
|
+
if col_type not in ["TEXT", "INTEGER", "REAL", "BLOB", "NUMERIC"]:
|
146
|
+
console.print(f"[red]❌ Invalid type: '{col_type}'[/red]")
|
147
|
+
console.print(
|
148
|
+
"[yellow]Valid types: TEXT, INTEGER, REAL, BLOB, NUMERIC[/yellow]"
|
149
|
+
)
|
150
|
+
raise typer.Exit(1)
|
151
|
+
|
152
|
+
parsed_columns.append(Column(
|
153
|
+
name=col_name,
|
154
|
+
type=col_type,
|
155
|
+
nullable=nullable,
|
156
|
+
foreign_key=foreign_key
|
157
|
+
))
|
158
|
+
|
159
|
+
try:
|
160
|
+
table_mgr = TableManager(config.project_dir, db_name, branch_name, "main")
|
161
|
+
table_mgr.create_table(name, parsed_columns)
|
162
|
+
console.print(
|
163
|
+
f"[green]✅ Created table '{name}' with {len(parsed_columns)} columns[/green]"
|
164
|
+
)
|
165
|
+
|
166
|
+
# Changes are automatically applied to all tenants by the manager
|
167
|
+
|
168
|
+
except ValueError as e:
|
169
|
+
console.print(f"[red]❌ {e}[/red]")
|
170
|
+
raise typer.Exit(1)
|
171
|
+
|
172
|
+
|
173
|
+
@app.command()
|
174
|
+
def delete(
|
175
|
+
ctx: typer.Context,
|
176
|
+
name: Optional[str] = typer.Argument(None, help="Name of the table to delete"),
|
177
|
+
force: bool = typer.Option(
|
178
|
+
False, "--force", "-f", help="Force deletion without confirmation"
|
179
|
+
),
|
180
|
+
apply: bool = typer.Option(
|
181
|
+
True, "--apply/--no-apply", help="Apply changes to all tenants"
|
182
|
+
),
|
183
|
+
):
|
184
|
+
"""Delete a table."""
|
185
|
+
name = validate_required_arg(name, "name", ctx)
|
186
|
+
config, config_data = get_config_with_data()
|
187
|
+
db_name = config_data.active_database
|
188
|
+
branch_name = config_data.active_branch
|
189
|
+
|
190
|
+
# Confirmation
|
191
|
+
if not force:
|
192
|
+
confirm = typer.confirm(f"Are you sure you want to delete table '{name}'?")
|
193
|
+
if not confirm:
|
194
|
+
console.print("[yellow]Cancelled[/yellow]")
|
195
|
+
raise typer.Exit(0)
|
196
|
+
|
197
|
+
try:
|
198
|
+
table_mgr = TableManager(config.project_dir, db_name, branch_name, "main")
|
199
|
+
table_mgr.delete_table(name)
|
200
|
+
console.print(f"[green]✅ Deleted table '{name}'[/green]")
|
201
|
+
|
202
|
+
if apply:
|
203
|
+
# Apply to all tenants
|
204
|
+
applier = ChangeApplier(config.project_dir, db_name, branch_name)
|
205
|
+
applied = applier.apply_all_unapplied()
|
206
|
+
if applied > 0:
|
207
|
+
console.print("[green]✅ Applied changes to all tenants[/green]")
|
208
|
+
|
209
|
+
except ValueError as e:
|
210
|
+
console.print(f"[red]❌ {e}[/red]")
|
211
|
+
raise typer.Exit(1)
|
212
|
+
|
213
|
+
|
214
|
+
@app.command()
|
215
|
+
def copy(
|
216
|
+
ctx: typer.Context,
|
217
|
+
source: Optional[str] = typer.Argument(None, help="Source table name"),
|
218
|
+
target: Optional[str] = typer.Argument(None, help="Target table name"),
|
219
|
+
data: bool = typer.Option(
|
220
|
+
True, "--data/--no-data", help="Copy data along with structure"
|
221
|
+
),
|
222
|
+
apply: bool = typer.Option(
|
223
|
+
True, "--apply/--no-apply", help="Apply changes to all tenants"
|
224
|
+
),
|
225
|
+
):
|
226
|
+
"""Copy a table to a new table."""
|
227
|
+
source = validate_required_arg(source, "source", ctx)
|
228
|
+
target = validate_required_arg(target, "target", ctx)
|
229
|
+
config, config_data = get_config_with_data()
|
230
|
+
db_name = config_data.active_database
|
231
|
+
branch_name = config_data.active_branch
|
232
|
+
|
233
|
+
try:
|
234
|
+
table_mgr = TableManager(config.project_dir, db_name, branch_name, "main")
|
235
|
+
table_mgr.copy_table(source, target, copy_data=data)
|
236
|
+
console.print(f"[green]✅ Copied table '{source}' to '{target}'[/green]")
|
237
|
+
|
238
|
+
if apply:
|
239
|
+
# Apply to all tenants
|
240
|
+
applier = ChangeApplier(config.project_dir, db_name, branch_name)
|
241
|
+
applied = applier.apply_all_unapplied()
|
242
|
+
if applied > 0:
|
243
|
+
console.print("[green]✅ Applied changes to all tenants[/green]")
|
244
|
+
|
245
|
+
except ValueError as e:
|
246
|
+
console.print(f"[red]❌ {e}[/red]")
|
247
|
+
raise typer.Exit(1)
|
248
|
+
|
249
|
+
|
250
|
+
@app.command()
|
251
|
+
def info(
|
252
|
+
ctx: typer.Context, name: Optional[str] = typer.Argument(None, help="Table name")
|
253
|
+
):
|
254
|
+
"""Show detailed information about a table."""
|
255
|
+
name = validate_required_arg(name, "name", ctx)
|
256
|
+
config, config_data = get_config_with_data()
|
257
|
+
db_name = config_data.active_database
|
258
|
+
branch_name = config_data.active_branch
|
259
|
+
|
260
|
+
try:
|
261
|
+
table_mgr = TableManager(config.project_dir, db_name, branch_name, "main")
|
262
|
+
table = table_mgr.get_table(name)
|
263
|
+
|
264
|
+
# Display info
|
265
|
+
console.print(f"\n[bold]Table: {table.name}[/bold]")
|
266
|
+
console.print(f"Database: {db_name}")
|
267
|
+
console.print(f"Branch: {branch_name}")
|
268
|
+
console.print("Tenant: main")
|
269
|
+
|
270
|
+
# Display columns
|
271
|
+
console.print("\n[bold]Columns:[/bold]")
|
272
|
+
col_table = RichTable()
|
273
|
+
col_table.add_column("Name", style="cyan")
|
274
|
+
col_table.add_column("Type", style="green")
|
275
|
+
col_table.add_column("Nullable", style="yellow")
|
276
|
+
col_table.add_column("Primary Key", style="red")
|
277
|
+
col_table.add_column("Default", style="blue")
|
278
|
+
|
279
|
+
for col in table.columns:
|
280
|
+
nullable = "Yes" if col.nullable else "No"
|
281
|
+
pk = "Yes" if col.primary_key else "No"
|
282
|
+
default = col.default or "-"
|
283
|
+
col_table.add_row(col.name, col.type, nullable, pk, default)
|
284
|
+
|
285
|
+
console.print(col_table)
|
286
|
+
|
287
|
+
except ValueError as e:
|
288
|
+
console.print(f"[red]❌ {e}[/red]")
|
289
|
+
raise typer.Exit(1)
|
@@ -0,0 +1,173 @@
|
|
1
|
+
"""Tenant management commands for CinchDB CLI."""
|
2
|
+
|
3
|
+
import typer
|
4
|
+
from typing import Optional
|
5
|
+
from pathlib import Path
|
6
|
+
from rich.console import Console
|
7
|
+
from rich.table import Table as RichTable
|
8
|
+
|
9
|
+
from cinchdb.config import Config
|
10
|
+
from cinchdb.core.path_utils import get_project_root
|
11
|
+
from cinchdb.managers.tenant import TenantManager
|
12
|
+
from cinchdb.cli.utils import get_config_with_data, validate_required_arg
|
13
|
+
|
14
|
+
app = typer.Typer(help="Tenant management commands", invoke_without_command=True)
|
15
|
+
console = Console()
|
16
|
+
|
17
|
+
|
18
|
+
@app.callback()
|
19
|
+
def callback(ctx: typer.Context):
|
20
|
+
"""Show help when no subcommand is provided."""
|
21
|
+
if ctx.invoked_subcommand is None:
|
22
|
+
console.print(ctx.get_help())
|
23
|
+
raise typer.Exit(0)
|
24
|
+
|
25
|
+
|
26
|
+
def get_config() -> Config:
|
27
|
+
"""Get config from current directory."""
|
28
|
+
project_root = get_project_root(Path.cwd())
|
29
|
+
if not project_root:
|
30
|
+
console.print("[red]❌ Not in a CinchDB project directory[/red]")
|
31
|
+
raise typer.Exit(1)
|
32
|
+
return Config(project_root)
|
33
|
+
|
34
|
+
|
35
|
+
@app.command(name="list")
|
36
|
+
def list_tenants():
|
37
|
+
"""List all tenants in the current branch."""
|
38
|
+
config, config_data = get_config_with_data()
|
39
|
+
db_name = config_data.active_database
|
40
|
+
branch_name = config_data.active_branch
|
41
|
+
|
42
|
+
tenant_mgr = TenantManager(config.project_dir, db_name, branch_name)
|
43
|
+
tenants = tenant_mgr.list_tenants()
|
44
|
+
|
45
|
+
if not tenants:
|
46
|
+
console.print("[yellow]No tenants found[/yellow]")
|
47
|
+
return
|
48
|
+
|
49
|
+
# Create a table
|
50
|
+
table = RichTable(
|
51
|
+
title=f"Tenants db={db_name} branch={branch_name}", title_justify="left"
|
52
|
+
)
|
53
|
+
table.add_column("Name", style="cyan")
|
54
|
+
table.add_column("Protected", style="yellow")
|
55
|
+
|
56
|
+
for tenant in tenants:
|
57
|
+
is_protected = "✓" if tenant.is_main else ""
|
58
|
+
table.add_row(tenant.name, is_protected)
|
59
|
+
|
60
|
+
console.print(table)
|
61
|
+
|
62
|
+
|
63
|
+
@app.command()
|
64
|
+
def create(
|
65
|
+
ctx: typer.Context,
|
66
|
+
name: Optional[str] = typer.Argument(None, help="Name of the tenant to create"),
|
67
|
+
description: Optional[str] = typer.Option(
|
68
|
+
None, "--description", "-d", help="Tenant description"
|
69
|
+
),
|
70
|
+
):
|
71
|
+
"""Create a new tenant."""
|
72
|
+
name = validate_required_arg(name, "name", ctx)
|
73
|
+
config, config_data = get_config_with_data()
|
74
|
+
db_name = config_data.active_database
|
75
|
+
branch_name = config_data.active_branch
|
76
|
+
|
77
|
+
try:
|
78
|
+
tenant_mgr = TenantManager(config.project_dir, db_name, branch_name)
|
79
|
+
tenant_mgr.create_tenant(name, description)
|
80
|
+
console.print(f"[green]✅ Created tenant '{name}'[/green]")
|
81
|
+
console.print("[yellow]Note: Tenant has same schema as main tenant[/yellow]")
|
82
|
+
|
83
|
+
except ValueError as e:
|
84
|
+
console.print(f"[red]❌ {e}[/red]")
|
85
|
+
raise typer.Exit(1)
|
86
|
+
|
87
|
+
|
88
|
+
@app.command()
|
89
|
+
def delete(
|
90
|
+
ctx: typer.Context,
|
91
|
+
name: Optional[str] = typer.Argument(None, help="Name of the tenant to delete"),
|
92
|
+
force: bool = typer.Option(
|
93
|
+
False, "--force", "-f", help="Force deletion without confirmation"
|
94
|
+
),
|
95
|
+
):
|
96
|
+
"""Delete a tenant."""
|
97
|
+
name = validate_required_arg(name, "name", ctx)
|
98
|
+
config, config_data = get_config_with_data()
|
99
|
+
db_name = config_data.active_database
|
100
|
+
branch_name = config_data.active_branch
|
101
|
+
|
102
|
+
if name == "main":
|
103
|
+
console.print("[red]❌ Cannot delete the main tenant[/red]")
|
104
|
+
raise typer.Exit(1)
|
105
|
+
|
106
|
+
# Confirmation
|
107
|
+
if not force:
|
108
|
+
confirm = typer.confirm(f"Are you sure you want to delete tenant '{name}'?")
|
109
|
+
if not confirm:
|
110
|
+
console.print("[yellow]Cancelled[/yellow]")
|
111
|
+
raise typer.Exit(0)
|
112
|
+
|
113
|
+
try:
|
114
|
+
tenant_mgr = TenantManager(config.project_dir, db_name, branch_name)
|
115
|
+
tenant_mgr.delete_tenant(name)
|
116
|
+
console.print(f"[green]✅ Deleted tenant '{name}'[/green]")
|
117
|
+
|
118
|
+
except ValueError as e:
|
119
|
+
console.print(f"[red]❌ {e}[/red]")
|
120
|
+
raise typer.Exit(1)
|
121
|
+
|
122
|
+
|
123
|
+
@app.command()
|
124
|
+
def copy(
|
125
|
+
ctx: typer.Context,
|
126
|
+
source: Optional[str] = typer.Argument(None, help="Source tenant name"),
|
127
|
+
target: Optional[str] = typer.Argument(None, help="Target tenant name"),
|
128
|
+
description: Optional[str] = typer.Option(
|
129
|
+
None, "--description", "-d", help="Target tenant description"
|
130
|
+
),
|
131
|
+
):
|
132
|
+
"""Copy a tenant to a new tenant (including data)."""
|
133
|
+
source = validate_required_arg(source, "source", ctx)
|
134
|
+
target = validate_required_arg(target, "target", ctx)
|
135
|
+
config, config_data = get_config_with_data()
|
136
|
+
db_name = config_data.active_database
|
137
|
+
branch_name = config_data.active_branch
|
138
|
+
|
139
|
+
try:
|
140
|
+
tenant_mgr = TenantManager(config.project_dir, db_name, branch_name)
|
141
|
+
tenant_mgr.copy_tenant(source, target, description)
|
142
|
+
console.print(f"[green]✅ Copied tenant '{source}' to '{target}'[/green]")
|
143
|
+
|
144
|
+
except ValueError as e:
|
145
|
+
console.print(f"[red]❌ {e}[/red]")
|
146
|
+
raise typer.Exit(1)
|
147
|
+
|
148
|
+
|
149
|
+
@app.command()
|
150
|
+
def rename(
|
151
|
+
ctx: typer.Context,
|
152
|
+
old_name: Optional[str] = typer.Argument(None, help="Current tenant name"),
|
153
|
+
new_name: Optional[str] = typer.Argument(None, help="New tenant name"),
|
154
|
+
):
|
155
|
+
"""Rename a tenant."""
|
156
|
+
old_name = validate_required_arg(old_name, "old_name", ctx)
|
157
|
+
new_name = validate_required_arg(new_name, "new_name", ctx)
|
158
|
+
config, config_data = get_config_with_data()
|
159
|
+
db_name = config_data.active_database
|
160
|
+
branch_name = config_data.active_branch
|
161
|
+
|
162
|
+
if old_name == "main":
|
163
|
+
console.print("[red]❌ Cannot rename the main tenant[/red]")
|
164
|
+
raise typer.Exit(1)
|
165
|
+
|
166
|
+
try:
|
167
|
+
tenant_mgr = TenantManager(config.project_dir, db_name, branch_name)
|
168
|
+
tenant_mgr.rename_tenant(old_name, new_name)
|
169
|
+
console.print(f"[green]✅ Renamed tenant '{old_name}' to '{new_name}'[/green]")
|
170
|
+
|
171
|
+
except ValueError as e:
|
172
|
+
console.print(f"[red]❌ {e}[/red]")
|
173
|
+
raise typer.Exit(1)
|