@topy-ai/maggie 0.7.47 → 0.7.49
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.
- package/README-zh-TW.md +2 -2
- package/README.md +76 -15
- package/bin/maggie.js +23 -6
- package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
- package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
- package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
- package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
- package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
- package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
- package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
- package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
- package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
- package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
- package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
- package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
- package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
- package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
- package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
- package/bundled-marketplace/README.md +17 -0
- package/bundled-marketplace/catalog.json +56 -7
- package/bundled-references/browser-inspection.md +25 -0
- package/bundled-references/google-integrations-runbook.md +54 -0
- package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
- package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
- package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
- package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
- package/bundled-references/maggiedash-booking/PRD.md +9 -1
- package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
- package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
- package/bundled-references/maggiedash-booking/README.md +23 -6
- package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
- package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
- package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
- package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
- package/bundled-references/maggiedash-booking/TASKS.md +44 -3
- package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
- package/bundled-references/provider-adapter-contract.md +33 -0
- package/bundled-skills/maggie-booking/SKILL.md +8 -0
- package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
- package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
- package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
- package/bundled-skills/maggie-deployment/SKILL.md +141 -7
- package/bundled-skills/maggie-deployment/references/vps.md +74 -1
- package/bundled-skills/maggie-design/SKILL.md +154 -0
- package/bundled-skills/maggie-feedback/SKILL.md +67 -1
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-ops/SKILL.md +37 -0
- package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
- package/bundled-skills/maggie-social-share/SKILL.md +56 -7
- package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
- package/bundled-skills/maggie-template/SKILL.md +57 -3
- package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
- package/bundled-tools/clis/maggie_booking.py +199 -26
- package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
- package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
- package/bundled-tools/clis/maggie_deployment.py +307 -18
- package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
- package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
- package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
- package/bundled-tools/clis/maggie_design.py +17 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_design_verify.py +359 -0
- package/bundled-tools/clis/maggie_docs.py +82 -4
- package/bundled-tools/clis/maggie_email_localization.py +382 -0
- package/bundled-tools/clis/maggie_feedback.py +115 -5
- package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
- package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
- package/bundled-tools/clis/maggie_localization.py +5 -0
- package/bundled-tools/clis/maggie_marketing_run.py +272 -0
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_notifications.py +528 -0
- package/bundled-tools/clis/maggie_ops.py +87 -0
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_schedule.py +12 -2
- package/bundled-tools/clis/maggie_service_import.py +520 -0
- package/bundled-tools/clis/maggie_social_queue.py +442 -0
- package/bundled-tools/clis/maggie_social_share.py +46 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/google_ads.py +306 -0
- package/bundled-tools/runtime/google_capabilities.py +18 -2
- package/bundled-tools/runtime/google_remediation.py +152 -0
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/google-integrations-runbook.md +54 -0
- package/references/provider-adapter-contract.md +33 -0
|
@@ -58,6 +58,8 @@ def job_args(job: dict, marketplace: Path, template_id: str) -> argparse.Namespa
|
|
|
58
58
|
values.setdefault("tag", [])
|
|
59
59
|
values.setdefault("site_url", None)
|
|
60
60
|
values.setdefault("sample", None)
|
|
61
|
+
values.setdefault("display_name", None)
|
|
62
|
+
values.setdefault("display_description", None)
|
|
61
63
|
return argparse.Namespace(**values)
|
|
62
64
|
|
|
63
65
|
|
|
@@ -169,6 +171,7 @@ def plan_job(args: argparse.Namespace) -> int:
|
|
|
169
171
|
"options": {
|
|
170
172
|
"provider": args.provider, "brand": args.brand, "category": args.category,
|
|
171
173
|
"tag": args.tag, "site_url": args.site_url, "sample": str(args.sample) if args.sample else None,
|
|
174
|
+
"display_name": getattr(args, "display_name", None), "display_description": getattr(args, "display_description", None),
|
|
172
175
|
},
|
|
173
176
|
"history": [{"phase": "STARTED", "at": now()}],
|
|
174
177
|
}
|
|
@@ -222,6 +225,13 @@ def package_step(args: argparse.Namespace) -> int:
|
|
|
222
225
|
command.extend(("--site-url", options.site_url))
|
|
223
226
|
if options.sample:
|
|
224
227
|
command.extend(("--sample", str(options.sample)))
|
|
228
|
+
# The customer-facing style name is neutral and separate from source
|
|
229
|
+
# evidence; without an explicit name the importer derives one from the
|
|
230
|
+
# measured design and checks it against the source brand and catalog.
|
|
231
|
+
if options.display_name:
|
|
232
|
+
command.extend(("--display-name", options.display_name))
|
|
233
|
+
if options.display_description:
|
|
234
|
+
command.extend(("--display-description", options.display_description))
|
|
225
235
|
try:
|
|
226
236
|
result = subprocess.run(command, cwd=marketplace.parent)
|
|
227
237
|
template = marketplace / "templates" / template_id
|
|
@@ -363,6 +373,8 @@ def main() -> int:
|
|
|
363
373
|
plan_parser.add_argument("--category", action="append", default=[])
|
|
364
374
|
plan_parser.add_argument("--tag", action="append", default=[])
|
|
365
375
|
plan_parser.add_argument("--sample", type=Path)
|
|
376
|
+
plan_parser.add_argument("--display-name", help="neutral customer-facing style name; never the source brand")
|
|
377
|
+
plan_parser.add_argument("--display-description")
|
|
366
378
|
plan_parser.add_argument("--force", action="store_true")
|
|
367
379
|
plan_parser.set_defaults(func=plan_job)
|
|
368
380
|
for name, func, help_text in (
|
|
@@ -390,6 +402,8 @@ def main() -> int:
|
|
|
390
402
|
run_parser.add_argument("--category", action="append", default=[])
|
|
391
403
|
run_parser.add_argument("--tag", action="append", default=[])
|
|
392
404
|
run_parser.add_argument("--sample", type=Path)
|
|
405
|
+
run_parser.add_argument("--display-name", help="neutral customer-facing style name; never the source brand")
|
|
406
|
+
run_parser.add_argument("--display-description")
|
|
393
407
|
run_parser.add_argument("--force", action="store_true")
|
|
394
408
|
run_parser.set_defaults(func=run)
|
|
395
409
|
status_parser = sub.add_parser("status", help="inspect a URL-to-template job")
|
|
@@ -81,6 +81,238 @@ def credential_preflight_main(argv: list[str]) -> int:
|
|
|
81
81
|
return 0 if result["passed"] else 1
|
|
82
82
|
|
|
83
83
|
|
|
84
|
+
ENV_KEY_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,127}")
|
|
85
|
+
SHA256_RE = re.compile(r"[0-9a-f]{64}")
|
|
86
|
+
DATABASE_URL_KEYS = ("DATABASE_URL", "POSTGRES_URL", "POSTGRES_PRISMA_URL", "PG_URL", "MYSQL_URL", "MONGODB_URI", "REDIS_URL")
|
|
87
|
+
DATABASE_HOST_KEYS = ("DB_HOST", "PGHOST", "POSTGRES_HOST", "MYSQL_HOST", "DATABASE_HOST")
|
|
88
|
+
LOCAL_DATABASE_HOSTS = {"localhost", "127.0.0.1", "::1", "0.0.0.0", "db", "postgres", "host.docker.internal"}
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def env_fingerprint(value: str) -> str:
|
|
92
|
+
return hashlib.sha256(value.encode("utf-8")).hexdigest()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def fingerprint_manifest(env_file: Path, host: str, keys: list[str] | None = None) -> dict:
|
|
96
|
+
"""Return key names and SHA-256 fingerprints only; values never leave this function."""
|
|
97
|
+
values = parse_env_file(env_file)
|
|
98
|
+
selected = sorted(set(keys)) if keys else sorted(values)
|
|
99
|
+
return {
|
|
100
|
+
"schemaVersion": "maggie-env-fingerprints.v1",
|
|
101
|
+
"host": host,
|
|
102
|
+
"keys": {key: env_fingerprint(values[key]) for key in selected if values.get(key, "").strip()},
|
|
103
|
+
"valuesReturned": False,
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_fingerprint_manifest(path: Path) -> dict:
|
|
108
|
+
"""Read a JSON manifest or the `KEY <sha256>` lines emitted by the no-echo shell snippet."""
|
|
109
|
+
text = path.read_text(encoding="utf-8")
|
|
110
|
+
try:
|
|
111
|
+
value = json.loads(text)
|
|
112
|
+
except json.JSONDecodeError:
|
|
113
|
+
keys = {}
|
|
114
|
+
for line in text.splitlines():
|
|
115
|
+
parts = line.split()
|
|
116
|
+
if len(parts) == 2:
|
|
117
|
+
keys[parts[0]] = parts[1].lower()
|
|
118
|
+
value = {"schemaVersion": "maggie-env-fingerprints.v1", "host": path.stem, "keys": keys}
|
|
119
|
+
if not isinstance(value, dict) or not isinstance(value.get("keys"), dict):
|
|
120
|
+
raise ValueError(f"fingerprint manifest is invalid: {path.name}")
|
|
121
|
+
keys = {}
|
|
122
|
+
for key, digest in value["keys"].items():
|
|
123
|
+
if not ENV_KEY_RE.fullmatch(str(key)) or not SHA256_RE.fullmatch(str(digest)):
|
|
124
|
+
raise ValueError(f"fingerprint manifest {path.name} must contain only env names and SHA-256 hex digests")
|
|
125
|
+
keys[str(key)] = str(digest)
|
|
126
|
+
# The empty-string digest is what the snippet prints for an unset key.
|
|
127
|
+
empty = env_fingerprint("")
|
|
128
|
+
return {"host": str(value.get("host") or path.stem), "keys": {key: digest for key, digest in keys.items() if digest != empty}}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def secret_parity(hosts: list[dict], required: list[str], strict: bool = False) -> dict:
|
|
132
|
+
"""Compare env names and fingerprints across hosts; output carries short fingerprints only."""
|
|
133
|
+
errors: list[str] = []
|
|
134
|
+
warnings: list[str] = []
|
|
135
|
+
names = [host["host"] for host in hosts]
|
|
136
|
+
if len(hosts) < 2:
|
|
137
|
+
errors.append("at least two hosts are required for a parity check")
|
|
138
|
+
if len(set(names)) != len(names):
|
|
139
|
+
errors.append("host names must be unique")
|
|
140
|
+
required_names = sorted({name.strip() for name in required if name.strip()})
|
|
141
|
+
invalid = [name for name in required_names if not ENV_KEY_RE.fullmatch(name)]
|
|
142
|
+
if invalid:
|
|
143
|
+
errors.append("required names must be environment variable names")
|
|
144
|
+
union = sorted(set().union(*(host["keys"] for host in hosts)) | set(required_names)) if hosts else required_names
|
|
145
|
+
keys = []
|
|
146
|
+
for key in union:
|
|
147
|
+
present = {host["host"]: host["keys"][key] for host in hosts if key in host["keys"]}
|
|
148
|
+
missing = [name for name in names if name not in present]
|
|
149
|
+
distinct = sorted(set(present.values()))
|
|
150
|
+
status = "ok"
|
|
151
|
+
if missing:
|
|
152
|
+
status = "missing"
|
|
153
|
+
elif len(distinct) > 1:
|
|
154
|
+
status = "mismatch"
|
|
155
|
+
is_required = key in required_names
|
|
156
|
+
keys.append({
|
|
157
|
+
"key": key,
|
|
158
|
+
"required": is_required,
|
|
159
|
+
"status": status,
|
|
160
|
+
"missingOn": missing,
|
|
161
|
+
"fingerprints": {name: digest[:12] for name, digest in present.items()} if status == "mismatch" else {},
|
|
162
|
+
})
|
|
163
|
+
if status != "ok":
|
|
164
|
+
message = f"{key} is {status}" + (f" on {', '.join(missing)}" if missing else " across hosts")
|
|
165
|
+
(errors if is_required or strict else warnings).append(message)
|
|
166
|
+
return {
|
|
167
|
+
"schemaVersion": "maggie-secret-parity.v1",
|
|
168
|
+
"hosts": names,
|
|
169
|
+
"required": required_names,
|
|
170
|
+
"keys": keys,
|
|
171
|
+
"valuesReturned": False,
|
|
172
|
+
"passed": not errors,
|
|
173
|
+
"errors": errors,
|
|
174
|
+
"warnings": warnings,
|
|
175
|
+
"mutation": "not executed",
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def secret_parity_main(argv: list[str]) -> int:
|
|
180
|
+
parser = argparse.ArgumentParser(
|
|
181
|
+
prog="maggie deployment secret-parity",
|
|
182
|
+
description="Compare env key names and SHA-256 fingerprints across hosts without printing values.",
|
|
183
|
+
)
|
|
184
|
+
parser.add_argument("--host", action="append", default=[], metavar="NAME=ENV_FILE", help="local env file for one host; repeat")
|
|
185
|
+
parser.add_argument("--manifest", action="append", default=[], help="fingerprint manifest produced on a host; repeat")
|
|
186
|
+
parser.add_argument("--required", action="append", default=[], help="required key name; repeat")
|
|
187
|
+
parser.add_argument("--strict", action="store_true", help="fail on optional keys that differ, not only required keys")
|
|
188
|
+
parser.add_argument("--output")
|
|
189
|
+
args = parser.parse_args(argv)
|
|
190
|
+
hosts = []
|
|
191
|
+
for item in args.host:
|
|
192
|
+
name, separator, path = item.partition("=")
|
|
193
|
+
if not separator or not name or not path:
|
|
194
|
+
parser.error("--host must be NAME=ENV_FILE")
|
|
195
|
+
hosts.append({"host": name, "keys": fingerprint_manifest(Path(path).expanduser(), name)["keys"]})
|
|
196
|
+
for path in args.manifest:
|
|
197
|
+
hosts.append(load_fingerprint_manifest(Path(path).expanduser()))
|
|
198
|
+
result = secret_parity(hosts, args.required, args.strict)
|
|
199
|
+
if args.output:
|
|
200
|
+
output = Path(args.output).resolve()
|
|
201
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
202
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
203
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
204
|
+
return 0 if result["passed"] else 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def secret_fingerprint_main(argv: list[str]) -> int:
|
|
208
|
+
parser = argparse.ArgumentParser(
|
|
209
|
+
prog="maggie deployment secret-fingerprint",
|
|
210
|
+
description="Write a host's env key names and SHA-256 fingerprints (never values) for secret-parity.",
|
|
211
|
+
)
|
|
212
|
+
parser.add_argument("--env-file", required=True)
|
|
213
|
+
parser.add_argument("--host", required=True, help="stable host label, e.g. api-1")
|
|
214
|
+
parser.add_argument("--key", action="append", default=[], help="limit to these key names; repeat")
|
|
215
|
+
parser.add_argument("--output")
|
|
216
|
+
args = parser.parse_args(argv)
|
|
217
|
+
result = fingerprint_manifest(Path(args.env_file).expanduser(), args.host, args.key or None)
|
|
218
|
+
text = json.dumps(result, indent=2, ensure_ascii=False) + "\n"
|
|
219
|
+
if args.output:
|
|
220
|
+
output = Path(args.output).resolve()
|
|
221
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
222
|
+
output.write_text(text, encoding="utf-8")
|
|
223
|
+
print(text, end="")
|
|
224
|
+
return 0
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def database_targets(values: dict[str, str]) -> list[dict]:
|
|
228
|
+
"""Extract database host/port/name per key; credentials are discarded."""
|
|
229
|
+
from urllib.parse import urlsplit
|
|
230
|
+
|
|
231
|
+
targets = []
|
|
232
|
+
for key, value in values.items():
|
|
233
|
+
if not value:
|
|
234
|
+
continue
|
|
235
|
+
if key in DATABASE_URL_KEYS or key.endswith("_DATABASE_URL") or key.endswith("_DB_URL"):
|
|
236
|
+
try:
|
|
237
|
+
parsed = urlsplit(value)
|
|
238
|
+
host = (parsed.hostname or "").lower()
|
|
239
|
+
port = parsed.port
|
|
240
|
+
except ValueError:
|
|
241
|
+
continue
|
|
242
|
+
if host:
|
|
243
|
+
targets.append({"key": key, "host": host, "port": port, "database": parsed.path.lstrip("/") or None})
|
|
244
|
+
elif key in DATABASE_HOST_KEYS:
|
|
245
|
+
targets.append({"key": key, "host": value.lower(), "port": None, "database": None})
|
|
246
|
+
return targets
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def environment_isolation(local_env: Path, production_env: Path | None = None, production_hosts: list[str] | None = None) -> dict:
|
|
250
|
+
"""Fail when a local env file points at the production database."""
|
|
251
|
+
errors: list[str] = []
|
|
252
|
+
warnings: list[str] = []
|
|
253
|
+
local = database_targets(parse_env_file(local_env)) if local_env.is_file() else []
|
|
254
|
+
production = database_targets(parse_env_file(production_env)) if production_env and production_env.is_file() else []
|
|
255
|
+
production += [{"key": "--production-db-host", "host": host.lower(), "port": None, "database": None} for host in production_hosts or [] if host]
|
|
256
|
+
if not local:
|
|
257
|
+
warnings.append("no database connection setting found in the local env file")
|
|
258
|
+
if not production:
|
|
259
|
+
warnings.append("no production database host provided; pass --production-env or --production-db-host")
|
|
260
|
+
findings = []
|
|
261
|
+
for item in local:
|
|
262
|
+
if item["host"] in LOCAL_DATABASE_HOSTS:
|
|
263
|
+
continue
|
|
264
|
+
for prod in production:
|
|
265
|
+
if item["host"] != prod["host"]:
|
|
266
|
+
continue
|
|
267
|
+
same_port = item["port"] is None or prod["port"] is None or item["port"] == prod["port"]
|
|
268
|
+
same_database = item["database"] is None or prod["database"] is None or item["database"] == prod["database"]
|
|
269
|
+
shared = same_port and same_database
|
|
270
|
+
findings.append({"localKey": item["key"], "productionKey": prod["key"], "host": item["host"], "sameDatabase": shared})
|
|
271
|
+
if shared:
|
|
272
|
+
errors.append(f"{item['key']} points at the production database host {item['host']}; local test data will reach live pages and workers")
|
|
273
|
+
else:
|
|
274
|
+
warnings.append(f"{item['key']} uses the production database server {item['host']} with a different database or port")
|
|
275
|
+
return {
|
|
276
|
+
"schemaVersion": "maggie-environment-isolation.v1",
|
|
277
|
+
"localTargets": [{key: item[key] for key in ("key", "host", "port")} for item in local],
|
|
278
|
+
"productionHosts": sorted({item["host"] for item in production}),
|
|
279
|
+
"findings": findings,
|
|
280
|
+
"credentialsReturned": False,
|
|
281
|
+
"passed": not errors,
|
|
282
|
+
"errors": errors,
|
|
283
|
+
"warnings": warnings,
|
|
284
|
+
"mutation": "not executed",
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def environment_isolation_main(argv: list[str]) -> int:
|
|
289
|
+
parser = argparse.ArgumentParser(
|
|
290
|
+
prog="maggie deployment env-isolation",
|
|
291
|
+
description="Check that local development does not use the production database.",
|
|
292
|
+
)
|
|
293
|
+
parser.add_argument("--project", default=".")
|
|
294
|
+
parser.add_argument("--local-env", help="defaults to PROJECT/.env")
|
|
295
|
+
parser.add_argument("--production-env", help="production env file (read locally; values are not printed)")
|
|
296
|
+
parser.add_argument("--production-db-host", action="append", default=[], help="production database hostname; repeat")
|
|
297
|
+
parser.add_argument("--output")
|
|
298
|
+
args = parser.parse_args(argv)
|
|
299
|
+
project = Path(args.project).expanduser().resolve()
|
|
300
|
+
hosts = list(args.production_db_host)
|
|
301
|
+
if os.environ.get("MAGGIE_PRODUCTION_DB_HOST"):
|
|
302
|
+
hosts.append(os.environ["MAGGIE_PRODUCTION_DB_HOST"])
|
|
303
|
+
result = environment_isolation(
|
|
304
|
+
Path(args.local_env).expanduser().resolve() if args.local_env else project / ".env",
|
|
305
|
+
Path(args.production_env).expanduser().resolve() if args.production_env else None,
|
|
306
|
+
hosts,
|
|
307
|
+
)
|
|
308
|
+
if args.output:
|
|
309
|
+
output = Path(args.output).resolve()
|
|
310
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
312
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
313
|
+
return 0 if result["passed"] else 1
|
|
314
|
+
|
|
315
|
+
|
|
84
316
|
def _secret_names(payload: object) -> list[str]:
|
|
85
317
|
"""Extract only binding names from a Wrangler secret-list response."""
|
|
86
318
|
entries: object = payload
|
|
@@ -303,6 +535,14 @@ def preflight(
|
|
|
303
535
|
"commands": {key: scripts[key] for key in ("build", "test", "lint", "typecheck", "deploy") if key in scripts},
|
|
304
536
|
"next_action": "review target, environment, migrations, secrets, and rollback owner before execute",
|
|
305
537
|
}
|
|
538
|
+
isolation_warnings: list[str] = []
|
|
539
|
+
production_env = next((path for path in (project / ".env.production", project / ".env.prod") if path.is_file()), None)
|
|
540
|
+
production_host = os.environ.get("MAGGIE_PRODUCTION_DB_HOST", "").strip()
|
|
541
|
+
if (project / ".env").is_file() and (production_env or production_host):
|
|
542
|
+
isolation = environment_isolation(project / ".env", production_env, [production_host] if production_host else [])
|
|
543
|
+
result["environment_isolation"] = {"passed": isolation["passed"], "productionHosts": isolation["productionHosts"], "errors": isolation["errors"]}
|
|
544
|
+
result["checks"]["local_database_isolated"] = isolation["passed"]
|
|
545
|
+
isolation_warnings = [f"environment isolation: {message}" for message in isolation["errors"]]
|
|
306
546
|
if target in {"vps", "vps-with-cloudflare-dns"}:
|
|
307
547
|
result["provider"] = "vps"
|
|
308
548
|
result["architecture"] = "Astro Node standalone behind Nginx, managed by systemd, with release/current rollback layout"
|
|
@@ -326,7 +566,7 @@ def preflight(
|
|
|
326
566
|
rollback_passed = False
|
|
327
567
|
if rollback_evidence.exists():
|
|
328
568
|
try:
|
|
329
|
-
rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8")))
|
|
569
|
+
rollback_passed = validate_rollback_evidence(json.loads(rollback_evidence.read_text(encoding="utf-8")), plan.get("retention", {}).get("keep"))["passed"]
|
|
330
570
|
except (OSError, json.JSONDecodeError):
|
|
331
571
|
rollback_passed = False
|
|
332
572
|
migration_manifest = project / ".maggie" / "migration-manifest.json"
|
|
@@ -339,7 +579,12 @@ def preflight(
|
|
|
339
579
|
data_path = project / ".maggie" / "deployment" / "data-release.json"
|
|
340
580
|
data_required = bool(migration.get("dataDependencies"))
|
|
341
581
|
data_checkpoint = validate_data_checkpoint(data_path) if data_path.exists() else {"passed": not data_required, "declared": False, "errors": ["data-release.json is required for a data-dependent release"] if data_required else []}
|
|
342
|
-
|
|
582
|
+
retention = plan.get("retention", {}) if isinstance(plan.get("retention"), dict) else {}
|
|
583
|
+
keep = retention.get("keep")
|
|
584
|
+
retention_ok = bool(keep in (1, 2, 3, 4, 5) and retention.get("prune") and retention.get("preserve"))
|
|
585
|
+
rollback_command = (plan.get("commands") or {}).get("rollback")
|
|
586
|
+
# Never advertise a rollback target that the retention policy deletes.
|
|
587
|
+
rollback_consistent = retention_ok and (bool(rollback_command) if keep >= 2 else not rollback_command and not any("<previous-release>" in str(item) for item in retention.get("preserve", [])))
|
|
343
588
|
delegation = {"passed": True, "state": "not-required", "errors": []}
|
|
344
589
|
delegation_path = delegation_evidence or project / ".maggie" / "deployment" / "deployer-delegation.json"
|
|
345
590
|
if require_deployer:
|
|
@@ -351,22 +596,38 @@ def preflight(
|
|
|
351
596
|
else:
|
|
352
597
|
delegation = validate_deployer_delegation(delegation_value, plan.get("release_root"), plan.get("service"))
|
|
353
598
|
delegation["path"] = str(delegation_path)
|
|
354
|
-
result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": not delegation["passed"], "deployer_delegation": delegation["passed"], "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
|
|
599
|
+
result["checks"].update({"node_runtime_declared": bool(package.get("engines", {}).get("node") or package.get("dependencies", {}).get("astro")), "start_command_or_runtime": "start" in scripts or "preview" in scripts, "stateless_or_migration_plan": True, "release_layout_declared": True, "rollback_command_declared": True, "release_retention_policy": retention_ok, "rollback_target_retained": rollback_consistent, "data_release_checkpoint": data_checkpoint["passed"], "remote_verification_pending": not delegation["passed"], "deployer_delegation": delegation["passed"], "release_plan_artifact": bool(plan_path and plan.get("release_layout") and plan.get("current_release")), "systemd_artifact": bool(systemd_text and "ExecStart=" in systemd_text and "EnvironmentFile=" in systemd_text and "NoNewPrivileges=true" in systemd_text), "nginx_artifact": bool(nginx_text and "proxy_pass" in nginx_text and "server_name" in nginx_text), "deployment_artifacts_secret_free": secret_free, "rollback_evidence": rollback_passed if environment == "production" else True})
|
|
355
600
|
result["delegation"] = delegation
|
|
356
601
|
result["vps"] = {"runtime":"Node.js standalone","reverseProxy":"Nginx","processManager":"systemd","releaseLayout":"/var/www/<site>/releases/<release> + current symlink","dns":"Cloudflare DNS or registrar DNS; verify apex and www separately","remoteCommands":["npm ci","npm run build","systemctl restart <service>","systemctl is-active <service>","curl -fsS https://<domain>/robots.txt","curl -fsS https://<domain>/sitemap.xml"],"handoverFields":["host","sshUser","domain","service","release","previousRelease","nginxConfig","migrationVersion","verification","rollback"]}
|
|
357
602
|
result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
|
|
358
603
|
runners = sorted(deployment_dir.rglob("release-runner.sh")) if deployment_dir.exists() else []
|
|
359
604
|
result["release_runners"] = [release_runner_status(path) for path in runners[:25]]
|
|
360
|
-
result["warnings"] = [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
|
|
361
|
-
result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
|
|
605
|
+
result["warnings"] = isolation_warnings + [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
|
|
606
|
+
result["blocking_checks"] = ["package_manifest", "build_command", "env_example", "node_runtime_declared", "start_command_or_runtime", "stateless_or_migration_plan", "release_layout_declared", "rollback_command_declared", "release_retention_policy", "rollback_target_retained", "data_release_checkpoint", "release_plan_artifact", "systemd_artifact", "nginx_artifact", "deployment_artifacts_secret_free", "rollback_evidence"]
|
|
362
607
|
if require_deployer:
|
|
363
608
|
result["blocking_checks"].append("deployer_delegation")
|
|
364
609
|
else:
|
|
365
610
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example"]
|
|
611
|
+
if isolation_warnings:
|
|
612
|
+
result["warnings"] = isolation_warnings
|
|
366
613
|
return result
|
|
367
614
|
|
|
368
615
|
|
|
369
|
-
def
|
|
616
|
+
def retention_policy(root: str, current: str, keep: int) -> dict:
|
|
617
|
+
"""One retention policy shared by the plan, runner, preflight, prune, and rollback evidence."""
|
|
618
|
+
if not isinstance(keep, int) or isinstance(keep, bool) or not 1 <= keep <= 5:
|
|
619
|
+
raise ValueError("keep must be between 1 and 5")
|
|
620
|
+
preserve = [current] + ([f"{root}/releases/<previous-release>"] if keep >= 2 else [])
|
|
621
|
+
return {
|
|
622
|
+
"keep": keep,
|
|
623
|
+
"preserve": preserve,
|
|
624
|
+
"prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +{keep + 1} | cut -d' ' -f2- | xargs -r rm -rf --",
|
|
625
|
+
"rollbackAvailable": keep >= 2,
|
|
626
|
+
"rollbackUnavailableReason": "" if keep >= 2 else "retention keeps only the active release; recover by redeploying the previous commit as a new release and restoring its data checkpoint",
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
|
|
630
|
+
def vps_plan(domain: str, service: str, release_root: str, node_port: int | None, deployer_user: str = "maggie-deploy", keep_releases: int = 2) -> dict:
|
|
370
631
|
"""Create reviewable, secret-free VPS service and reverse-proxy artifacts."""
|
|
371
632
|
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", domain):
|
|
372
633
|
raise ValueError("domain must be a hostname without a scheme or path")
|
|
@@ -380,6 +641,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
|
|
|
380
641
|
raise ValueError("node port must be between 1024 and 65535")
|
|
381
642
|
root = release_root.rstrip("/")
|
|
382
643
|
current = f"{root}/current"
|
|
644
|
+
retention = retention_policy(root, current, keep_releases)
|
|
383
645
|
privileged_commands = [
|
|
384
646
|
f"/bin/systemctl restart {service}",
|
|
385
647
|
f"/bin/systemctl is-active {service}",
|
|
@@ -407,7 +669,7 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
|
|
|
407
669
|
"allowed_domains_required": True,
|
|
408
670
|
},
|
|
409
671
|
"release_layout": f"{root}/releases/<immutable-release> plus current symlink",
|
|
410
|
-
"retention":
|
|
672
|
+
"retention": retention,
|
|
411
673
|
"staging_first": True,
|
|
412
674
|
"secrets": "external environment file; no secrets in generated artifacts",
|
|
413
675
|
"commands": {
|
|
@@ -415,7 +677,11 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
|
|
|
415
677
|
"switch": f"ln -sfn {root}/releases/<release> {current}",
|
|
416
678
|
"restart": f"sudo -n /bin/systemctl restart {service}",
|
|
417
679
|
"verify": f"sudo -n /bin/systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
|
|
418
|
-
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && sudo -n /bin/systemctl restart {service}",
|
|
680
|
+
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && sudo -n /bin/systemctl restart {service}" if retention["rollbackAvailable"] else None,
|
|
681
|
+
# The database owner (e.g. postgres) often cannot traverse a release
|
|
682
|
+
# under a deploy user's home; stream the file instead of widening
|
|
683
|
+
# filesystem permissions.
|
|
684
|
+
"migrate_as_db_owner": f"sudo cat {root}/releases/<release>/<migration.sql> | sudo -u postgres psql -v ON_ERROR_STOP=1 --single-transaction -d <database> -f -",
|
|
419
685
|
"data_rollback": "restore the matching data checkpoint before restarting the previous code release",
|
|
420
686
|
},
|
|
421
687
|
"files": {
|
|
@@ -450,8 +716,12 @@ def validate_data_checkpoint(path: Path) -> dict:
|
|
|
450
716
|
return {"passed": not errors, "declared": True, "errors": errors}
|
|
451
717
|
|
|
452
718
|
|
|
453
|
-
def validate_rollback_evidence(value: object) -> dict:
|
|
454
|
-
"""Validate release rollback evidence, including the explicitly safe first release.
|
|
719
|
+
def validate_rollback_evidence(value: object, keep: int | None = None) -> dict:
|
|
720
|
+
"""Validate release rollback evidence, including the explicitly safe first release.
|
|
721
|
+
|
|
722
|
+
With a one-release retention policy no previous release stays on disk, so
|
|
723
|
+
upgrade evidence must say the code rollback is unavailable and name the
|
|
724
|
+
recovery plan instead of advertising a removed target."""
|
|
455
725
|
if not isinstance(value, dict):
|
|
456
726
|
return {"passed": False, "errors": ["rollback evidence must be an object"]}
|
|
457
727
|
errors = []
|
|
@@ -471,6 +741,13 @@ def validate_rollback_evidence(value: object) -> dict:
|
|
|
471
741
|
errors.append("first release evidence must not claim a previous release")
|
|
472
742
|
if not value.get("notApplicableReason"):
|
|
473
743
|
errors.append("first release evidence needs notApplicableReason")
|
|
744
|
+
elif keep == 1:
|
|
745
|
+
if value.get("codeRollback") != "unavailable":
|
|
746
|
+
errors.append("retention keeps one release; rollback evidence must declare codeRollback: unavailable")
|
|
747
|
+
if not value.get("recoveryPlan"):
|
|
748
|
+
errors.append("retention keeps one release; rollback evidence needs a recoveryPlan (redeploy previous commit + data checkpoint)")
|
|
749
|
+
elif value.get("codeRollback") == "unavailable":
|
|
750
|
+
errors.append("codeRollback unavailable contradicts a retention policy that keeps a previous release")
|
|
474
751
|
elif not value.get("previousRelease"):
|
|
475
752
|
errors.append("upgrade rollback evidence requires previousRelease")
|
|
476
753
|
return {"passed": not errors, "errors": errors}
|
|
@@ -498,15 +775,14 @@ def verify_infrastructure(units_dir: Path | None, service: str, timer: str | Non
|
|
|
498
775
|
|
|
499
776
|
|
|
500
777
|
def retention_plan(release_root: str, current_link: str, keep: int) -> dict:
|
|
501
|
-
|
|
502
|
-
raise ValueError("keep must be between 2 and 5")
|
|
778
|
+
policy = retention_policy(release_root.rstrip("/"), current_link, keep)
|
|
503
779
|
root = Path(release_root).resolve()
|
|
504
780
|
releases = sorted((path for path in root.iterdir() if path.is_dir() and not path.is_symlink()), key=lambda path: path.stat().st_mtime, reverse=True) if root.is_dir() else []
|
|
505
781
|
current_target = Path(current_link).resolve() if Path(current_link).exists() else None
|
|
506
782
|
preserved = {str(current_target)} if current_target else set()
|
|
507
783
|
preserved.update(str(path) for path in releases[:keep])
|
|
508
784
|
candidates = [str(path) for path in releases if str(path) not in preserved]
|
|
509
|
-
return {"schemaVersion": "maggie-retention-plan.v1", "releaseRoot": str(root), "currentLink": str(Path(current_link)), "keep": keep, "preserved": sorted(preserved), "candidates": candidates, "mutation": "not executed", "generatedAt": datetime.now(timezone.utc).isoformat()}
|
|
785
|
+
return {"schemaVersion": "maggie-retention-plan.v1", "releaseRoot": str(root), "currentLink": str(Path(current_link)), "keep": keep, "preserved": sorted(preserved), "candidates": candidates, "rollbackAvailable": policy["rollbackAvailable"], "rollbackUnavailableReason": policy["rollbackUnavailableReason"], "mutation": "not executed", "generatedAt": datetime.now(timezone.utc).isoformat()}
|
|
510
786
|
|
|
511
787
|
|
|
512
788
|
def write_vps_plan(output_dir: Path, plan: dict) -> None:
|
|
@@ -594,6 +870,7 @@ def release_runner(plan: dict) -> str:
|
|
|
594
870
|
service = plan["service"]
|
|
595
871
|
domain = plan["domain"]
|
|
596
872
|
current = plan["current_release"]
|
|
873
|
+
keep = retention_policy(root, current, plan.get("retention", {}).get("keep", 2))["keep"]
|
|
597
874
|
generator = release_runner_generator()
|
|
598
875
|
return f'''#!/usr/bin/env bash
|
|
599
876
|
set -euo pipefail
|
|
@@ -657,13 +934,19 @@ wait_for_active
|
|
|
657
934
|
wait_for_http "https://{domain}/robots.txt"
|
|
658
935
|
wait_for_http "https://{domain}/sitemap.xml"
|
|
659
936
|
|
|
660
|
-
#
|
|
937
|
+
# Retention policy: keep {keep} release(s), and prune only after health checks.
|
|
938
|
+
KEEP_RELEASES={keep}
|
|
661
939
|
mapfile -t RELEASES < <(find "$RELEASE_ROOT/releases" -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | cut -d' ' -f2-)
|
|
662
|
-
for candidate in "${{RELEASES[@]
|
|
663
|
-
[ "$candidate" = "$PREVIOUS" ] && continue
|
|
940
|
+
for candidate in "${{RELEASES[@]:$KEEP_RELEASES}}"; do
|
|
941
|
+
[ "$KEEP_RELEASES" -ge 2 ] && [ "$candidate" = "$PREVIOUS" ] && continue
|
|
664
942
|
[ "$candidate" = "$RELEASE" ] && continue
|
|
665
943
|
rm -rf -- "$candidate"
|
|
666
944
|
done
|
|
945
|
+
if [ "$KEEP_RELEASES" -ge 2 ] && [ -n "$PREVIOUS" ]; then
|
|
946
|
+
echo "rollback target retained: $PREVIOUS"
|
|
947
|
+
else
|
|
948
|
+
echo "rollback target: unavailable (retention keeps only the active release; redeploy the previous commit and restore its data checkpoint)"
|
|
949
|
+
fi
|
|
667
950
|
'''
|
|
668
951
|
|
|
669
952
|
|
|
@@ -678,6 +961,12 @@ def main() -> int:
|
|
|
678
961
|
return credential_preflight_main(sys.argv[2:])
|
|
679
962
|
if sys.argv[1:2] == ["worker-secrets"]:
|
|
680
963
|
return worker_secret_preflight_main(sys.argv[2:])
|
|
964
|
+
if sys.argv[1:2] == ["secret-parity"]:
|
|
965
|
+
return secret_parity_main(sys.argv[2:])
|
|
966
|
+
if sys.argv[1:2] == ["secret-fingerprint"]:
|
|
967
|
+
return secret_fingerprint_main(sys.argv[2:])
|
|
968
|
+
if sys.argv[1:2] == ["env-isolation"]:
|
|
969
|
+
return environment_isolation_main(sys.argv[2:])
|
|
681
970
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
682
971
|
parser.add_argument("project", nargs="?", default=".")
|
|
683
972
|
parser.add_argument("--target", choices=("detected", "cloudflare", "vps", "vps-with-cloudflare-dns", "gcp", "aws"), default="detected")
|
|
@@ -698,7 +987,7 @@ def main() -> int:
|
|
|
698
987
|
parser.add_argument("--check-runner", help="read-only check of a generated runner's Maggie version and template fingerprint")
|
|
699
988
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
700
989
|
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
701
|
-
parser.add_argument("--keep-releases", type=int, default=2)
|
|
990
|
+
parser.add_argument("--keep-releases", type=int, default=2, help="releases to keep (1-5); 1 means no on-disk rollback target, used by --vps-plan and --retention-plan")
|
|
702
991
|
parser.add_argument("--verify-infra", action="store_true", help="verify declared or installed systemd service/timer")
|
|
703
992
|
parser.add_argument("--units-dir", help="directory containing generated .service and .timer units")
|
|
704
993
|
parser.add_argument("--timer", help="systemd timer unit name")
|
|
@@ -726,7 +1015,7 @@ def main() -> int:
|
|
|
726
1015
|
if args.vps_plan:
|
|
727
1016
|
if not args.domain:
|
|
728
1017
|
parser.error("--domain is required with --vps-plan")
|
|
729
|
-
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port, args.deployer_user)
|
|
1018
|
+
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port, args.deployer_user, args.keep_releases)
|
|
730
1019
|
if args.plan_dir:
|
|
731
1020
|
write_vps_plan(Path(args.plan_dir).resolve(), plan)
|
|
732
1021
|
if args.runner_output:
|