@topy-ai/maggie 0.7.46 → 0.7.48
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 +12 -4
- package/README.md +55 -11
- package/bin/maggie.js +26 -4
- 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/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/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/memory-hook.md +18 -5
- 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-deployment/SKILL.md +59 -1
- package/bundled-skills/maggie-deployment/references/vps.md +41 -0
- package/bundled-skills/maggie-design/SKILL.md +99 -0
- package/bundled-skills/maggie-feedback/SKILL.md +41 -2
- package/bundled-skills/maggie-marketplace/SKILL.md +91 -0
- package/bundled-skills/maggie-memory/SKILL.md +10 -1
- package/bundled-skills/maggie-qa-workflow/SKILL.md +41 -1
- package/bundled-skills/maggie-service-booking/SKILL.md +23 -0
- package/bundled-skills/maggie-social-share/SKILL.md +37 -5
- package/bundled-skills/maggie-social-share/references/queue-and-connections.md +101 -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 +295 -0
- package/bundled-tools/clis/maggie_design.py +9 -0
- package/bundled-tools/clis/maggie_design_lint.py +277 -0
- package/bundled-tools/clis/maggie_feedback.py +107 -3
- package/bundled-tools/clis/maggie_marketplace.py +199 -5
- package/bundled-tools/clis/maggie_memory.py +10 -3
- package/bundled-tools/clis/maggie_palette.py +280 -0
- package/bundled-tools/clis/maggie_qa_workflow.py +156 -1
- package/bundled-tools/clis/maggie_social_queue.py +338 -0
- package/bundled-tools/clis/maggie_social_share.py +42 -9
- package/bundled-tools/clis/maggie_template_package.py +1141 -0
- package/bundled-tools/runtime/maggie_memory.py +17 -2
- package/package.json +1 -1
- package/references/browser-inspection.md +25 -0
- package/references/memory-hook.md +18 -5
- 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")
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import argparse
|
|
6
|
+
import hashlib
|
|
7
|
+
import inspect
|
|
6
8
|
import json
|
|
9
|
+
import os
|
|
7
10
|
import re
|
|
8
11
|
import shlex
|
|
9
12
|
import subprocess
|
|
@@ -78,6 +81,238 @@ def credential_preflight_main(argv: list[str]) -> int:
|
|
|
78
81
|
return 0 if result["passed"] else 1
|
|
79
82
|
|
|
80
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
|
+
|
|
81
316
|
def _secret_names(payload: object) -> list[str]:
|
|
82
317
|
"""Extract only binding names from a Wrangler secret-list response."""
|
|
83
318
|
entries: object = payload
|
|
@@ -300,6 +535,14 @@ def preflight(
|
|
|
300
535
|
"commands": {key: scripts[key] for key in ("build", "test", "lint", "typecheck", "deploy") if key in scripts},
|
|
301
536
|
"next_action": "review target, environment, migrations, secrets, and rollback owner before execute",
|
|
302
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"]]
|
|
303
546
|
if target in {"vps", "vps-with-cloudflare-dns"}:
|
|
304
547
|
result["provider"] = "vps"
|
|
305
548
|
result["architecture"] = "Astro Node standalone behind Nginx, managed by systemd, with release/current rollback layout"
|
|
@@ -352,11 +595,16 @@ def preflight(
|
|
|
352
595
|
result["delegation"] = delegation
|
|
353
596
|
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"]}
|
|
354
597
|
result["next_action"] = "review VPS host, SSH user, domain/DNS, systemd service, Nginx config, release path, migrations, and rollback owner before execute"
|
|
598
|
+
runners = sorted(deployment_dir.rglob("release-runner.sh")) if deployment_dir.exists() else []
|
|
599
|
+
result["release_runners"] = [release_runner_status(path) for path in runners[:25]]
|
|
600
|
+
result["warnings"] = isolation_warnings + [f"generated release runner is stale or unmarked: {item['path']}" for item in result["release_runners"] if item["status"] == "stale"]
|
|
355
601
|
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"]
|
|
356
602
|
if require_deployer:
|
|
357
603
|
result["blocking_checks"].append("deployer_delegation")
|
|
358
604
|
else:
|
|
359
605
|
result["blocking_checks"] = ["package_manifest", "build_command", "env_example"]
|
|
606
|
+
if isolation_warnings:
|
|
607
|
+
result["warnings"] = isolation_warnings
|
|
360
608
|
return result
|
|
361
609
|
|
|
362
610
|
|
|
@@ -549,15 +797,51 @@ WantedBy=multi-user.target
|
|
|
549
797
|
(output_dir / f"{domain}.conf").write_text(nginx, encoding="utf-8")
|
|
550
798
|
|
|
551
799
|
|
|
800
|
+
def release_runner_generator() -> dict[str, str]:
|
|
801
|
+
version = os.environ.get("MAGGIE_VERSION", "").strip()
|
|
802
|
+
if not version:
|
|
803
|
+
version_file = Path(__file__).resolve().parents[2] / "VERSION"
|
|
804
|
+
try:
|
|
805
|
+
version = version_file.read_text(encoding="utf-8").strip()
|
|
806
|
+
except OSError:
|
|
807
|
+
version = "unknown"
|
|
808
|
+
source = inspect.getsource(release_runner).encode("utf-8")
|
|
809
|
+
return {"version": version, "fingerprint": hashlib.sha256(source).hexdigest()[:16]}
|
|
810
|
+
|
|
811
|
+
|
|
812
|
+
def release_runner_status(path: Path) -> dict:
|
|
813
|
+
try:
|
|
814
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
815
|
+
except OSError as error:
|
|
816
|
+
return {"path": str(path), "status": "stale", "reason": f"runner cannot be read: {error}", "refresh": "Regenerate from the reviewed VPS plan and inspect the diff."}
|
|
817
|
+
match = re.search(r"^# Maggie release runner: generator=([^ ]+) template=([a-f0-9]{16})$", content, re.MULTILINE)
|
|
818
|
+
current = release_runner_generator()
|
|
819
|
+
if not match:
|
|
820
|
+
return {"path": str(path), "status": "stale", "reason": "runner has no generator version/fingerprint", "currentGenerator": current, "refresh": "Regenerate from the reviewed VPS plan and inspect the diff before replacing this file."}
|
|
821
|
+
generated = {"version": match.group(1), "fingerprint": match.group(2)}
|
|
822
|
+
stale = generated != current
|
|
823
|
+
return {
|
|
824
|
+
"path": str(path),
|
|
825
|
+
"status": "stale" if stale else "current",
|
|
826
|
+
"generatedBy": generated,
|
|
827
|
+
"currentGenerator": current,
|
|
828
|
+
"reason": "runner generator version or template fingerprint has changed" if stale else "runner matches the current generator",
|
|
829
|
+
"refresh": "Regenerate from the reviewed VPS plan and inspect the diff before replacing this file." if stale else "",
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
|
|
552
833
|
def release_runner(plan: dict) -> str:
|
|
553
834
|
"""Render the ordered, reviewable VPS runner for one release."""
|
|
554
835
|
root = plan["release_root"]
|
|
555
836
|
service = plan["service"]
|
|
556
837
|
domain = plan["domain"]
|
|
557
838
|
current = plan["current_release"]
|
|
839
|
+
generator = release_runner_generator()
|
|
558
840
|
return f'''#!/usr/bin/env bash
|
|
559
841
|
set -euo pipefail
|
|
560
842
|
|
|
843
|
+
# Maggie release runner: generator={generator["version"]} template={generator["fingerprint"]}
|
|
844
|
+
|
|
561
845
|
# Migration precedes the symlink flip; later releases carry agent state.
|
|
562
846
|
: "${{RELEASE_ID:?set RELEASE_ID to an immutable release name}}"
|
|
563
847
|
RELEASE_KIND="${{RELEASE_KIND:-upgrade}}"
|
|
@@ -636,6 +920,12 @@ def main() -> int:
|
|
|
636
920
|
return credential_preflight_main(sys.argv[2:])
|
|
637
921
|
if sys.argv[1:2] == ["worker-secrets"]:
|
|
638
922
|
return worker_secret_preflight_main(sys.argv[2:])
|
|
923
|
+
if sys.argv[1:2] == ["secret-parity"]:
|
|
924
|
+
return secret_parity_main(sys.argv[2:])
|
|
925
|
+
if sys.argv[1:2] == ["secret-fingerprint"]:
|
|
926
|
+
return secret_fingerprint_main(sys.argv[2:])
|
|
927
|
+
if sys.argv[1:2] == ["env-isolation"]:
|
|
928
|
+
return environment_isolation_main(sys.argv[2:])
|
|
639
929
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
640
930
|
parser.add_argument("project", nargs="?", default=".")
|
|
641
931
|
parser.add_argument("--target", choices=("detected", "cloudflare", "vps", "vps-with-cloudflare-dns", "gcp", "aws"), default="detected")
|
|
@@ -653,6 +943,7 @@ def main() -> int:
|
|
|
653
943
|
parser.add_argument("--deployer-evidence", help="delegation evidence JSON; defaults to .maggie/deployment/deployer-delegation.json")
|
|
654
944
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
655
945
|
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
946
|
+
parser.add_argument("--check-runner", help="read-only check of a generated runner's Maggie version and template fingerprint")
|
|
656
947
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
657
948
|
parser.add_argument("--current-link", help="current symlink for --retention-plan")
|
|
658
949
|
parser.add_argument("--keep-releases", type=int, default=2)
|
|
@@ -665,6 +956,10 @@ def main() -> int:
|
|
|
665
956
|
parser.add_argument("--has-auth", action="store_true")
|
|
666
957
|
args = parser.parse_args()
|
|
667
958
|
try:
|
|
959
|
+
if args.check_runner:
|
|
960
|
+
report = release_runner_status(Path(args.check_runner).expanduser().resolve())
|
|
961
|
+
print(json.dumps(report, indent=2, ensure_ascii=False))
|
|
962
|
+
return 0
|
|
668
963
|
if args.verify_deployer:
|
|
669
964
|
if not args.deployer_host:
|
|
670
965
|
parser.error("--deployer-host is required with --verify-deployer")
|
|
@@ -854,9 +854,18 @@ commands:
|
|
|
854
854
|
resume restart a failed design workflow
|
|
855
855
|
author plan an original first-party page
|
|
856
856
|
markup-check validate optional style attributes, content keys, and entities
|
|
857
|
+
pressable-check flag pressables/buttons nested in another pressable (JSX/TSX)
|
|
858
|
+
layer-check flag unlayered CSS that overrides Tailwind v4 utilities
|
|
859
|
+
palette generate or validate a semantic palette role contract
|
|
857
860
|
reference-ui, init, validate-ui, app-init, app-validate, rebrand, review
|
|
858
861
|
""")
|
|
859
862
|
return 0
|
|
863
|
+
if len(sys.argv) > 1 and sys.argv[1] in {"pressable-check", "layer-check"}:
|
|
864
|
+
from maggie_design_lint import main as lint_main
|
|
865
|
+
return lint_main(sys.argv[1:])
|
|
866
|
+
if len(sys.argv) > 1 and sys.argv[1] == "palette":
|
|
867
|
+
from maggie_palette import main as palette_main
|
|
868
|
+
return palette_main(sys.argv[2:])
|
|
860
869
|
if len(sys.argv) > 1 and sys.argv[1] == "reference-ui":
|
|
861
870
|
command = argparse.ArgumentParser(description="Record sanitized blog/service UI evidence from a read-only reference project.")
|
|
862
871
|
command.add_argument("--project", type=Path, default=Path.cwd())
|