xgic-payload-cms-cli 0.2.2__tar.gz → 0.2.4__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 (20) hide show
  1. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/PKG-INFO +5 -5
  2. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/README.md +4 -4
  3. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/pyproject.toml +1 -1
  4. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/__init__.py +1 -1
  5. xgic_payload_cms_cli-0.2.4/src/xgic/cli/payload/commands/dev.py +215 -0
  6. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/reset.py +1 -1
  7. xgic_payload_cms_cli-0.2.4/src/xgic/cli/payload/env_helpers.py +335 -0
  8. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/plugin.py +2 -2
  9. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/project.py +148 -81
  10. xgic_payload_cms_cli-0.2.2/src/xgic/cli/payload/commands/dev.py +0 -137
  11. xgic_payload_cms_cli-0.2.2/src/xgic/cli/payload/env_helpers.py +0 -91
  12. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/.gitignore +0 -0
  13. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/LICENSE +0 -0
  14. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/NOTICE +0 -0
  15. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/__init__.py +0 -0
  16. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/payload_env.py +0 -0
  17. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/schema.py +0 -0
  18. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/setup.py +0 -0
  19. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/config.py +0 -0
  20. {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/layout.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: xgic-payload-cms-cli
3
- Version: 0.2.2
3
+ Version: 0.2.4
4
4
  Summary: XGIC CLI Payload CMS module - product commands and helpers (xgic.cli.payload).
5
5
  Project-URL: Homepage, https://github.com/xgic/payload-cms-cli
6
6
  Project-URL: Repository, https://github.com/xgic/payload-cms-cli
@@ -90,7 +90,7 @@ One brand (`xgic`), clear ownership, and dry-run-friendly destructive paths.
90
90
  uv pip install \
91
91
  "xgic-cli>=0.2.0,<0.3" \
92
92
  "xgic-dev-cli>=0.2.0,<0.3" \
93
- "xgic-payload-cms-cli>=0.2.2,<0.3"
93
+ "xgic-payload-cms-cli>=0.2.4,<0.3"
94
94
  xgic payload --help
95
95
  ```
96
96
 
@@ -118,7 +118,7 @@ xgic payload dev
118
118
  xgic payload dev
119
119
  ```
120
120
 
121
- Optional: `xgic payload env` (status), `xgic payload env --regenerate --yes` (rotate credentials only — not required for first setup).
121
+ Optional: `xgic payload env` (status), `xgic payload env --regenerate --yes` (rewrite credentials in `.devcontainer/.env` **and** sync the app `.env` — not required for first setup). Regenerate does **not** delete Compose DB volume data; it also does **not** change the password already stored in that volume—recreate the volume (`xgic payload reset --yes`, destructive) or update the DB role to match. Non-destructive live rotation is tracked separately.
122
122
 
123
123
  Prefer the end-user template for application work: [payload-cms](https://github.com/xgic/payload-cms).
124
124
 
@@ -131,9 +131,9 @@ All product commands nest under **`xgic payload`**:
131
131
  | Command | Purpose |
132
132
  |---------|---------|
133
133
  | `xgic payload dev` | **Primary daily command** — smart start (up if needed, DB check, `pnpm dev`) |
134
- | `xgic payload setup [--quiet]` | **First-run one-shot:** env (if missing) + DB profile + scaffold (idempotent) |
134
+ | `xgic payload setup [--quiet]` | **First-run one-shot:** env (if missing) + DB profile + scaffold (idempotent). `pnpx` is invoked with `--allow-build=@swc/core` (and other native packages) so pnpm 10+ can run generator install scripts. |
135
135
  | `xgic payload env [--json]` | Product env status (project name, `.env`, services) |
136
- | `xgic payload env --regenerate --yes` | Rotate credentials in `.devcontainer/.env` (optional; setup creates env when absent) |
136
+ | `xgic payload env --regenerate --yes` | Rewrite `.devcontainer/.env` (`DATABASE_URL`) and sync the app `.env` (optional; setup creates env when absent). Does **not** delete DB volume data; does **not** update the password inside an existing volume |
137
137
  | `xgic payload schema` | Run template schema generator when present |
138
138
  | `xgic payload reset` | Fast targeted reset (project folder + active DB volume) |
139
139
 
@@ -55,7 +55,7 @@ One brand (`xgic`), clear ownership, and dry-run-friendly destructive paths.
55
55
  uv pip install \
56
56
  "xgic-cli>=0.2.0,<0.3" \
57
57
  "xgic-dev-cli>=0.2.0,<0.3" \
58
- "xgic-payload-cms-cli>=0.2.2,<0.3"
58
+ "xgic-payload-cms-cli>=0.2.4,<0.3"
59
59
  xgic payload --help
60
60
  ```
61
61
 
@@ -83,7 +83,7 @@ xgic payload dev
83
83
  xgic payload dev
84
84
  ```
85
85
 
86
- Optional: `xgic payload env` (status), `xgic payload env --regenerate --yes` (rotate credentials only — not required for first setup).
86
+ Optional: `xgic payload env` (status), `xgic payload env --regenerate --yes` (rewrite credentials in `.devcontainer/.env` **and** sync the app `.env` — not required for first setup). Regenerate does **not** delete Compose DB volume data; it also does **not** change the password already stored in that volume—recreate the volume (`xgic payload reset --yes`, destructive) or update the DB role to match. Non-destructive live rotation is tracked separately.
87
87
 
88
88
  Prefer the end-user template for application work: [payload-cms](https://github.com/xgic/payload-cms).
89
89
 
@@ -96,9 +96,9 @@ All product commands nest under **`xgic payload`**:
96
96
  | Command | Purpose |
97
97
  |---------|---------|
98
98
  | `xgic payload dev` | **Primary daily command** — smart start (up if needed, DB check, `pnpm dev`) |
99
- | `xgic payload setup [--quiet]` | **First-run one-shot:** env (if missing) + DB profile + scaffold (idempotent) |
99
+ | `xgic payload setup [--quiet]` | **First-run one-shot:** env (if missing) + DB profile + scaffold (idempotent). `pnpx` is invoked with `--allow-build=@swc/core` (and other native packages) so pnpm 10+ can run generator install scripts. |
100
100
  | `xgic payload env [--json]` | Product env status (project name, `.env`, services) |
101
- | `xgic payload env --regenerate --yes` | Rotate credentials in `.devcontainer/.env` (optional; setup creates env when absent) |
101
+ | `xgic payload env --regenerate --yes` | Rewrite `.devcontainer/.env` (`DATABASE_URL`) and sync the app `.env` (optional; setup creates env when absent). Does **not** delete DB volume data; does **not** update the password inside an existing volume |
102
102
  | `xgic payload schema` | Run template schema generator when present |
103
103
  | `xgic payload reset` | Fast targeted reset (project folder + active DB volume) |
104
104
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "xgic-payload-cms-cli"
7
- version = "0.2.2"
7
+ version = "0.2.4"
8
8
  description = "XGIC CLI Payload CMS module - product commands and helpers (xgic.cli.payload)."
9
9
  readme = "README.md"
10
10
  license = { text = "Apache-2.0" }
@@ -21,7 +21,7 @@ from xgic.cli.payload.project import (
21
21
  load_create_payload_config,
22
22
  )
23
23
 
24
- __version__ = "0.2.2"
24
+ __version__ = "0.2.4"
25
25
 
26
26
  __all__ = [
27
27
  "DEFAULT_COMPOSE_PROJECT",
@@ -0,0 +1,215 @@
1
+ """``xgic payload dev`` — smart Payload CMS development server start."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import subprocess
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from xgic.cli.app import CommandContext
12
+ from xgic.cli.core.environment import EnvironmentType
13
+ from xgic.cli.payload.config import (
14
+ DEFAULT_PRIMARY_SERVICE,
15
+ db_ready,
16
+ get_compose_project_name,
17
+ get_db_profile,
18
+ make_payload_docker_controller,
19
+ )
20
+ from xgic.cli.payload.layout import display_project_location, resolve_project_dir
21
+ from xgic.cli.payload.project import (
22
+ ensure_db_services,
23
+ is_payload_project_complete,
24
+ load_create_payload_config,
25
+ )
26
+ from xgic.cli.utils.output import print_info, print_success, print_warning
27
+
28
+ # Exit codes commonly used when the user presses Ctrl+C / sends SIGINT.
29
+ _SIGINT_EXIT_CODES = {130, -signal.SIGINT, 128 + signal.SIGINT}
30
+
31
+
32
+ def _app_cwd(project_dir: Path) -> Path:
33
+ if project_dir == Path("."):
34
+ return Path.cwd()
35
+ return (Path.cwd() / project_dir).resolve()
36
+
37
+
38
+ def _terminate_pnpm(proc: subprocess.Popen[bytes]) -> None:
39
+ if proc.poll() is not None:
40
+ return
41
+ try:
42
+ if sys.platform == "win32":
43
+ proc.terminate()
44
+ else:
45
+ proc.send_signal(signal.SIGINT)
46
+ except OSError:
47
+ pass
48
+ try:
49
+ proc.wait(timeout=15)
50
+ except subprocess.TimeoutExpired:
51
+ proc.kill()
52
+ proc.wait(timeout=5)
53
+
54
+
55
+ def _report_pnpm_exit(returncode: int, app_cwd: Path) -> int:
56
+ try:
57
+ rel = Path(os.path.relpath(app_cwd, Path.cwd()))
58
+ except ValueError:
59
+ rel = app_cwd
60
+ project_hint = display_project_location(rel)
61
+
62
+ if returncode in _SIGINT_EXIT_CODES:
63
+ print_info("Development server stopped by user (Ctrl+C).")
64
+ return 0
65
+
66
+ if returncode == 0:
67
+ # Next.js often exits 0 on SIGTERM (child exitCode null -> || 0).
68
+ # That is not a healthy idle end for a long-running app dev server.
69
+ print_warning(
70
+ "The app dev child process exited with code 0 while under "
71
+ "`xgic payload dev`. For a long-running Next.js server that usually means "
72
+ "SIGTERM/SIGINT, a compile crash, or the terminal session ended "
73
+ "- not a normal idle shutdown."
74
+ )
75
+ print_info("Retry with: xgic payload dev")
76
+ print_info(
77
+ f"If it stops again while Compiling / (app: {project_hint}), "
78
+ "check DB connectivity, free memory, and (on bind-mounted "
79
+ "Windows workspaces) named volumes for node_modules/.next."
80
+ )
81
+ return 1
82
+
83
+ print_warning(
84
+ f"The app dev child process exited with code {returncode} "
85
+ "(launched by `xgic payload dev`)."
86
+ )
87
+ print_info("Retry with: xgic payload dev")
88
+ return returncode or 1
89
+
90
+
91
+ def _run_pnpm_dev(app_cwd: Path) -> int:
92
+ """Run pnpm dev in the foreground until it stops.
93
+
94
+ Do not install shell ``trap ... exit 0`` handlers: they made SIGTERM
95
+ stops look like a successful idle exit while the page was still
96
+ compiling / serving. Inherit stdio so the VS Code terminal stays attached.
97
+ """
98
+ print_info(f"Launching pnpm dev in {app_cwd}...")
99
+ print_info(
100
+ "First HTTP request compiles the website template "
101
+ "(cold Turbopack); later requests are much faster."
102
+ )
103
+ env = os.environ.copy()
104
+ if env.get("CI", "").lower() in {"1", "true", "yes"}:
105
+ print_warning(
106
+ "CI=true is set in this environment; unsetting for pnpm dev "
107
+ "so Next.js stays in interactive watch mode."
108
+ )
109
+ env["CI"] = ""
110
+
111
+ try:
112
+ proc = subprocess.Popen(
113
+ ["pnpm", "dev"],
114
+ cwd=str(app_cwd),
115
+ env=env,
116
+ stdin=sys.stdin,
117
+ stdout=sys.stdout,
118
+ stderr=sys.stderr,
119
+ )
120
+ except FileNotFoundError:
121
+ print_warning(
122
+ "pnpm not found. Run inside the Dev Container image "
123
+ "(or install pnpm on PATH)."
124
+ )
125
+ return 1
126
+
127
+ try:
128
+ returncode = proc.wait()
129
+ except KeyboardInterrupt:
130
+ print_info("Stopping development server (Ctrl+C)...")
131
+ _terminate_pnpm(proc)
132
+ print_info("Development server stopped by user (Ctrl+C).")
133
+ return 0
134
+
135
+ return _report_pnpm_exit(returncode, app_cwd)
136
+
137
+
138
+ def run_dev(ctx: CommandContext) -> int:
139
+ """Start DB if needed and launch the Payload CMS app dev server."""
140
+ env = ctx.env
141
+ cfg = load_create_payload_config()
142
+ project_dir = resolve_project_dir(cfg)
143
+
144
+ if not is_payload_project_complete(project_dir):
145
+ print_warning(
146
+ "Payload CMS app is not set up yet at "
147
+ f"{display_project_location(project_dir)}."
148
+ )
149
+ print_info("Run first: xgic payload env --regenerate --yes")
150
+ print_info("Then: xgic payload setup")
151
+ print_info("Then: xgic payload dev")
152
+ return 1
153
+
154
+ print_info("Starting Payload CMS development server...")
155
+ print_info(f"App location: {display_project_location(project_dir)}")
156
+
157
+ docker = make_payload_docker_controller(env)
158
+ docker.project_name = get_compose_project_name()
159
+ profile = get_db_profile()
160
+
161
+ # Never recreate the primary Dev Container service; only ensure DB.
162
+ if not db_ready(docker):
163
+ print_warning(
164
+ f"Database ({profile}) not ready. Starting DB service only..."
165
+ )
166
+ db_rc = ensure_db_services(quiet=False)
167
+ if db_rc != 0:
168
+ print_warning(
169
+ "Could not start database. Fix Compose/DB, then re-run "
170
+ "xgic payload dev."
171
+ )
172
+ return db_rc
173
+ else:
174
+ print_success("Database is ready")
175
+
176
+ app_cwd = _app_cwd(project_dir)
177
+ if not (app_cwd / "package.json").is_file():
178
+ print_warning(f"No package.json under {app_cwd}")
179
+ return 1
180
+
181
+ if env.env_type in (
182
+ EnvironmentType.DEV_CONTAINER,
183
+ EnvironmentType.GENERIC_CONTAINER,
184
+ ):
185
+ return _run_pnpm_dev(app_cwd)
186
+
187
+ # Host: try docker exec into primary service (optional path)
188
+ rel = (
189
+ "."
190
+ if project_dir == Path(".")
191
+ else project_dir.as_posix().strip("/")
192
+ )
193
+ try:
194
+ print_info(
195
+ f"Launching pnpm dev via container service "
196
+ f"{DEFAULT_PRIMARY_SERVICE!r}..."
197
+ )
198
+ docker.exec(
199
+ DEFAULT_PRIMARY_SERVICE,
200
+ "sh",
201
+ "-c",
202
+ f"cd /workspace/{rel} && exec pnpm dev",
203
+ check=False,
204
+ )
205
+ except Exception as e:
206
+ print_warning(f"Failed to launch pnpm dev from host: {e}")
207
+ print_info(
208
+ "Prefer: Dev Containers → Reopen in Container, then "
209
+ "xgic payload dev"
210
+ )
211
+ return 1
212
+
213
+ print_success("Environment ready for development.")
214
+ print_info("Environment context: " + env.describe())
215
+ return 0
@@ -97,7 +97,7 @@ def run_reset(ctx: CommandContext) -> int:
97
97
  print_info("Rotating credentials (--rotate-credentials)...")
98
98
  rc = perform_env_regenerate(yes=True)
99
99
  if rc == 0:
100
- print_success("Credentials rotated in .env.")
100
+ print_success("Credentials rotated in compose and app .env.")
101
101
  else:
102
102
  print_warning("Credential rotation had issues (check .env).")
103
103
 
@@ -0,0 +1,335 @@
1
+ """Payload CMS product env file generation (credentials + secrets)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import secrets
8
+ import subprocess
9
+ from pathlib import Path
10
+
11
+ from xgic.cli.payload.config import (
12
+ DEFAULT_CONFIG_FILE,
13
+ get_compose_project_name,
14
+ get_db_config,
15
+ get_db_profile,
16
+ )
17
+ from xgic.cli.utils.output import print_info, print_success, print_warning
18
+
19
+ ENV_FILE = Path(".devcontainer/.env")
20
+ DEFAULT_NEXT_PUBLIC_SERVER_URL = "http://localhost:3000"
21
+
22
+ _PLACEHOLDER_RE = re.compile(
23
+ r"^(?:YOUR_[A-Z0-9_]*|CHANGE_?ME|CHANGEME|XXX|TODO)?$",
24
+ re.IGNORECASE,
25
+ )
26
+ _STALE_VOLUME_WARNING = (
27
+ "Regenerate rewrites credential files only — it does NOT delete "
28
+ "Compose database volume data. It also does NOT update the password "
29
+ "already stored inside that volume, so the app may fail auth until "
30
+ "you either (1) recreate the volume (destructive: "
31
+ "xgic payload reset --yes) or (2) update the database role password "
32
+ "to match the new files (non-destructive; tracked as a separate "
33
+ "rotate feature)."
34
+ )
35
+
36
+
37
+ def generate_fresh_env_content(
38
+ *,
39
+ config_file: Path = DEFAULT_CONFIG_FILE,
40
+ ) -> str:
41
+ """Pure: return compose .env content with fresh secrets + db from config."""
42
+ db_name, db_user = get_db_config(config_file)
43
+ payload_secret = secrets.token_hex(32)
44
+ adapter = get_db_profile(config_file)
45
+
46
+ if adapter == "mongodb":
47
+ mongo_pass = secrets.token_hex(16)
48
+ return f"""MONGO_INITDB_ROOT_USERNAME={db_user}
49
+ MONGO_INITDB_ROOT_PASSWORD={mongo_pass}
50
+ MONGO_INITDB_DATABASE={db_name}
51
+ PAYLOAD_SECRET={payload_secret}
52
+ DATABASE_URL=mongodb://{db_user}:{mongo_pass}@mongodb:27017/{db_name}?authSource=admin
53
+ """
54
+
55
+ pg_pass = secrets.token_hex(16)
56
+ return f"""POSTGRES_USER={db_user}
57
+ POSTGRES_PASSWORD={pg_pass}
58
+ POSTGRES_DB={db_name}
59
+ PAYLOAD_SECRET={payload_secret}
60
+ DATABASE_URL=postgres://{db_user}:{pg_pass}@postgres:5432/{db_name}
61
+ """
62
+
63
+
64
+ def parse_dotenv(content: str) -> dict[str, str]:
65
+ """Parse KEY=value assignments from .env text (last key wins)."""
66
+ values: dict[str, str] = {}
67
+ for line in content.splitlines():
68
+ stripped = line.strip()
69
+ if not stripped or stripped.startswith("#") or "=" not in stripped:
70
+ continue
71
+ key, _, value = stripped.partition("=")
72
+ key = key.strip()
73
+ if not key:
74
+ continue
75
+ values[key] = value.strip().strip('"').strip("'")
76
+ return values
77
+
78
+
79
+ def looks_like_placeholder(value: str) -> bool:
80
+ """Return True for empty values or YOUR_* / CHANGE_ME style placeholders."""
81
+ return _PLACEHOLDER_RE.fullmatch(value.strip().strip('"').strip("'")) is not None
82
+
83
+
84
+ def _set_env_key(content: str, key: str, value: str) -> str:
85
+ pattern = re.compile(rf"^{re.escape(key)}=.*$", re.MULTILINE)
86
+ replacement = f"{key}={value}"
87
+ if pattern.search(content):
88
+ return pattern.sub(replacement, content, count=1)
89
+ if content and not content.endswith("\n"):
90
+ content += "\n"
91
+ return content + f"{replacement}\n"
92
+
93
+
94
+ def _remove_env_key(content: str, key: str) -> str:
95
+ return re.sub(rf"^{re.escape(key)}=.*\r?\n?", "", content, flags=re.MULTILINE)
96
+
97
+
98
+ def compute_synced_project_env_content(
99
+ original_content: str,
100
+ live_db_uri: str,
101
+ live_payload_secret: str,
102
+ *,
103
+ cron_secret: str | None = None,
104
+ preview_secret: str | None = None,
105
+ next_public_server_url: str | None = None,
106
+ ) -> str:
107
+ """Return app .env content with live DATABASE_URL / secrets.
108
+
109
+ Writes Payload-canonical ``DATABASE_URL`` only (migrates a lone
110
+ ``DATABASE_URI``). Generates ``CRON_SECRET`` / ``PREVIEW_SECRET`` when
111
+ missing or still placeholders. Preserves other keys; sets
112
+ ``NEXT_PUBLIC_SERVER_URL`` when absent.
113
+ """
114
+ content = original_content
115
+ parsed = parse_dotenv(content)
116
+
117
+ if live_db_uri:
118
+ if "DATABASE_URL" in parsed:
119
+ content = _set_env_key(content, "DATABASE_URL", live_db_uri)
120
+ content = _remove_env_key(content, "DATABASE_URI")
121
+ elif "DATABASE_URI" in parsed:
122
+ content = re.sub(
123
+ r"^DATABASE_URI=.*$",
124
+ f"DATABASE_URL={live_db_uri}",
125
+ content,
126
+ count=1,
127
+ flags=re.MULTILINE,
128
+ )
129
+ content = _remove_env_key(content, "DATABASE_URI")
130
+ else:
131
+ content = _set_env_key(content, "DATABASE_URL", live_db_uri)
132
+
133
+ if live_payload_secret:
134
+ content = _set_env_key(content, "PAYLOAD_SECRET", live_payload_secret)
135
+
136
+ cron_val = parsed.get("CRON_SECRET")
137
+ if cron_val is None or looks_like_placeholder(cron_val):
138
+ content = _set_env_key(
139
+ content, "CRON_SECRET", cron_secret or secrets.token_hex(32)
140
+ )
141
+
142
+ preview_val = parsed.get("PREVIEW_SECRET")
143
+ if preview_val is None or looks_like_placeholder(preview_val):
144
+ content = _set_env_key(
145
+ content, "PREVIEW_SECRET", preview_secret or secrets.token_hex(32)
146
+ )
147
+
148
+ if "NEXT_PUBLIC_SERVER_URL" not in parsed:
149
+ content = _set_env_key(
150
+ content,
151
+ "NEXT_PUBLIC_SERVER_URL",
152
+ next_public_server_url or DEFAULT_NEXT_PUBLIC_SERVER_URL,
153
+ )
154
+
155
+ return content
156
+
157
+
158
+ def load_live_env_from_devcontainer(
159
+ env_file: Path = Path(".devcontainer/.env"),
160
+ ) -> tuple[str, str]:
161
+ """Load DATABASE_URL and PAYLOAD_SECRET from process env or compose .env."""
162
+ db_url = os.environ.get("DATABASE_URL") or os.environ.get("DATABASE_URI", "")
163
+ secret = os.environ.get("PAYLOAD_SECRET", "")
164
+ if (db_url and secret) or not env_file.is_file():
165
+ return db_url, secret
166
+ try:
167
+ parsed = parse_dotenv(env_file.read_text(encoding="utf-8"))
168
+ except OSError:
169
+ return db_url, secret
170
+ if not db_url:
171
+ db_url = parsed.get("DATABASE_URL") or parsed.get("DATABASE_URI", "")
172
+ if not secret:
173
+ secret = parsed.get("PAYLOAD_SECRET", "")
174
+ return db_url, secret
175
+
176
+
177
+ def sync_live_env_into_project(
178
+ project_dir: Path,
179
+ live_db_uri: str,
180
+ live_payload_secret: str,
181
+ *,
182
+ env_path: Path | None = None,
183
+ create_if_missing: bool = False,
184
+ ) -> Path | None:
185
+ """Best-effort sync of live credentials into the generated project's .env.
186
+
187
+ Returns the path written (or already matching), or None when skipped.
188
+ """
189
+ if env_path is None:
190
+ base = Path.cwd() if project_dir == Path(".") else project_dir
191
+ env_path = base / ".env"
192
+ if not env_path.is_file() and not create_if_missing:
193
+ return None
194
+
195
+ try:
196
+ content = env_path.read_text(encoding="utf-8") if env_path.is_file() else ""
197
+ new_content = compute_synced_project_env_content(
198
+ content, live_db_uri, live_payload_secret
199
+ )
200
+ if new_content != content:
201
+ env_path.parent.mkdir(parents=True, exist_ok=True)
202
+ env_path.write_text(new_content, encoding="utf-8")
203
+ return env_path
204
+ except Exception:
205
+ return None
206
+
207
+
208
+ def compose_db_volume_name(*, config_file: Path = DEFAULT_CONFIG_FILE) -> str:
209
+ """Return the conventional Compose DB volume name for this workspace."""
210
+ return f"{get_compose_project_name(config_file)}-{get_db_profile(config_file)}-data"
211
+
212
+
213
+ def compose_db_volume_exists(*, config_file: Path = DEFAULT_CONFIG_FILE) -> bool:
214
+ """Return True when ``docker volume inspect`` finds the Compose DB volume."""
215
+ name = compose_db_volume_name(config_file=config_file)
216
+ try:
217
+ result = subprocess.run(
218
+ ["docker", "volume", "inspect", name],
219
+ check=False,
220
+ capture_output=True,
221
+ text=True,
222
+ timeout=5,
223
+ )
224
+ return result.returncode == 0
225
+ except Exception:
226
+ return False
227
+
228
+
229
+ def _infer_workspace_root(config_file: Path, env_file: Path) -> Path:
230
+ for candidate in (config_file, env_file):
231
+ path = candidate if candidate.is_absolute() else Path.cwd() / candidate
232
+ if path.parent.name == ".devcontainer":
233
+ return path.parent.parent
234
+ return Path.cwd()
235
+
236
+
237
+ def _resolve_app_env_path(config_file: Path, env_file: Path) -> Path:
238
+ from xgic.cli.payload.layout import resolve_project_dir
239
+ from xgic.cli.payload.project import load_create_payload_config
240
+
241
+ root = _infer_workspace_root(config_file, env_file)
242
+ rel = resolve_project_dir(load_create_payload_config(config_file), root=root)
243
+ if rel == Path(".") or str(rel) in (".", ""):
244
+ return root / ".env"
245
+ return root / rel / ".env"
246
+
247
+
248
+ def _warn_stale_compose_db_volume(
249
+ *,
250
+ config_file: Path,
251
+ env_existed: bool,
252
+ planned: bool = False,
253
+ ) -> None:
254
+ """Warn when file rewrite cannot fix a volume initialized with the old password."""
255
+ exists = compose_db_volume_exists(config_file=config_file)
256
+ if not exists and not env_existed:
257
+ return
258
+ prefix = "Dry run: " if planned else ""
259
+ if exists:
260
+ name = compose_db_volume_name(config_file=config_file)
261
+ print_warning(
262
+ f"{prefix}Detected existing Compose DB volume {name!r}. "
263
+ "Data in that volume is kept, but it may still hold the "
264
+ "previous password (connectivity can break until reset or "
265
+ "an in-place role password update)."
266
+ )
267
+ print_warning(f"{prefix}{_STALE_VOLUME_WARNING}")
268
+
269
+
270
+ def perform_env_regenerate(
271
+ *,
272
+ dry_run: bool = False,
273
+ yes: bool = False,
274
+ env_file: Path = ENV_FILE,
275
+ config_file: Path = DEFAULT_CONFIG_FILE,
276
+ ) -> int:
277
+ """Regenerate compose .env and sync the Payload app .env (guarded)."""
278
+ from xgic.cli.payload.project import is_payload_project_complete
279
+
280
+ content = generate_fresh_env_content(config_file=config_file)
281
+ env_existed = env_file.is_file()
282
+ app_env = _resolve_app_env_path(config_file, env_file)
283
+ would_sync = app_env.is_file() or is_payload_project_complete(app_env.parent)
284
+
285
+ if dry_run:
286
+ print_info("Dry run: would write fresh credentials to .env")
287
+ print_info(f" (content length: {len(content)} chars)")
288
+ print_info(f" compose env: {env_file}")
289
+ if would_sync:
290
+ print_info(f" would sync app env: {app_env}")
291
+ _warn_stale_compose_db_volume(
292
+ config_file=config_file,
293
+ env_existed=env_existed,
294
+ planned=True,
295
+ )
296
+ return 0
297
+
298
+ if not yes:
299
+ print_warning("This will overwrite .env with new random credentials.")
300
+ print_warning("Re-run with --yes to proceed.")
301
+ return 1
302
+
303
+ try:
304
+ env_file.parent.mkdir(parents=True, exist_ok=True)
305
+ env_file.write_text(content, encoding="utf-8")
306
+ except Exception as e:
307
+ print_warning(f"Failed to write {env_file}: {e}")
308
+ return 1
309
+
310
+ parsed = parse_dotenv(content)
311
+ live_db = parsed.get("DATABASE_URL") or parsed.get("DATABASE_URI", "")
312
+ live_secret = parsed.get("PAYLOAD_SECRET", "")
313
+ written = sync_live_env_into_project(
314
+ app_env.parent,
315
+ live_db,
316
+ live_secret,
317
+ env_path=app_env,
318
+ create_if_missing=is_payload_project_complete(app_env.parent),
319
+ )
320
+
321
+ db_name, db_user = get_db_config(config_file)
322
+ adapter = get_db_profile(config_file)
323
+ print_success(f"Generated fresh credentials in {env_file}")
324
+ if adapter == "mongodb":
325
+ print_info(f" (MONGO DB for {db_name})")
326
+ else:
327
+ print_info(f" (POSTGRES_DB={db_name}, POSTGRES_USER={db_user})")
328
+ if written is not None:
329
+ print_success(f"Synced credentials into {written}")
330
+ _warn_stale_compose_db_volume(
331
+ config_file=config_file,
332
+ env_existed=env_existed,
333
+ planned=False,
334
+ )
335
+ return 0
@@ -67,7 +67,7 @@ def register(
67
67
  penv.add_argument(
68
68
  "--regenerate",
69
69
  action="store_true",
70
- help="Write fresh credentials to .devcontainer/.env",
70
+ help="Write fresh credentials to .devcontainer/.env and sync the app .env",
71
71
  )
72
72
  penv.add_argument(
73
73
  "--yes",
@@ -116,6 +116,6 @@ def register(
116
116
  reset.add_argument(
117
117
  "--rotate-credentials",
118
118
  action="store_true",
119
- help="Also regenerate .devcontainer/.env credentials",
119
+ help="Also regenerate compose + app .env credentials",
120
120
  )
121
121
  reset.set_defaults(func=run_reset)
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  import contextlib
6
6
  import json
7
- import os
8
7
  import re
9
8
  import subprocess
10
9
  import time
@@ -19,6 +18,10 @@ from xgic.cli.payload.config import (
19
18
  get_db_profile,
20
19
  make_payload_docker_controller,
21
20
  )
21
+ from xgic.cli.payload.env_helpers import (
22
+ load_live_env_from_devcontainer,
23
+ sync_live_env_into_project,
24
+ )
22
25
  from xgic.cli.payload.layout import (
23
26
  create_payload_target_arg,
24
27
  display_project_location,
@@ -26,6 +29,20 @@ from xgic.cli.payload.layout import (
26
29
  )
27
30
  from xgic.cli.utils.output import print_info, print_success, print_warning
28
31
 
32
+ _sync_live_env_into_project = sync_live_env_into_project
33
+
34
+ # Native packages whose install scripts pnpm 10+ otherwise skips.
35
+ # @swc/core is required by create-payload-app itself; the rest match the
36
+ # website template allow-list plus Next.js optional native add-ons.
37
+ NATIVE_PNPM_BUILDS: tuple[str, ...] = (
38
+ "@swc/core",
39
+ "@parcel/watcher",
40
+ "esbuild",
41
+ "sharp",
42
+ "unrs-resolver",
43
+ "workerd",
44
+ )
45
+
29
46
 
30
47
  def load_create_payload_config(
31
48
  config_path: Path = DEFAULT_CONFIG_FILE,
@@ -76,6 +93,134 @@ def is_payload_project_complete(project_dir: Path) -> bool:
76
93
  return any(p.exists() for p in candidates)
77
94
 
78
95
 
96
+ def pnpx_allow_build_args(
97
+ packages: tuple[str, ...] = NATIVE_PNPM_BUILDS,
98
+ ) -> list[str]:
99
+ """Return ``pnpx --allow-build=…`` flags for native install scripts."""
100
+ return [f"--allow-build={name}" for name in packages]
101
+
102
+
103
+ def merge_package_json_only_built(
104
+ text: str, names: tuple[str, ...] = NATIVE_PNPM_BUILDS
105
+ ) -> str:
106
+ """Return package.json text with ``pnpm.onlyBuiltDependencies`` merged."""
107
+ data = json.loads(text)
108
+ pnpm = data.setdefault("pnpm", {})
109
+ existing = pnpm.get("onlyBuiltDependencies") or []
110
+ if not isinstance(existing, list):
111
+ existing = []
112
+ str_existing = [str(item) for item in existing]
113
+ merged = list(dict.fromkeys([*str_existing, *names]))
114
+ if merged == str_existing:
115
+ return text
116
+ pnpm["onlyBuiltDependencies"] = merged
117
+ return json.dumps(data, indent=2) + "\n"
118
+
119
+
120
+ def yaml_map_key(name: str) -> str:
121
+ """Quote YAML mapping keys that are not plain scalars (e.g. ``@swc/core``)."""
122
+ if not name or name[0] in "@`&*!|>%{}[]'\"" or any(
123
+ ch in name for ch in ":#"
124
+ ):
125
+ return json.dumps(name)
126
+ return name
127
+
128
+
129
+ def parse_yaml_map_key(raw: str) -> str:
130
+ """Strip optional YAML quotes from a mapping key token."""
131
+ raw = raw.strip()
132
+ if len(raw) >= 2 and raw[0] == raw[-1] and raw[0] in "\"'":
133
+ return raw[1:-1]
134
+ return raw
135
+
136
+
137
+ def _allow_builds_block(names: list[str]) -> list[str]:
138
+ return ["allowBuilds:"] + [f" {yaml_map_key(n)}: true" for n in names]
139
+
140
+
141
+ def merge_workspace_allow_builds(
142
+ text: str, names: tuple[str, ...] = NATIVE_PNPM_BUILDS
143
+ ) -> str:
144
+ """Return pnpm-workspace.yaml text with ``allowBuilds`` keys merged.
145
+
146
+ Avoids a YAML dependency: only handles the generated website-template
147
+ shape (a top-level ``allowBuilds:`` mapping of ``name: true``).
148
+ Always re-emits the block so unquoted ``@`` keys from 0.2.3 are repaired.
149
+ """
150
+ normalized = text if text.endswith("\n") or text == "" else text + "\n"
151
+ lines = normalized.splitlines()
152
+ start = next(
153
+ (i for i, ln in enumerate(lines) if ln.startswith("allowBuilds:")),
154
+ None,
155
+ )
156
+ if start is None:
157
+ extra = _allow_builds_block(list(names))
158
+ return normalized + "\n".join(extra) + "\n"
159
+
160
+ existing: list[str] = []
161
+ end = start + 1
162
+ while end < len(lines):
163
+ ln = lines[end]
164
+ if not ln.strip():
165
+ end += 1
166
+ continue
167
+ if ln.startswith(" ") or ln.startswith("\t"):
168
+ key_raw = ln.strip().split(":", 1)[0]
169
+ existing.append(parse_yaml_map_key(key_raw))
170
+ end += 1
171
+ continue
172
+ break
173
+ merged = list(dict.fromkeys([*existing, *names]))
174
+ block = _allow_builds_block(merged)
175
+ new_lines = lines[:start] + block + lines[end:]
176
+ return "\n".join(new_lines) + "\n"
177
+
178
+
179
+ def ensure_native_pnpm_builds(
180
+ project_dir: Path, *, quiet: bool = False
181
+ ) -> None:
182
+ """Allow-list native install scripts in a generated Payload CMS app."""
183
+ changed = False
184
+ workspace = project_dir / "pnpm-workspace.yaml"
185
+ package_json = project_dir / "package.json"
186
+ try:
187
+ if workspace.is_file():
188
+ old = workspace.read_text(encoding="utf-8")
189
+ new = merge_workspace_allow_builds(old)
190
+ if new != old:
191
+ workspace.write_text(new, encoding="utf-8")
192
+ changed = True
193
+ if package_json.is_file():
194
+ old = package_json.read_text(encoding="utf-8")
195
+ new = merge_package_json_only_built(old)
196
+ if new != old:
197
+ package_json.write_text(new, encoding="utf-8")
198
+ changed = True
199
+ except (OSError, json.JSONDecodeError) as e:
200
+ if not quiet:
201
+ print_warning(f"Could not update pnpm native allow-list: {e}")
202
+ return
203
+ if not changed:
204
+ return
205
+ if not quiet:
206
+ print_info(
207
+ "Allowing native pnpm install scripts "
208
+ f"({', '.join(NATIVE_PNPM_BUILDS)}) in "
209
+ f"{display_project_location(project_dir)}."
210
+ )
211
+ result = subprocess.run(
212
+ ["pnpm", "install"],
213
+ cwd=str(project_dir),
214
+ check=False,
215
+ )
216
+ if result.returncode != 0 and not quiet:
217
+ print_warning(
218
+ "pnpm install after native allow-list update exited "
219
+ f"{result.returncode}. Re-run inside the app directory if "
220
+ "install scripts were skipped."
221
+ )
222
+
223
+
79
224
  def build_create_payload_command(
80
225
  target: str,
81
226
  *,
@@ -87,6 +232,7 @@ def build_create_payload_command(
87
232
  """Return the argv list for a non-interactive create-payload-app run."""
88
233
  cmd = [
89
234
  "pnpx",
235
+ *pnpx_allow_build_args(),
90
236
  "create-payload-app@latest",
91
237
  target,
92
238
  "-t",
@@ -116,80 +262,6 @@ def resolve_db_connection_string(
116
262
  return live_db_uri or json_db_uri or None
117
263
 
118
264
 
119
- def load_live_env_from_devcontainer(
120
- env_file: Path = Path(".devcontainer/.env"),
121
- ) -> tuple[str, str]:
122
- """Load DATABASE_URI and PAYLOAD_SECRET from .devcontainer/.env if present."""
123
- db_uri = os.environ.get("DATABASE_URI", "")
124
- secret = os.environ.get("PAYLOAD_SECRET", "")
125
- if (db_uri and secret) or not env_file.is_file():
126
- return db_uri, secret
127
- try:
128
- for line in env_file.read_text(encoding="utf-8").splitlines():
129
- line = line.strip()
130
- if not line or line.startswith("#") or "=" not in line:
131
- continue
132
- key, _, value = line.partition("=")
133
- key = key.strip()
134
- value = value.strip().strip('"').strip("'")
135
- if key == "DATABASE_URI" and not db_uri:
136
- db_uri = value
137
- elif key == "PAYLOAD_SECRET" and not secret:
138
- secret = value
139
- except OSError:
140
- pass
141
- return db_uri, secret
142
-
143
-
144
- def compute_synced_project_env_content(
145
- original_content: str, live_db_uri: str, live_payload_secret: str
146
- ) -> str:
147
- """Return .env content with live DATABASE_URL / PAYLOAD_SECRET."""
148
- content = original_content
149
- if live_db_uri:
150
- content = re.sub(
151
- r"^DATABASE_URL=.*$",
152
- f"DATABASE_URL={live_db_uri}",
153
- content,
154
- flags=re.MULTILINE,
155
- )
156
- # Some templates use DATABASE_URI
157
- content = re.sub(
158
- r"^DATABASE_URI=.*$",
159
- f"DATABASE_URI={live_db_uri}",
160
- content,
161
- flags=re.MULTILINE,
162
- )
163
- if live_payload_secret:
164
- content = re.sub(
165
- r"^PAYLOAD_SECRET=.*$",
166
- f"PAYLOAD_SECRET={live_payload_secret}",
167
- content,
168
- flags=re.MULTILINE,
169
- )
170
- return content
171
-
172
-
173
- def _sync_live_env_into_project(
174
- project_dir: Path, live_db_uri: str, live_payload_secret: str
175
- ) -> None:
176
- """Best-effort sync of live credentials into the generated project's .env."""
177
- base = Path.cwd() if project_dir == Path(".") else project_dir
178
- gen_env = base / ".env"
179
- if not gen_env.is_file():
180
- return
181
-
182
- try:
183
- content = gen_env.read_text(encoding="utf-8")
184
- new_content = compute_synced_project_env_content(
185
- content, live_db_uri, live_payload_secret
186
- )
187
- if new_content != content:
188
- gen_env.write_text(new_content, encoding="utf-8")
189
- except Exception:
190
- pass
191
-
192
-
193
265
  def validate_compose_project_name(name: str) -> str:
194
266
  """Return a Docker Compose–safe project name or raise ValueError."""
195
267
  cleaned = name.strip().lower()
@@ -383,12 +455,6 @@ def ensure_payload_project(*, quiet: bool = False) -> int:
383
455
  check=False,
384
456
  capture_output=True,
385
457
  )
386
- with contextlib.suppress(Exception):
387
- subprocess.run(
388
- ["corepack", "pnpm", "approve-builds", "@swc/core"],
389
- check=False,
390
- capture_output=True,
391
- )
392
458
 
393
459
  cmd = build_create_payload_command(
394
460
  target,
@@ -442,5 +508,6 @@ def ensure_payload_project(*, quiet: bool = False) -> int:
442
508
  "Payload CMS project created successfully at "
443
509
  f"{display_project_location(project_dir)}."
444
510
  )
511
+ ensure_native_pnpm_builds(project_dir, quiet=quiet)
445
512
  _sync_live_env_into_project(project_dir, live_db_uri, live_secret)
446
513
  return 0
@@ -1,137 +0,0 @@
1
- """``xgic payload dev`` — smart Payload CMS development server start."""
2
-
3
- from __future__ import annotations
4
-
5
- import subprocess
6
- from pathlib import Path
7
-
8
- from xgic.cli.app import CommandContext
9
- from xgic.cli.core.environment import EnvironmentType
10
- from xgic.cli.payload.config import (
11
- DEFAULT_PRIMARY_SERVICE,
12
- db_ready,
13
- get_compose_project_name,
14
- get_db_profile,
15
- make_payload_docker_controller,
16
- )
17
- from xgic.cli.payload.layout import display_project_location, resolve_project_dir
18
- from xgic.cli.payload.project import (
19
- ensure_db_services,
20
- is_payload_project_complete,
21
- load_create_payload_config,
22
- )
23
- from xgic.cli.utils.output import print_info, print_success, print_warning
24
-
25
-
26
- def _app_cwd(project_dir: Path) -> Path:
27
- if project_dir == Path("."):
28
- return Path.cwd()
29
- return (Path.cwd() / project_dir).resolve()
30
-
31
-
32
- def run_dev(ctx: CommandContext) -> int:
33
- """Start DB if needed and launch the Payload CMS app dev server."""
34
- env = ctx.env
35
- cfg = load_create_payload_config()
36
- project_dir = resolve_project_dir(cfg)
37
-
38
- if not is_payload_project_complete(project_dir):
39
- print_warning(
40
- "Payload CMS app is not set up yet at "
41
- f"{display_project_location(project_dir)}."
42
- )
43
- print_info("Run first: xgic payload env --regenerate --yes")
44
- print_info("Then: xgic payload setup")
45
- print_info("Then: xgic payload dev")
46
- return 1
47
-
48
- print_info("Starting Payload CMS development server...")
49
- print_info(f"App location: {display_project_location(project_dir)}")
50
-
51
- docker = make_payload_docker_controller(env)
52
- docker.project_name = get_compose_project_name()
53
- profile = get_db_profile()
54
-
55
- # Never recreate the primary Dev Container service; only ensure DB.
56
- if not db_ready(docker):
57
- print_warning(
58
- f"Database ({profile}) not ready. Starting DB service only..."
59
- )
60
- db_rc = ensure_db_services(quiet=False)
61
- if db_rc != 0:
62
- print_warning(
63
- "Could not start database. Fix Compose/DB, then re-run "
64
- "xgic payload dev."
65
- )
66
- return db_rc
67
- else:
68
- print_success("Database is ready")
69
-
70
- app_cwd = _app_cwd(project_dir)
71
- if not (app_cwd / "package.json").is_file():
72
- print_warning(f"No package.json under {app_cwd}")
73
- return 1
74
-
75
- if env.env_type in (
76
- EnvironmentType.DEV_CONTAINER,
77
- EnvironmentType.GENERIC_CONTAINER,
78
- ):
79
- try:
80
- print_info(f"Launching pnpm dev in {app_cwd}...")
81
- result = subprocess.run(
82
- ["sh", "-c", 'trap "exit 0" INT TERM; exec pnpm dev'],
83
- cwd=str(app_cwd),
84
- check=False,
85
- )
86
- if result.returncode in (130, -2, 2):
87
- print_info("Development server stopped by user (Ctrl+C).")
88
- return 0
89
- if result.returncode != 0:
90
- print_warning(f"pnpm dev exited with code {result.returncode}.")
91
- print_info(
92
- f"Fallback: cd {display_project_location(project_dir)} "
93
- "&& pnpm dev"
94
- )
95
- return result.returncode or 1
96
- print_info("Development server exited cleanly.")
97
- return 0
98
- except KeyboardInterrupt:
99
- print_info("Development server stopped by user (Ctrl+C).")
100
- return 0
101
- except FileNotFoundError:
102
- print_warning(
103
- "pnpm not found. Run inside the Dev Container image "
104
- "(or install pnpm on PATH)."
105
- )
106
- return 1
107
-
108
- # Host: try docker exec into primary service (optional path)
109
- rel = (
110
- "."
111
- if project_dir == Path(".")
112
- else project_dir.as_posix().strip("/")
113
- )
114
- try:
115
- print_info(
116
- f"Launching pnpm dev via container service "
117
- f"{DEFAULT_PRIMARY_SERVICE!r}..."
118
- )
119
- docker.exec(
120
- DEFAULT_PRIMARY_SERVICE,
121
- "sh",
122
- "-c",
123
- f"cd /workspace/{rel} && "
124
- "sh -c 'trap \"exit 0\" INT TERM; exec pnpm dev'",
125
- check=False,
126
- )
127
- except Exception as e:
128
- print_warning(f"Failed to launch pnpm dev from host: {e}")
129
- print_info(
130
- "Prefer: Dev Containers → Reopen in Container, then "
131
- "xgic payload dev"
132
- )
133
- return 1
134
-
135
- print_success("Environment ready for development.")
136
- print_info("Environment context: " + env.describe())
137
- return 0
@@ -1,91 +0,0 @@
1
- """Payload CMS product env file generation (credentials + secrets)."""
2
-
3
- from __future__ import annotations
4
-
5
- import json
6
- import secrets
7
- from pathlib import Path
8
-
9
- from xgic.cli.payload.config import DEFAULT_CONFIG_FILE, get_db_config
10
- from xgic.cli.utils.output import print_info, print_success, print_warning
11
-
12
- ENV_FILE = Path(".devcontainer/.env")
13
-
14
-
15
- def generate_fresh_env_content(
16
- *,
17
- config_file: Path = DEFAULT_CONFIG_FILE,
18
- ) -> str:
19
- """Pure: return .env content with fresh secrets + db from config."""
20
- db_name, db_user = get_db_config(config_file)
21
- payload_secret = secrets.token_hex(32)
22
-
23
- adapter = "postgres"
24
- if config_file.exists():
25
- try:
26
- with config_file.open(encoding="utf-8") as f:
27
- cfg = json.load(f)
28
- adapter = str(cfg.get("dbAdapter", "postgres")).lower()
29
- except Exception:
30
- pass
31
-
32
- if adapter == "mongodb":
33
- mongo_pass = secrets.token_hex(16)
34
- return f"""MONGO_INITDB_ROOT_USERNAME={db_user}
35
- MONGO_INITDB_ROOT_PASSWORD={mongo_pass}
36
- MONGO_INITDB_DATABASE={db_name}
37
- PAYLOAD_SECRET={payload_secret}
38
- DATABASE_URI=mongodb://{db_user}:{mongo_pass}@mongodb:27017/{db_name}?authSource=admin
39
- """
40
-
41
- pg_pass = secrets.token_hex(16)
42
- return f"""POSTGRES_USER={db_user}
43
- POSTGRES_PASSWORD={pg_pass}
44
- POSTGRES_DB={db_name}
45
- PAYLOAD_SECRET={payload_secret}
46
- DATABASE_URI=postgres://{db_user}:{pg_pass}@postgres:5432/{db_name}
47
- """
48
-
49
-
50
- def perform_env_regenerate(
51
- *,
52
- dry_run: bool = False,
53
- yes: bool = False,
54
- env_file: Path = ENV_FILE,
55
- config_file: Path = DEFAULT_CONFIG_FILE,
56
- ) -> int:
57
- """Regenerate .env with fresh credentials (guarded by --yes / dry-run)."""
58
- if dry_run:
59
- content = generate_fresh_env_content(config_file=config_file)
60
- print_info("Dry run: would write fresh credentials to .env")
61
- print_info(f" (content length: {len(content)} chars)")
62
- return 0
63
-
64
- if not yes:
65
- print_warning("This will overwrite .env with new random credentials.")
66
- print_warning("Re-run with --yes to proceed.")
67
- return 1
68
-
69
- content = generate_fresh_env_content(config_file=config_file)
70
- try:
71
- env_file.parent.mkdir(parents=True, exist_ok=True)
72
- with env_file.open("w", encoding="utf-8") as f:
73
- f.write(content)
74
- db_name, db_user = get_db_config(config_file)
75
- adapter = "postgres"
76
- if config_file.exists():
77
- try:
78
- with config_file.open(encoding="utf-8") as f:
79
- cfg = json.load(f)
80
- adapter = str(cfg.get("dbAdapter", "postgres")).lower()
81
- except Exception:
82
- pass
83
- print_success(f"Generated fresh credentials in {env_file}")
84
- if adapter == "mongodb":
85
- print_info(f" (MONGO DB for {db_name})")
86
- else:
87
- print_info(f" (POSTGRES_DB={db_name}, POSTGRES_USER={db_user})")
88
- return 0
89
- except Exception as e:
90
- print_warning(f"Failed to write {env_file}: {e}")
91
- return 1