@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.
Files changed (91) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +76 -15
  3. package/bin/maggie.js +23 -6
  4. package/bundled-contracts/google-integrations/capability-report-v2.schema.json +2 -1
  5. package/bundled-contracts/maggie-content/email-localization-v1.schema.json +89 -0
  6. package/bundled-contracts/maggie-content/lifecycle-email-v1.schema.json +76 -0
  7. package/bundled-contracts/maggie-deployment/readiness-v1.schema.json +6 -1
  8. package/bundled-contracts/maggie-design/palette-roles-v1.schema.json +47 -0
  9. package/bundled-contracts/maggie-marketplace/template-content-v1.schema.json +103 -0
  10. package/bundled-contracts/maggie-ops/marketing-run-v1.schema.json +89 -0
  11. package/bundled-contracts/maggie-ops/notification-catalogue-example-v1.json +941 -0
  12. package/bundled-contracts/maggie-ops/notification-catalogue-v1.schema.json +273 -0
  13. package/bundled-contracts/maggie-service-booking/fact-provenance-v1.schema.json +128 -0
  14. package/bundled-contracts/maggie-service-booking/import-stages-v1.schema.json +191 -0
  15. package/bundled-contracts/maggiedash/booking-browser-evidence-v1.json +21 -0
  16. package/bundled-contracts/maggiedash/booking-browser-evidence-v2.json +191 -0
  17. package/bundled-contracts/maggiedash/booking-email-templates-v1.json +3 -1
  18. package/bundled-contracts/maggiedash/booking-voucher-lifecycle-v1.json +85 -0
  19. package/bundled-contracts/maggiedash/execution-board.json +4629 -1553
  20. package/bundled-contracts/maggiedash/stripe-booking-capabilities-v1.json +1 -0
  21. package/bundled-marketplace/README.md +17 -0
  22. package/bundled-marketplace/catalog.json +56 -7
  23. package/bundled-references/browser-inspection.md +25 -0
  24. package/bundled-references/google-integrations-runbook.md +54 -0
  25. package/bundled-references/maggiedash-booking/CURRENT-STATE.md +128 -10
  26. package/bundled-references/maggiedash-booking/EXECUTION-BOARD.json +4629 -1553
  27. package/bundled-references/maggiedash-booking/HOST-ADAPTER.md +5 -0
  28. package/bundled-references/maggiedash-booking/ORAWELLNESS-INTEGRATION-AUDIT.md +64 -21
  29. package/bundled-references/maggiedash-booking/PRD.md +9 -1
  30. package/bundled-references/maggiedash-booking/PROGRESS.md +244 -2399
  31. package/bundled-references/maggiedash-booking/QA-TEST-PLAN.md +37 -6
  32. package/bundled-references/maggiedash-booking/README.md +23 -6
  33. package/bundled-references/maggiedash-booking/RUNTIME-OPERATIONS.md +64 -8
  34. package/bundled-references/maggiedash-booking/SKILLS-AND-CLI.md +11 -2
  35. package/bundled-references/maggiedash-booking/STRIPE-INTEGRATION.md +66 -2
  36. package/bundled-references/maggiedash-booking/TASK-RUNBOOK.md +1 -1
  37. package/bundled-references/maggiedash-booking/TASKS.md +44 -3
  38. package/bundled-references/maggiedash-booking/VOUCHERS.md +47 -0
  39. package/bundled-references/provider-adapter-contract.md +33 -0
  40. package/bundled-skills/maggie-booking/SKILL.md +8 -0
  41. package/bundled-skills/maggie-clone-to-template/SKILL.md +26 -0
  42. package/bundled-skills/maggie-content-localization/SKILL.md +17 -1
  43. package/bundled-skills/maggie-content-localization/references/email-and-lifecycle.md +83 -0
  44. package/bundled-skills/maggie-deployment/SKILL.md +141 -7
  45. package/bundled-skills/maggie-deployment/references/vps.md +74 -1
  46. package/bundled-skills/maggie-design/SKILL.md +154 -0
  47. package/bundled-skills/maggie-feedback/SKILL.md +67 -1
  48. package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
  49. package/bundled-skills/maggie-ops/SKILL.md +37 -0
  50. package/bundled-skills/maggie-ops/references/notifications-operations.md +154 -0
  51. package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
  52. package/bundled-skills/maggie-service-booking/SKILL.md +82 -0
  53. package/bundled-skills/maggie-social-share/SKILL.md +56 -7
  54. package/bundled-skills/maggie-social-share/references/marketing-run.md +57 -0
  55. package/bundled-skills/maggie-social-share/references/queue-and-connections.md +152 -0
  56. package/bundled-skills/maggie-template/SKILL.md +57 -3
  57. package/bundled-skills/maggie-template/references/template-content-v1.example.json +34 -0
  58. package/bundled-tools/clis/maggie_booking.py +199 -26
  59. package/bundled-tools/clis/maggie_booking_vouchers.py +185 -0
  60. package/bundled-tools/clis/maggie_clone_to_template.py +14 -0
  61. package/bundled-tools/clis/maggie_deployment.py +307 -18
  62. package/bundled-tools/clis/maggie_deployment_auth_canary.py +216 -0
  63. package/bundled-tools/clis/maggie_deployment_collect.py +225 -0
  64. package/bundled-tools/clis/maggie_deployment_readiness.py +53 -3
  65. package/bundled-tools/clis/maggie_design.py +17 -0
  66. package/bundled-tools/clis/maggie_design_lint.py +277 -0
  67. package/bundled-tools/clis/maggie_design_verify.py +359 -0
  68. package/bundled-tools/clis/maggie_docs.py +82 -4
  69. package/bundled-tools/clis/maggie_email_localization.py +382 -0
  70. package/bundled-tools/clis/maggie_feedback.py +115 -5
  71. package/bundled-tools/clis/maggie_icon_glyph_check.py +382 -0
  72. package/bundled-tools/clis/maggie_icon_release_gate.py +28 -1
  73. package/bundled-tools/clis/maggie_localization.py +5 -0
  74. package/bundled-tools/clis/maggie_marketing_run.py +272 -0
  75. package/bundled-tools/clis/maggie_marketplace.py +199 -5
  76. package/bundled-tools/clis/maggie_notifications.py +528 -0
  77. package/bundled-tools/clis/maggie_ops.py +87 -0
  78. package/bundled-tools/clis/maggie_palette.py +280 -0
  79. package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
  80. package/bundled-tools/clis/maggie_schedule.py +12 -2
  81. package/bundled-tools/clis/maggie_service_import.py +520 -0
  82. package/bundled-tools/clis/maggie_social_queue.py +442 -0
  83. package/bundled-tools/clis/maggie_social_share.py +46 -9
  84. package/bundled-tools/clis/maggie_template_package.py +1141 -0
  85. package/bundled-tools/runtime/google_ads.py +306 -0
  86. package/bundled-tools/runtime/google_capabilities.py +18 -2
  87. package/bundled-tools/runtime/google_remediation.py +152 -0
  88. package/package.json +1 -1
  89. package/references/browser-inspection.md +25 -0
  90. package/references/google-integrations-runbook.md +54 -0
  91. 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"))) ["passed"]
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
- retention_ok = bool(plan.get("retention", {}).get("keep") in (2, 3, 4, 5) and plan.get("retention", {}).get("prune") and plan.get("retention", {}).get("preserve"))
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 vps_plan(domain: str, service: str, release_root: str, node_port: int | None, deployer_user: str = "maggie-deploy") -> dict:
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": {"keep": 2, "preserve": [current, f"{root}/releases/<previous-release>"], "prune": f"find {root}/releases -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\\n' | sort -nr | tail -n +3 | cut -d' ' -f2- | xargs -r rm -rf --"},
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
- if keep < 2 or keep > 5:
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
- # Keep current plus one rollback candidate, and prune only after health checks.
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[@]:2}}"; do
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: