quickscale-cli 0.87.0__tar.gz → 0.88.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.
Files changed (33) hide show
  1. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/PKG-INFO +2 -2
  2. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/pyproject.toml +2 -2
  3. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/_version.py +1 -1
  4. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/apply_command.py +78 -30
  5. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/deployment_commands.py +112 -17
  6. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/development_commands.py +167 -75
  7. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/module_commands.py +30 -24
  8. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/module_config.py +8 -852
  9. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/module_output.py +5 -1
  10. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/status_command.py +234 -95
  11. quickscale_cli-0.88.0/src/quickscale_cli/utils/auth_migration.py +169 -0
  12. quickscale_cli-0.88.0/src/quickscale_cli/utils/docker_utils.py +688 -0
  13. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/module_dependency_sync.py +72 -6
  14. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/module_wiring_manager.py +54 -28
  15. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/project_manager.py +38 -22
  16. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/railway_utils.py +2 -2
  17. quickscale_cli-0.87.0/src/quickscale_cli/utils/docker_utils.py +0 -340
  18. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/README.md +0 -0
  19. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/__init__.py +0 -0
  20. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/__init__.py +0 -0
  21. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/apply_support.py +0 -0
  22. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/dr_commands.py +0 -0
  23. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/dr_output.py +0 -0
  24. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/plan_command.py +0 -0
  25. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/plan_selection.py +0 -0
  26. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/remove_command.py +0 -0
  27. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/commands/remove_output.py +0 -0
  28. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/main.py +0 -0
  29. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/py.typed +0 -0
  30. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/__init__.py +0 -0
  31. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/dependency_utils.py +0 -0
  32. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/development_state.py +0 -0
  33. {quickscale_cli-0.87.0 → quickscale_cli-0.88.0}/src/quickscale_cli/utils/project_identity.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: quickscale-cli
3
- Version: 0.87.0
3
+ Version: 0.88.0
4
4
  Summary: Command-line interface for QuickScale Django project generator
5
5
  License-Expression: Apache-2.0
6
6
  Keywords: django,cli,project-generator,saas
@@ -14,7 +14,7 @@ Classifier: Programming Language :: Python :: 3.14
14
14
  Classifier: Framework :: Django
15
15
  Classifier: Framework :: Django :: 6.0
16
16
  Requires-Dist: click (>=8.4.2,<9.0.0)
17
- Requires-Dist: quickscale-core (>=0.87.0,<0.88.0)
17
+ Requires-Dist: quickscale-core (>=0.88.0,<0.89.0)
18
18
  Project-URL: Documentation, https://github.com/Experto-AI/quickscale/tree/main/docs
19
19
  Project-URL: Homepage, https://github.com/Experto-AI/quickscale
20
20
  Project-URL: Repository, https://github.com/Experto-AI/quickscale
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "quickscale-cli"
3
- version = "0.87.0"
3
+ version = "0.88.0"
4
4
  description = "Command-line interface for QuickScale Django project generator"
5
5
  requires-python = ">=3.14,<3.15"
6
6
  readme = "README.md"
@@ -31,7 +31,7 @@ packages = [{include = "quickscale_cli", from = "src"}]
31
31
  [tool.poetry.dependencies]
32
32
  python = ">=3.14,<3.15"
33
33
  click = "^8.4.2"
34
- quickscale-core = "^0.87.0"
34
+ quickscale-core = "^0.88.0"
35
35
 
36
36
  [tool.poetry.group.dev.dependencies]
37
37
  # Dev dependencies centralized in root pyproject.toml
@@ -1,2 +1,2 @@
1
1
  # Auto-generated by scripts/version_tool.sh
2
- __version__ = "0.87.0"
2
+ __version__ = "0.88.0"
@@ -68,9 +68,12 @@ from quickscale_cli.commands.apply_support import (
68
68
  _report_theme_preflight_error,
69
69
  )
70
70
  from quickscale_cli.commands.apply_support import _resolve_apply_raw_root # noqa: F401
