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.
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/PKG-INFO +5 -5
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/README.md +4 -4
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/pyproject.toml +1 -1
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/__init__.py +1 -1
- xgic_payload_cms_cli-0.2.4/src/xgic/cli/payload/commands/dev.py +215 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/reset.py +1 -1
- xgic_payload_cms_cli-0.2.4/src/xgic/cli/payload/env_helpers.py +335 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/plugin.py +2 -2
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/project.py +148 -81
- xgic_payload_cms_cli-0.2.2/src/xgic/cli/payload/commands/dev.py +0 -137
- xgic_payload_cms_cli-0.2.2/src/xgic/cli/payload/env_helpers.py +0 -91
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/.gitignore +0 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/LICENSE +0 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/NOTICE +0 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/__init__.py +0 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/payload_env.py +0 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/schema.py +0 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/setup.py +0 -0
- {xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/config.py +0 -0
- {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.
|
|
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.
|
|
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` (
|
|
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` |
|
|
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.
|
|
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` (
|
|
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` |
|
|
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.
|
|
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" }
|
|
@@ -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
|
{xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/reset.py
RENAMED
|
@@ -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 .
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
{xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/schema.py
RENAMED
|
File without changes
|
{xgic_payload_cms_cli-0.2.2 → xgic_payload_cms_cli-0.2.4}/src/xgic/cli/payload/commands/setup.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|