ch-migrate-cli 0.5.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.
ch_migrate/__init__.py ADDED
@@ -0,0 +1,68 @@
1
+ """
2
+ ch-migrate-cli: Alembic-based migrations for ClickHouse Cloud.
3
+
4
+ Usage:
5
+ from ch_migrate import read_sql, get_db, get_env_config, create_dictionary
6
+ """
7
+
8
+ __version__ = "0.5.0"
9
+
10
+
11
+ from typing import Any
12
+
13
+
14
+ # Lazy imports to avoid import errors before dependencies are created
15
+ def __getattr__(name: str) -> Any:
16
+ if name in ("read_sql", "get_db", "create_dictionary", "on_cluster", "get_cluster"):
17
+ from ch_migrate.helpers import (
18
+ create_dictionary,
19
+ get_cluster,
20
+ get_db,
21
+ on_cluster,
22
+ read_sql,
23
+ )
24
+
25
+ return {
26
+ "read_sql": read_sql,
27
+ "get_db": get_db,
28
+ "create_dictionary": create_dictionary,
29
+ "on_cluster": on_cluster,
30
+ "get_cluster": get_cluster,
31
+ }[name]
32
+ elif name == "run_sql":
33
+ from ch_migrate.sql import run_sql
34
+
35
+ return run_sql
36
+ elif name == "IrreversibleMigration":
37
+ from ch_migrate.downgrade import IrreversibleMigration
38
+
39
+ return IrreversibleMigration
40
+ elif name == "get_env_config":
41
+ from ch_migrate.config import get_env_config
42
+
43
+ return get_env_config
44
+ elif name in ("get_secret", "SSMSecretNotFoundError", "SSMJsonKeyError"):
45
+ from ch_migrate.secrets import SSMJsonKeyError, SSMSecretNotFoundError, get_secret
46
+
47
+ return {
48
+ "get_secret": get_secret,
49
+ "SSMSecretNotFoundError": SSMSecretNotFoundError,
50
+ "SSMJsonKeyError": SSMJsonKeyError,
51
+ }[name]
52
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
53
+
54
+
55
+ __all__ = [
56
+ "__version__",
57
+ "read_sql",
58
+ "run_sql",
59
+ "IrreversibleMigration",
60
+ "get_db",
61
+ "get_env_config",
62
+ "create_dictionary",
63
+ "on_cluster",
64
+ "get_cluster",
65
+ "get_secret",
66
+ "SSMSecretNotFoundError",
67
+ "SSMJsonKeyError",
68
+ ]
@@ -0,0 +1,163 @@
1
+ """The files `ch-migrate new` writes for a SQL-first migration.
2
+
3
+ Alembic generates the revision file; this module rewrites it to run an upgrade
4
+ SQL file and a downgrade SQL file, and creates those files in the
5
+ object-centric history layout under migrations/sql/history/.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import ast
11
+ import re
12
+ from dataclasses import dataclass
13
+ from datetime import datetime
14
+ from pathlib import Path, PurePosixPath
15
+ from typing import Any
16
+
17
+ from ch_migrate.rebase import _MISSING, _literal_assignment
18
+
19
+ OBJECT_DIRS = {"table": "tables", "view": "views", "dictionary": "dictionaries"}
20
+ OTHER_DIR = "other"
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class NewOptions:
25
+ """The authoring choices for one `new` invocation."""
26
+
27
+ table_name: str | None = None
28
+ view_name: str | None = None
29
+ dict_name: str | None = None
30
+ exchange: bool = False
31
+ python_migration: bool = False
32
+ irreversible_reason: str | None = None
33
+
34
+ def named_objects(self) -> list[tuple[str, str]]:
35
+ return [
36
+ (kind, name)
37
+ for kind, name in (
38
+ ("table", self.table_name),
39
+ ("view", self.view_name),
40
+ ("dictionary", self.dict_name),
41
+ )
42
+ if name
43
+ ]
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class RevisionHeader:
48
+ """Identifiers Alembic wrote into a freshly generated revision file."""
49
+
50
+ message: str
51
+ revision: str
52
+ down_revision: Any
53
+ branch_labels: Any
54
+ depends_on: Any
55
+ docstring_source: str
56
+
57
+
58
+ @dataclass(frozen=True)
59
+ class SqlFiles:
60
+ """Paths of a migration's SQL files, relative to migrations/sql/."""
61
+
62
+ upgrade: str
63
+ downgrade: str | None
64
+
65
+
66
+ def read_revision_header(path: Path) -> RevisionHeader:
67
+ """Read the identifiers out of an Alembic-generated revision file."""
68
+ content = path.read_text()
69
+ module = ast.parse(content)
70
+ docstring = ast.get_docstring(module) or ""
71
+
72
+ def literal(name: str) -> Any:
73
+ value = _literal_assignment(content, name)
74
+ return None if value is _MISSING else value
75
+
76
+ return RevisionHeader(
77
+ message=docstring.splitlines()[0].strip() if docstring else path.stem,
78
+ revision=str(literal("revision")),
79
+ down_revision=literal("down_revision"),
80
+ branch_labels=literal("branch_labels"),
81
+ depends_on=literal("depends_on"),
82
+ docstring_source=ast.get_source_segment(content, module.body[0]) if docstring else '""""""',
83
+ )
84
+
85
+
86
+ def history_dir(object_type: str | None, object_name: str | None) -> PurePosixPath:
87
+ """Where a migration's SQL files live, relative to migrations/sql/."""
88
+ if object_type and object_name:
89
+ return PurePosixPath("history", OBJECT_DIRS[object_type], object_name)
90
+ return PurePosixPath("history", OTHER_DIR)
91
+
92
+
93
+ def write_sql_files(sql_root: Path, header: RevisionHeader, options: NewOptions) -> SqlFiles:
94
+ """Create the upgrade (and, if reversible, downgrade) SQL files."""
95
+ named = options.named_objects()
96
+ rel_dir = history_dir(*named[0]) if named else history_dir(None, None)
97
+ stem = f"{datetime.now().strftime('%Y_%m_%d_%H%M')}_{header.revision}_"
98
+ stem += _slug(header.message)
99
+ (sql_root / rel_dir).mkdir(parents=True, exist_ok=True)
100
+
101
+ upgrade = rel_dir / f"{stem}.up.sql"
102
+ (sql_root / upgrade).write_text(_sql_file_text(header, "upgrade"))
103
+ if options.irreversible_reason is not None:
104
+ return SqlFiles(upgrade=str(upgrade), downgrade=None)
105
+
106
+ downgrade = rel_dir / f"{stem}.down.sql"
107
+ (sql_root / downgrade).write_text(_sql_file_text(header, "downgrade"))
108
+ return SqlFiles(upgrade=str(upgrade), downgrade=str(downgrade))
109
+
110
+
111
+ def render_revision(header: RevisionHeader, files: SqlFiles, irreversible: str | None) -> str:
112
+ """The revision file for a SQL-first migration."""
113
+ imports = "IrreversibleMigration, run_sql" if irreversible else "run_sql"
114
+ marker = ""
115
+ if irreversible:
116
+ marker = (
117
+ "\n# `ch-migrate down` refuses to revert past this migration.\n"
118
+ f"irreversible = {irreversible!r}\n"
119
+ )
120
+ downgrade_body = " raise IrreversibleMigration(revision, irreversible)"
121
+ else:
122
+ downgrade_body = f" run_sql({files.downgrade!r})"
123
+
124
+ return (
125
+ f"{header.docstring_source}\n\n"
126
+ f"from ch_migrate import {imports}\n\n"
127
+ "# revision identifiers\n"
128
+ f"revision = {header.revision!r}\n"
129
+ f"down_revision = {header.down_revision!r}\n"
130
+ f"branch_labels = {header.branch_labels!r}\n"
131
+ f"depends_on = {header.depends_on!r}\n"
132
+ f"{marker}\n\n"
133
+ "def upgrade() -> None:\n"
134
+ f" run_sql({files.upgrade!r})\n\n\n"
135
+ "def downgrade() -> None:\n"
136
+ f"{downgrade_body}\n"
137
+ )
138
+
139
+
140
+ def _slug(message: str) -> str:
141
+ return re.sub(r"\W+", "_", message).strip("_").lower()[:40] or "migration"
142
+
143
+
144
+ def _sql_file_text(header: RevisionHeader, direction: str) -> str:
145
+ guidance = {
146
+ "upgrade": (
147
+ "-- Write the statements for this change, each ending with a semicolon.\n"
148
+ "-- They run one at a time, in order. Make each one safe to run twice\n"
149
+ "-- (IF NOT EXISTS / IF EXISTS), so a failed run can simply be re-run.\n"
150
+ ),
151
+ "downgrade": (
152
+ "-- Statements end with a semicolon and run one at a time, in order.\n"
153
+ "-- If the change cannot be undone (dropped data does not come back),\n"
154
+ "-- mark the migration irreversible instead.\n"
155
+ ),
156
+ }[direction]
157
+ return (
158
+ f"-- {header.message}: {direction}\n"
159
+ f"-- Revision: {header.revision}\n"
160
+ "--\n"
161
+ f"{guidance}"
162
+ "-- {db} is replaced with the environment's database name.\n\n"
163
+ )
@@ -0,0 +1,379 @@
1
+ """
2
+ Bootstrap script for initializing ClickHouse database and users.
3
+
4
+ Usage:
5
+ ch-migrate bootstrap <environment>
6
+ ch-migrate bootstrap <environment> --dry-run
7
+
8
+ This script:
9
+ 1. Connects using admin credentials
10
+ 2. Creates the target database (if not exists)
11
+ 3. Creates roles for migration, read-only, and dict access
12
+ 4. Creates users and assigns roles
13
+ 5. Is idempotent - safe to run multiple times
14
+ """
15
+
16
+ import re
17
+ import sys
18
+ from pathlib import Path
19
+ from typing import Optional
20
+
21
+ from ch_migrate import ui
22
+ from ch_migrate.config import get_env_config
23
+ from ch_migrate.secrets import get_secret
24
+
25
+ # Pattern for valid SQL identifiers (database, user, role names)
26
+ _IDENTIFIER_PATTERN = re.compile(r"^[a-zA-Z_][a-zA-Z0-9_]*$")
27
+
28
+
29
+ def validate_identifier(name: str, label: str) -> str:
30
+ """
31
+ Validate a SQL identifier (database, user, role name).
32
+
33
+ Args:
34
+ name: The identifier to validate
35
+ label: Human-readable label for error messages (e.g., "database name")
36
+
37
+ Returns:
38
+ The validated identifier (unchanged if valid)
39
+
40
+ Raises:
41
+ ValueError: If the identifier contains invalid characters
42
+ """
43
+ if not _IDENTIFIER_PATTERN.match(name):
44
+ raise ValueError(
45
+ f"Invalid {label}: {name!r}. "
46
+ f"Must start with letter/underscore and contain only alphanumeric/underscore."
47
+ )
48
+ return name
49
+
50
+
51
+ def escape_sql_string(s: str) -> str:
52
+ """
53
+ Escape a string for use in SQL single-quoted literals.
54
+
55
+ Note: This is for string literals (e.g., passwords) only, not identifiers.
56
+ Use validate_identifier() for database/user/role names.
57
+ """
58
+ return s.replace("\\", "\\\\").replace("'", "''")
59
+
60
+
61
+ def build_bootstrap_sql(
62
+ *,
63
+ project: str,
64
+ db: str,
65
+ migration_user: str,
66
+ migration_password: str,
67
+ dict_reader_name: Optional[str] = None,
68
+ dict_reader_password: Optional[str] = None,
69
+ mcp_user_name: Optional[str] = None,
70
+ mcp_password: Optional[str] = None,
71
+ ) -> str:
72
+ """
73
+ Build the bootstrap SQL with optional users.
74
+
75
+ Args:
76
+ project: Project name (used for role prefixes)
77
+ db: Database name
78
+ migration_user: Migration user name
79
+ migration_password: Migration user password
80
+ dict_reader_name: Optional dict reader user name
81
+ dict_reader_password: Optional dict reader password
82
+ mcp_user_name: Optional MCP user name
83
+ mcp_password: Optional MCP user password
84
+
85
+ Returns:
86
+ Complete SQL string for bootstrap
87
+
88
+ Raises:
89
+ ValueError: If any identifier contains invalid characters
90
+ """
91
+ # Validate all identifiers to prevent SQL injection
92
+ project = validate_identifier(project, "project name")
93
+ db = validate_identifier(db, "database name")
94
+ migration_user = validate_identifier(migration_user, "migration user")
95
+ if dict_reader_name:
96
+ dict_reader_name = validate_identifier(dict_reader_name, "dict reader user")
97
+ if mcp_user_name:
98
+ mcp_user_name = validate_identifier(mcp_user_name, "MCP user")
99
+
100
+ lines = [
101
+ "-- Bootstrap script for ClickHouse database",
102
+ "-- Safe to run multiple times (idempotent)",
103
+ "",
104
+ "-- Create database",
105
+ f"CREATE DATABASE IF NOT EXISTS {db};",
106
+ "",
107
+ "-- =============================================================================",
108
+ "-- ROLES",
109
+ "-- =============================================================================",
110
+ "",
111
+ "-- Migration role: schema changes and data operations",
112
+ "-- (Explicit grants instead of ALL for ClickHouse Cloud compatibility)",
113
+ f"CREATE ROLE IF NOT EXISTS {project}_migration_role;",
114
+ "",
115
+ "-- Schema operations (RENAME is part of ALTER)",
116
+ f"GRANT CREATE TABLE, DROP TABLE, UNDROP TABLE, ALTER ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
117
+ f"GRANT CREATE VIEW, DROP VIEW ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
118
+ f"GRANT CREATE DICTIONARY, DROP DICTIONARY ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
119
+ f"GRANT CREATE FUNCTION, DROP FUNCTION ON *.* TO {project}_migration_role WITH GRANT OPTION;",
120
+ "",
121
+ "-- User/role management (for migrations that create RLS users/roles)",
122
+ "-- CURRENT GRANTS: ClickHouse Cloud's admin cannot grant ALTER/DROP USER on its managed sql-console user",
123
+ f"GRANT CURRENT GRANTS(CREATE USER, ALTER USER, DROP USER ON *.*) TO {project}_migration_role WITH GRANT OPTION;",
124
+ f"GRANT CREATE ROLE, ALTER ROLE, DROP ROLE ON *.* TO {project}_migration_role WITH GRANT OPTION;",
125
+ f"GRANT ROLE ADMIN ON *.* TO {project}_migration_role WITH GRANT OPTION;",
126
+ "",
127
+ "-- Row-level security management",
128
+ f"GRANT ACCESS MANAGEMENT ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
129
+ f"GRANT CREATE ROW POLICY, ALTER ROW POLICY, DROP ROW POLICY, SHOW ROW POLICIES ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
130
+ "",
131
+ "-- Data operations",
132
+ f"GRANT SELECT, INSERT, DELETE, TRUNCATE, OPTIMIZE ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
133
+ "",
134
+ "-- Introspection (needed by Alembic)",
135
+ f"GRANT SHOW TABLES, SHOW COLUMNS, SHOW DICTIONARIES ON {db}.* TO {project}_migration_role WITH GRANT OPTION;",
136
+ "",
137
+ "-- Temp tables (for zero-downtime migrations with EXCHANGE TABLES)",
138
+ "-- Note: EXCHANGE TABLES requires DROP on both tables, already granted above",
139
+ f"GRANT CREATE TEMPORARY TABLE ON *.* TO {project}_migration_role WITH GRANT OPTION;",
140
+ "",
141
+ "-- System table access (monitoring migrations, RLS introspection, user/role management)",
142
+ "-- Use CURRENT GRANTS to avoid failure when admin lacks access to some system tables",
143
+ "-- (e.g., system.zookeeper on ClickHouse Cloud)",
144
+ f"GRANT CURRENT GRANTS(SELECT ON system.*) TO {project}_migration_role WITH GRANT OPTION;",
145
+ "-- ClickHouse Cloud requires explicit grants for individual system tables",
146
+ f"GRANT SELECT ON system.grants TO {project}_migration_role;",
147
+ ]
148
+
149
+ # MCP readonly role (optional)
150
+ if mcp_user_name and mcp_password:
151
+ lines.extend(
152
+ [
153
+ "",
154
+ "-- Read-only role: for MCP tools",
155
+ f"CREATE ROLE IF NOT EXISTS {project}_readonly_role;",
156
+ f"GRANT SELECT ON {db}.* TO {project}_readonly_role;",
157
+ f"GRANT SHOW TABLES ON {db}.* TO {project}_readonly_role;",
158
+ ]
159
+ )
160
+
161
+ # Dict reader role (optional)
162
+ if dict_reader_name and dict_reader_password:
163
+ lines.extend(
164
+ [
165
+ "",
166
+ "-- Dict reader role: for dictionary sources",
167
+ f"CREATE ROLE IF NOT EXISTS {project}_dict_role;",
168
+ "-- SELECT grants added per-table when dictionaries are created",
169
+ ]
170
+ )
171
+
172
+ lines.extend(
173
+ [
174
+ "",
175
+ "-- =============================================================================",
176
+ "-- USERS",
177
+ "-- =============================================================================",
178
+ "",
179
+ "-- Migration user (required)",
180
+ f"CREATE USER IF NOT EXISTS {migration_user}",
181
+ f"IDENTIFIED BY '{escape_sql_string(migration_password)}';",
182
+ f"GRANT {project}_migration_role TO {migration_user};",
183
+ ]
184
+ )
185
+
186
+ # MCP user (optional)
187
+ if mcp_user_name and mcp_password:
188
+ lines.extend(
189
+ [
190
+ "",
191
+ "-- MCP user: read-only access for AI tools",
192
+ f"CREATE USER IF NOT EXISTS {mcp_user_name}",
193
+ f"IDENTIFIED BY '{escape_sql_string(mcp_password)}';",
194
+ f"GRANT {project}_readonly_role TO {mcp_user_name};",
195
+ ]
196
+ )
197
+
198
+ # Dict reader user (optional)
199
+ if dict_reader_name and dict_reader_password:
200
+ lines.extend(
201
+ [
202
+ "",
203
+ "-- Dict reader user: for dictionary sources",
204
+ f"CREATE USER IF NOT EXISTS {dict_reader_name}",
205
+ f"IDENTIFIED BY '{escape_sql_string(dict_reader_password)}';",
206
+ f"GRANT {project}_dict_role TO {dict_reader_name};",
207
+ ]
208
+ )
209
+
210
+ return "\n".join(lines)
211
+
212
+
213
+ def run_bootstrap(
214
+ env_name: str,
215
+ config_path: Optional[Path] = None,
216
+ dry_run: bool = False,
217
+ verbose: bool = False,
218
+ ) -> None:
219
+ """
220
+ Run bootstrap for the specified environment.
221
+
222
+ Args:
223
+ env_name: Environment name (dev, staging, production, etc.)
224
+ config_path: Path to config.yaml (defaults to ./config.yaml)
225
+ dry_run: If True, print SQL without executing
226
+ verbose: If True, print each SQL statement as it executes
227
+ """
228
+ if config_path is None:
229
+ config_path = Path.cwd() / "config.yaml"
230
+
231
+ env_config = get_env_config(env_name, config_path)
232
+
233
+ # Get SSM paths if configured
234
+ ssm_config = env_config.get("ssm", {})
235
+
236
+ # Get AWS region for SSM lookups (optional, uses AWS default if not set)
237
+ aws_region = env_config.get("aws_region")
238
+
239
+ # Get required credentials
240
+ admin_password = get_secret(
241
+ env_name,
242
+ "admin_password",
243
+ ssm_path=ssm_config.get("admin_password"),
244
+ aws_region=aws_region,
245
+ required=True,
246
+ )
247
+
248
+ migration_password = get_secret(
249
+ env_name,
250
+ "migration_password",
251
+ ssm_path=ssm_config.get("migration_password"),
252
+ aws_region=aws_region,
253
+ required=True,
254
+ )
255
+ assert admin_password is not None # required=True ensures this
256
+ assert migration_password is not None # required=True ensures this
257
+
258
+ # Get optional credentials
259
+ dict_reader_name = env_config.get("dict_reader_name")
260
+ dict_reader_password = None
261
+ if dict_reader_name:
262
+ dict_reader_password = get_secret(
263
+ env_name,
264
+ "dict_reader_password",
265
+ ssm_path=ssm_config.get("dict_reader_password"),
266
+ aws_region=aws_region,
267
+ required=True,
268
+ )
269
+
270
+ mcp_user_name = env_config.get("mcp_user_name")
271
+ mcp_password = None
272
+ if mcp_user_name:
273
+ mcp_password = get_secret(
274
+ env_name,
275
+ "mcp_password",
276
+ ssm_path=ssm_config.get("mcp_password"),
277
+ aws_region=aws_region,
278
+ required=True,
279
+ )
280
+
281
+ # Get migration user - support both new 'migration_user' and legacy 'user' field
282
+ migration_user = env_config.get("migration_user") or env_config.get("user")
283
+ if not migration_user:
284
+ raise ValueError(f"migration_user is required in config.yaml for environment '{env_name}'")
285
+
286
+ # Build SQL
287
+ project_config = env_config.get("project")
288
+ if project_config:
289
+ project = project_config
290
+ else:
291
+ # Derive from database name (e.g., myproject_dev -> myproject)
292
+ project = env_config["database"].split("_")[0]
293
+
294
+ sql = build_bootstrap_sql(
295
+ project=project,
296
+ db=env_config["database"],
297
+ migration_user=migration_user,
298
+ migration_password=migration_password,
299
+ dict_reader_name=dict_reader_name,
300
+ dict_reader_password=dict_reader_password,
301
+ mcp_user_name=mcp_user_name,
302
+ mcp_password=mcp_password,
303
+ )
304
+
305
+ if dry_run:
306
+ ui.step(f"Dry run for {env_name}: the SQL bootstrap would run")
307
+ # Mask passwords in output
308
+ masked_sql = sql
309
+ for pw in [migration_password, dict_reader_password, mcp_password]:
310
+ if pw:
311
+ masked_sql = masked_sql.replace(pw, "********")
312
+ print(masked_sql)
313
+ return
314
+
315
+ host = env_config["host"]
316
+ admin_user = env_config.get("admin_user", "default")
317
+ secure = env_config.get("secure", True)
318
+ port = env_config.get("port", 8443 if secure else 8123)
319
+
320
+ ui.step(f"Bootstrapping {env_name}: database {env_config['database']} on {host}, as {admin_user}")
321
+ ui.detail(f"Migration user: {migration_user}")
322
+ if dict_reader_name:
323
+ ui.detail(f"Dict reader: {dict_reader_name}")
324
+ if mcp_user_name:
325
+ ui.detail(f"MCP user: {mcp_user_name}")
326
+
327
+ import clickhouse_connect
328
+
329
+ client = clickhouse_connect.get_client(
330
+ host=host,
331
+ port=port,
332
+ username=admin_user,
333
+ password=admin_password,
334
+ secure=secure,
335
+ interface="https" if secure else "http",
336
+ )
337
+
338
+ # Collect passwords for masking in verbose output
339
+ passwords_to_mask = [p for p in [migration_password, dict_reader_password, mcp_password] if p]
340
+
341
+ # Execute each statement
342
+ for statement in sql.split(";"):
343
+ statement = statement.strip()
344
+ # Strip leading comment lines to get to the actual SQL
345
+ lines = statement.split("\n")
346
+ sql_lines = [line for line in lines if not line.strip().startswith("--")]
347
+ actual_sql = "\n".join(sql_lines).strip()
348
+ if actual_sql:
349
+ if verbose:
350
+ # Mask passwords in output
351
+ masked = statement
352
+ for pw in passwords_to_mask:
353
+ masked = masked.replace(pw, "********")
354
+ ui.detail(f"{masked};")
355
+ client.command(actual_sql)
356
+
357
+ ui.success(f"Bootstrap complete for {env_name}.")
358
+ ui.hint(f"Next: `ch-migrate new {env_name} NAME --table TABLE`, or `ch-migrate up {env_name}`.")
359
+
360
+
361
+ def main() -> None:
362
+ """CLI entrypoint for bootstrap."""
363
+ if len(sys.argv) < 2:
364
+ print("Usage: ch-migrate bootstrap <environment> [--dry-run]")
365
+ print("Example: ch-migrate bootstrap dev")
366
+ sys.exit(1)
367
+
368
+ dry_run = "--dry-run" in sys.argv
369
+ env_name = sys.argv[1]
370
+
371
+ try:
372
+ run_bootstrap(env_name, dry_run=dry_run)
373
+ except Exception as e:
374
+ print(f"Error: {e}", file=sys.stderr)
375
+ sys.exit(1)
376
+
377
+
378
+ if __name__ == "__main__":
379
+ main()