71
- from quickscale_cli.commands.module_commands import embed_module, ModuleEmbedProvenance
72
- from quickscale_cli.commands.module_config import (
71
+ from quickscale_cli.commands.module_commands import (
73
72
  APPLY_MODULE_EXECUTION_MODE,
73
+ embed_module,
74
+ ModuleEmbedProvenance,
75
+ )
76
+ from quickscale_cli.commands.module_config import (
74
77
  get_default_backups_config,
75
78
  validate_backups_module_options,
76
79
  )
@@ -139,6 +142,7 @@ from quickscale_core.utils.git_utils import (
139
142
  GitError,
140
143
  is_working_directory_clean,
141
144
  resolve_remote_ref,
145
+ resolve_split_branch,
142
146
  validate_module_name,
143
147
  validate_tag_name,
144
148
  )
@@ -513,6 +517,30 @@ def _parse_split_ref_overrides(split_refs: tuple[str, ...]) -> dict[str, str]:
513
517
  return parsed
514
518
 
515
519
 
520
+ def _expand_split_refs_from_branches(ctx: ApplyContext) -> dict[str, str]:
521
+ """
522
+ Map every module being embedded to its canonical ``splits/<name>-module`` branch.
523
+
524
+ This is the bulk form of ``--split-ref``. Every override in the pre-seal
525
+ verification flow is mechanically the module's own split branch, so deriving
526
+ them removes a dozen hand-typed arguments -- and with them the chance of
527
+ typing one wrong or omitting one, which the exact-coverage rule would reject
528
+ only after the operator had already retyped the whole line.
529
+
530
+ The result covers exactly the embed set by construction, so
531
+ :func:`_validate_split_ref_override_coverage` cannot fail on it. It is
532
+ recomputed against the refreshed context after the advisory lock is taken,
533
+ because the embed set itself can change there.
534
+ """
535
+ try:
536
+ return {
537
+ module_name: resolve_split_branch(module_name)
538
+ for module_name in sorted(_module_names_to_embed(ctx))
539
+ }
540
+ except GitError as error:
541
+ raise click.UsageError(f"--split-refs-from-branches: {error}") from error
542
+
543
+
516
544
  def _module_names_to_embed(ctx: ApplyContext) -> set[str]:
517
545
  """Return the exact module target set for this apply pass."""
518
546
  if ctx.existing_state is None:
@@ -882,12 +910,7 @@ def _load_module_manifests(
882
910
  """Load manifests for all installed modules"""
883
911
  manifests: dict[str, ModuleManifest] = {}
884
912
  for module_name in module_names:
885
- try:
886
- manifest = get_manifest_for_module(project_path, module_name, strict=strict)
887
- except ManifestError as error:
888
- if strict and "Manifest file not found:" not in str(error):
889
- raise
890
- manifest = None
913
+ manifest = get_manifest_for_module(project_path, module_name, strict=strict)
891
914
  if manifest:
892
915
  manifests[module_name] = manifest
893
916
  return manifests
@@ -1898,16 +1921,18 @@ def _attempt_provenance_repair_if_needed(ctx: ApplyContext) -> None:
1898
1921
 
1899
1922
  try:
1900
1923
  resolved_sha = resolve_remote_ref(remote, branch)
1901
- module_state.commit_sha = resolved_sha
1902
- # Backfill the full provenance triple: also refresh embedded_at
1903
- # to the repair timestamp and ensure version is populated from
1904
- # the embedded manifest when available.
1905
- module_state.embedded_at = repair_timestamp
1906
1924
  manifest = get_manifest_for_module(ctx.output_path, module_name)
1925
+ repaired_version = module_state.version
1907
1926
  if manifest is not None:
1908
1927
  normalized_version = normalize_installed_version(manifest.version)
1909
1928
  if normalized_version is not None:
1910
- module_state.version = normalized_version
1929
+ repaired_version = normalized_version
1930
+ # Commit the complete repair only after every lookup succeeds.
1931
+ # An incomplete embedded module must not leave partially repaired
1932
+ # state that can later be persisted.
1933
+ module_state.commit_sha = resolved_sha
1934
+ module_state.embedded_at = repair_timestamp
1935
+ module_state.version = repaired_version
1911
1936
  repaired_any = True
1912
1937
  click.secho(f"✅ Repaired {module_name}: {resolved_sha[:8]}", fg="green")
1913
1938
  except Exception as e:
@@ -2980,15 +3005,12 @@ def _regenerate_managed_wiring_for_apply(
2980
3005
  delta: Any | None,
2981
3006
  ) -> tuple[bool, str]:
2982
3007
  try:
2983
- desired_module_names = sorted(qs_config.modules.keys())
2984
- if existing_state is None:
2985
- selected = module_names
2986
- else:
2987
- unchanged = getattr(delta, "modules_unchanged", []) if delta else []
2988
- selected = sorted(set(unchanged) | set(module_names))
2989
- if not desired_module_names:
2990
- selected = []
2991
- options = {m: c.options for m, c in qs_config.modules.items()}
3008
+ del module_names, existing_state, delta
3009
+ selected = sorted(qs_config.modules.keys())
3010
+ options = {
3011
+ module_name: dict(module_config.options or {})
3012
+ for module_name, module_config in qs_config.modules.items()
3013
+ }
2992
3014
  return regenerate_managed_wiring(
2993
3015
  output_path,
2994
3016
  module_names=selected,
@@ -3183,8 +3205,8 @@ def _refresh_context_after_lock(ctx: ApplyContext) -> None:
3183
3205
  list(merged_state.modules.keys()),
3184
3206
  strict=True,
3185
3207
  )
3186
- except ManifestError:
3187
- manifests = {}
3208
+ except ManifestError as error:
3209
+ _abort_for_manifest_error(error, command_name="apply")
3188
3210
 
3189
3211
  # SA7.4: re-validate required-module version constraints with
3190
3212
  # fresh manifests so a concurrent module update is not missed.
@@ -3373,6 +3395,8 @@ def _execute_apply_steps(
3373
3395
  no_modules: bool,
3374
3396
  verbose_docker: bool = False,
3375
3397
  split_ref_overrides: Mapping[str, str] | None = None,
3398
+ *,
3399
+ split_refs_from_branches: bool = False,
3376
3400
  ) -> None:
3377
3401
  """Execute the apply steps after confirmation."""
3378
3402
  click.echo("\n" + "=" * 50)
@@ -3412,6 +3436,11 @@ def _execute_apply_steps(
3412
3436
  # the lock so that planning uses fresh state, not a stale snapshot
3413
3437
  # taken before the lock was held.
3414
3438
  _refresh_context_after_lock(ctx)
3439
+ # The embed set can change under the lock, so a set derived before it
3440
+ # would no longer cover exactly the modules being embedded. Re-derive
3441
+ # rather than re-validating a stale map.
3442
+ if split_refs_from_branches:
3443
+ split_ref_overrides = _expand_split_refs_from_branches(ctx)
3415
3444
  _validate_split_ref_override_coverage(
3416
3445
  ctx,
3417
3446
  split_ref_overrides,
@@ -3589,11 +3618,14 @@ def _execute_apply_steps_locked(
3589
3618
  # the full post-embed module set before continuing to wiring, dependency
3590
3619
  # sync, or migration steps.
3591
3620
  if embedded_modules:
3592
- post_embed_manifests = _load_module_manifests(
3593
- ctx.output_path,
3594
- list(ctx.qs_config.modules.keys()),
3595
- strict=True,
3596
- )
3621
+ try:
3622
+ post_embed_manifests = _load_module_manifests(
3623
+ ctx.output_path,
3624
+ list(ctx.qs_config.modules.keys()),
3625
+ strict=True,
3626
+ )
3627
+ except ManifestError as error:
3628
+ _abort_for_manifest_error(error, command_name="apply")
3597
3629
  try:
3598
3630
  check_required_module_versions(post_embed_manifests)
3599
3631
  except ManifestError as error:
@@ -3973,6 +4005,14 @@ def _resolve_apply_preflight(config_path: Path) -> Path:
3973
4005
  multiple=True,
3974
4006
  help="Use an explicit split ref for each module being embedded (repeatable).",
3975
4007
  )
4008
+ @click.option(
4009
+ "--split-refs-from-branches",
4010
+ is_flag=True,
4011
+ help=(
4012
+ "Embed every module from its own splits/<module>-module branch. "
4013
+ "Bulk form of --split-ref for pre-seal release verification."
4014
+ ),
4015
+ )
3976
4016
  @click.option(
3977
4017
  "--verbose-docker",
3978
4018
  is_flag=True,
@@ -3984,6 +4024,7 @@ def apply(
3984
4024
  no_docker: bool,
3985
4025
  no_modules: bool,
3986
4026
  split_refs: tuple[str, ...],
4027
+ split_refs_from_branches: bool,
3987
4028
  verbose_docker: bool,
3988
4029
  ) -> None:
3989
4030
  """
@@ -4026,12 +4067,18 @@ def apply(
4026
4067
  15. Finalize authoritative state
4027
4068
  16. Display next steps
4028
4069
  """
4070
+ if split_refs and split_refs_from_branches:
4071
+ raise click.UsageError(
4072
+ "--split-refs-from-branches cannot be combined with --split-ref"
4073
+ )
4029
4074
  split_ref_overrides = _parse_split_ref_overrides(split_refs)
4030
4075
  config_path = Path(config)
4031
4076
  _resolve_apply_preflight(config_path)
4032
4077
 
4033
4078
  # Prepare context
4034
4079
  ctx = _prepare_apply_context(config_path)
4080
+ if split_refs_from_branches:
4081
+ split_ref_overrides = _expand_split_refs_from_branches(ctx)
4035
4082
  _validate_split_ref_override_coverage(
4036
4083
  ctx,
4037
4084
  split_ref_overrides,
@@ -4071,4 +4118,5 @@ def apply(
4071
4118
  no_modules,
4072
4119
  show_docker_output,
4073
4120
  split_ref_overrides,
4121
+ split_refs_from_branches=split_refs_from_branches,
4074
4122
  )
@@ -1,5 +1,7 @@
1
1
  """Deployment commands for production platforms."""
2
2
 
3
+ import json
4
+ import secrets
3
5
  import sys
4
6
  from pathlib import Path
5
7
 
@@ -32,6 +34,23 @@ from quickscale_cli.utils.railway_utils import (
32
34
  )
33
35
 
34
36
 
37
+ RUNTIME_DB_ROLE = "quickscale_runtime"
38
+
39
+
40
+ def runtime_database_url(postgres_service: str, password: str) -> str:
41
+ """Build RUNTIME_DATABASE_URL for the restricted runtime role.
42
+
43
+ Host, port and database are Railway reference variables on the PostgreSQL
44
+ service, resolved by Railway at deploy time. The generated start.sh creates
45
+ the role from RUNTIME_DB_ROLE and RUNTIME_DB_PASSWORD before migrating.
46
+ """
47
+ ref = postgres_service
48
+ return (
49
+ f"postgresql://{RUNTIME_DB_ROLE}:{password}"
50
+ f"@${{{{{ref}.PGHOST}}}}:${{{{{ref}.PGPORT}}}}/${{{{{ref}.PGDATABASE}}}}"
51
+ )
52
+
53
+
35
54
  @click.group()
36
55
  def deploy() -> None:
37
56
  """Deployment commands for production platforms."""
@@ -225,13 +244,30 @@ def _init_railway_project_step() -> None:
225
244
  click.echo("✅ Railway project already initialized")
226
245
 
227
246
 
228
- def _setup_postgres_step() -> None:
229
- """Set up PostgreSQL database service."""
247
+ def _list_railway_service_names() -> list[str]:
248
+ """Return the service names in the linked Railway environment.
249
+
250
+ Uses ``railway service list --json``. A bare ``railway service`` is the
251
+ deprecated interactive link prompt: once a project has services it waits
252
+ for a selection and only ends at the command timeout.
253
+ """
254
+ result = run_railway_command(["service", "list", "--json"], timeout=30)
255
+ if result.returncode != 0:
256
+ raise RuntimeError(
257
+ f"railway service list failed: {(result.stderr or result.stdout).strip()}"
258
+ )
259
+ return [str(service.get("name", "")) for service in json.loads(result.stdout)]
260
+
261
+
262
+ def _setup_postgres_step() -> str:
263
+ """Set up PostgreSQL database service and return its service name."""
230
264
  try:
231
- result = run_railway_command(["service"], timeout=10)
265
+ names = _list_railway_service_names()
232
266
 
233
- if "postgres" in result.stdout.lower():
267
+ existing = [name for name in names if "postgres" in name.lower()]
268
+ if existing:
234
269
  click.echo("✅ PostgreSQL service already exists")
270
+ return existing[0]
235
271
  else:
236
272
  click.echo("Adding PostgreSQL service...")
237
273
  result = run_railway_command(["add", "--database", "postgres"], timeout=30)
@@ -243,24 +279,31 @@ def _setup_postgres_step() -> None:
243
279
  click.secho("✅ PostgreSQL service added", fg="green")
244
280
  except Exception as e:
245
281
  click.secho(f"⚠️ Warning: Could not check database service: {e}", fg="yellow")
282
+ return "Postgres"
246
283
 
247
284
 
248
285
  def _create_app_service_step(app_service: str) -> None:
249
286
  """Create application service."""
250
287
  try:
251
- result = run_railway_command(["service"], timeout=10)
288
+ names = _list_railway_service_names()
252
289
 
253
- if app_service.lower() in result.stdout.lower():
290
+ if app_service.lower() in (name.lower() for name in names):
254
291
  click.echo(f"✅ Service '{app_service}' already exists")
255
292
  else:
293
+ # Railway CLI 5.x prompts ("What do you need?", "Enter a variable")
294
+ # whenever it sees a TTY, even with --service, and its TUI leaves the
295
+ # terminal in raw mode on timeout. Run without a TTY so it never prompts.
256
296
  result = run_railway_command(
257
- ["add", "--service", app_service], timeout=30, interactive=True
297
+ ["add", "--service", app_service], timeout=60, input_data=""
258
298
  )
259
299
 
260
300
  if result.returncode != 0:
261
301
  click.secho(
262
302
  f"⚠️ Warning: Could not create service '{app_service}'", fg="yellow"
263
303
  )
304
+ detail = (result.stderr or result.stdout or "").strip()
305
+ if detail:
306
+ click.echo(f" {detail}", err=True)
264
307
  click.echo("💡 Create manually: railway add --service", err=True)
265
308
  else:
266
309
  click.secho(f"✅ Service '{app_service}' created", fg="green")
@@ -283,11 +326,11 @@ def _link_database_step(app_service: str) -> bool:
283
326
  click.secho(f"⚠️ Warning: {link_message}", fg="yellow")
284
327
  click.echo("💡 You may need to link DATABASE_URL manually:")
285
328
  click.echo(
286
- f" railway variables --set 'DATABASE_URL=${{{{Postgres.DATABASE_URL}}}}' "
329
+ f" railway variable set 'DATABASE_URL=${{{{Postgres.DATABASE_URL}}}}' "
287
330
  f"--service {app_service}"
288
331
  )
289
332
  click.echo(
290
- f" railway variables --set 'DATABASE_URL=${{{{PostgreSQL.DATABASE_URL}}}}' "
333
+ f" railway variable set 'DATABASE_URL=${{{{PostgreSQL.DATABASE_URL}}}}' "
291
334
  f"--service {app_service}"
292
335
  )
293
336
  click.echo(" Use single quotes to prevent shell expansion errors")
@@ -311,12 +354,46 @@ def _generate_domain_step(app_service: str) -> str | None:
311
354
  else:
312
355
  click.secho("⚠️ Warning: Could not auto-generate domain", fg="yellow")
313
356
  click.echo("💡 Generate manually: railway domain")
314
- click.echo("💡 Then set: railway variables --set ALLOWED_HOSTS=<your-domain>")
357
+ click.echo("💡 Then set: railway variable set ALLOWED_HOSTS=<your-domain>")
315
358
  return None
316
359
 
317
360
 
318
- def _configure_env_vars_step(app_service: str, domain_name: str | None) -> None:
319
- """Configure environment variables in batch."""
361
+ def _existing_service_variables(app_service: str) -> dict[str, str]:
362
+ """Return the app service's current Railway variables, or {} if unreadable."""
363
+ try:
364
+ return get_railway_variables(app_service) or {}
365
+ except Exception:
366
+ return {}
367
+
368
+
369
+ def _runtime_role_variables(
370
+ postgres_service: str, existing: dict[str, str]
371
+ ) -> dict[str, str]:
372
+ """Return the runtime-role variables to set, honouring what already exists.
373
+
374
+ - ``RUNTIME_DATABASE_URL`` set without ``RUNTIME_DB_PASSWORD``: the role is
375
+ operator-managed, so nothing is written.
376
+ - ``RUNTIME_DB_PASSWORD`` already set: it is reused, so a re-deploy never
377
+ rotates the password underneath the container still serving traffic.
378
+ - Otherwise a new URL-safe (hex) password is generated.
379
+ """
380
+ if existing.get("RUNTIME_DATABASE_URL") and not existing.get("RUNTIME_DB_PASSWORD"):
381
+ return {}
382
+ password = existing.get("RUNTIME_DB_PASSWORD") or secrets.token_hex(24)
383
+ return {
384
+ "RUNTIME_DB_ROLE": RUNTIME_DB_ROLE,
385
+ "RUNTIME_DB_PASSWORD": password,
386
+ "RUNTIME_DATABASE_URL": runtime_database_url(postgres_service, password),
387
+ }
388
+
389
+
390
+ def _configure_env_vars_step(
391
+ app_service: str,
392
+ domain_name: str | None,
393
+ postgres_service: str = "Postgres",
394
+ existing_vars: dict[str, str] | None = None,
395
+ ) -> None:
396
+ """Configure environment variables in batch, including the runtime DB role."""
320
397
  click.echo(f"Setting variables for service: {app_service}")
321
398
  click.echo("💡 Setting all variables at once to minimize deployments")
322
399
 
@@ -329,6 +406,9 @@ def _configure_env_vars_step(app_service: str, domain_name: str | None) -> None:
329
406
  "DJANGO_SETTINGS_MODULE": f"{django_package}.settings.production",
330
407
  }
331
408
 
409
+ runtime_vars = _runtime_role_variables(postgres_service, existing_vars or {})
410
+ env_vars.update(runtime_vars)
411
+
332
412
  if domain_name:
333
413
  env_vars["ALLOWED_HOSTS"] = domain_name
334
414
 
@@ -337,10 +417,16 @@ def _configure_env_vars_step(app_service: str, domain_name: str | None) -> None:
337
417
  if success:
338
418
  click.secho("✅ All environment variables configured successfully", fg="green")
339
419
  for key in env_vars:
340
- if key == "SECRET_KEY":
420
+ if key in ("SECRET_KEY", "RUNTIME_DB_PASSWORD"):
341
421
  click.echo(f" • {key}=<generated>")
422
+ elif key == "RUNTIME_DATABASE_URL":
423
+ click.echo(f" • {key}=<{RUNTIME_DB_ROLE} @ {postgres_service}>")
342
424
  else:
343
425
  click.echo(f" • {key}={env_vars[key]}")
426
+ if not runtime_vars:
427
+ click.echo(
428
+ " • RUNTIME_DATABASE_URL: operator-managed (RUNTIME_DB_PASSWORD unset), left unchanged"
429
+ )
344
430
  click.echo(
345
431
  "💡 Variables are set without triggering a deployment. The deploy step below will start one."
346
432
  )
@@ -487,7 +573,7 @@ def _display_summary(
487
573
  "dashboard — start.sh will create it on next deploy"
488
574
  )
489
575
  click.echo(
490
- f" Or manually: railway run --service {app_service} "
576
+ f" Or manually: railway ssh --service {app_service} "
491
577
  f"python manage.py createsuperuser"
492
578
  )
493
579
  click.echo(" 5. Configure custom domain (optional): railway domain")
@@ -497,11 +583,15 @@ def _display_summary(
497
583
  click.echo(" • Verify DATABASE_URL in Railway dashboard > Variables")
498
584
  if not database_linked:
499
585
  click.echo(
500
- " • Link DB manually: railway variables --set "
586
+ " • Link DB manually: railway variable set "
501
587
  f"'DATABASE_URL=${{{{Postgres.DATABASE_URL}}}}' --service {app_service}"
502
588
  )
503
589
  click.echo(" • Check healthcheck status in Railway dashboard")
504
590
  click.echo(" • Migrations run automatically on first deploy")
591
+ click.echo(
592
+ f" • start.sh creates the restricted '{RUNTIME_DB_ROLE}' database role "
593
+ "before migrating"
594
+ )
505
595
 
506
596
  click.echo("\n📖 Documentation:")
507
597
  click.echo(" • Railway: https://docs.railway.app")
@@ -552,7 +642,7 @@ def railway(project_name: str | None) -> None:
552
642
 
553
643
  # Step 4: Check for PostgreSQL service
554
644
  click.echo("\n🗄️ Setting up PostgreSQL database...")
555
- _setup_postgres_step()
645
+ postgres_service = _setup_postgres_step()
556
646
 
557
647
  # Step 5: Create app service
558
648
  click.echo(f"\n📦 Creating application service: {app_service}...")
@@ -569,7 +659,12 @@ def railway(project_name: str | None) -> None:
569
659
 
570
660
  # Step 8: Configure environment variables in batch
571
661
  click.echo("\n⚙️ Configuring environment variables...")
572
- _configure_env_vars_step(app_service, domain_name)
662
+ _configure_env_vars_step(
663
+ app_service,
664
+ domain_name,
665
+ postgres_service,
666
+ _existing_service_variables(app_service),
667
+ )
573
668
 
574
669
  # Step 9: Deploy to app service
575
670
  click.echo("\n🚢 Deploying application...")