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.
Files changed (68) hide show
  1. cinchdb/__init__.py +7 -0
  2. cinchdb/__main__.py +6 -0
  3. cinchdb/api/__init__.py +5 -0
  4. cinchdb/api/app.py +76 -0
  5. cinchdb/api/auth.py +290 -0
  6. cinchdb/api/main.py +137 -0
  7. cinchdb/api/routers/__init__.py +25 -0
  8. cinchdb/api/routers/auth.py +135 -0
  9. cinchdb/api/routers/branches.py +368 -0
  10. cinchdb/api/routers/codegen.py +164 -0
  11. cinchdb/api/routers/columns.py +290 -0
  12. cinchdb/api/routers/data.py +479 -0
  13. cinchdb/api/routers/databases.py +177 -0
  14. cinchdb/api/routers/projects.py +133 -0
  15. cinchdb/api/routers/query.py +156 -0
  16. cinchdb/api/routers/tables.py +349 -0
  17. cinchdb/api/routers/tenants.py +216 -0
  18. cinchdb/api/routers/views.py +219 -0
  19. cinchdb/cli/__init__.py +0 -0
  20. cinchdb/cli/commands/__init__.py +1 -0
  21. cinchdb/cli/commands/branch.py +479 -0
  22. cinchdb/cli/commands/codegen.py +176 -0
  23. cinchdb/cli/commands/column.py +308 -0
  24. cinchdb/cli/commands/database.py +212 -0
  25. cinchdb/cli/commands/query.py +136 -0
  26. cinchdb/cli/commands/remote.py +144 -0
  27. cinchdb/cli/commands/table.py +289 -0
  28. cinchdb/cli/commands/tenant.py +173 -0
  29. cinchdb/cli/commands/view.py +189 -0
  30. cinchdb/cli/handlers/__init__.py +5 -0
  31. cinchdb/cli/handlers/codegen_handler.py +189 -0
  32. cinchdb/cli/main.py +137 -0
  33. cinchdb/cli/utils.py +182 -0
  34. cinchdb/config.py +177 -0
  35. cinchdb/core/__init__.py +5 -0
  36. cinchdb/core/connection.py +175 -0
  37. cinchdb/core/database.py +537 -0
  38. cinchdb/core/maintenance.py +73 -0
  39. cinchdb/core/path_utils.py +153 -0
  40. cinchdb/managers/__init__.py +26 -0
  41. cinchdb/managers/branch.py +167 -0
  42. cinchdb/managers/change_applier.py +414 -0
  43. cinchdb/managers/change_comparator.py +194 -0
  44. cinchdb/managers/change_tracker.py +182 -0
  45. cinchdb/managers/codegen.py +523 -0
  46. cinchdb/managers/column.py +579 -0
  47. cinchdb/managers/data.py +455 -0
  48. cinchdb/managers/merge_manager.py +429 -0
  49. cinchdb/managers/query.py +214 -0
  50. cinchdb/managers/table.py +383 -0
  51. cinchdb/managers/tenant.py +258 -0
  52. cinchdb/managers/view.py +252 -0
  53. cinchdb/models/__init__.py +27 -0
  54. cinchdb/models/base.py +44 -0
  55. cinchdb/models/branch.py +26 -0
  56. cinchdb/models/change.py +47 -0
  57. cinchdb/models/database.py +20 -0
  58. cinchdb/models/project.py +20 -0
  59. cinchdb/models/table.py +86 -0
  60. cinchdb/models/tenant.py +19 -0
  61. cinchdb/models/view.py +15 -0
  62. cinchdb/utils/__init__.py +15 -0
  63. cinchdb/utils/sql_validator.py +137 -0
  64. cinchdb-0.1.0.dist-info/METADATA +195 -0
  65. cinchdb-0.1.0.dist-info/RECORD +68 -0
  66. cinchdb-0.1.0.dist-info/WHEEL +4 -0
  67. cinchdb-0.1.0.dist-info/entry_points.txt +3 -0
  68. 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)