@topy-ai/maggie 0.7.30 → 0.7.32
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 +99 -17
- package/bin/maggie.js +105 -17
- package/bundled-references/google-integrations-runbook.md +26 -0
- package/bundled-skills/maggie-blog/SKILL.md +18 -1
- package/bundled-skills/maggie-dash/SKILL.md +18 -3
- package/bundled-skills/maggie-deployment/SKILL.md +72 -0
- package/bundled-skills/maggie-deployment/references/vps.md +26 -0
- package/bundled-skills/maggie-design/SKILL.md +16 -0
- package/bundled-skills/maggie-feedback/SKILL.md +11 -2
- package/bundled-skills/maggie-ops/SKILL.md +11 -2
- package/bundled-skills/maggie-seo-geo/SKILL.md +22 -1
- package/bundled-tools/clis/maggie.py +12 -0
- package/bundled-tools/clis/maggie_analytics.py +34 -0
- package/bundled-tools/clis/maggie_blog.py +13 -0
- package/bundled-tools/clis/maggie_dash.py +17 -0
- package/bundled-tools/clis/maggie_deployment.py +91 -7
- package/bundled-tools/clis/maggie_feedback.py +18 -4
- package/bundled-tools/clis/maggie_icon_release_gate.py +79 -0
- package/bundled-tools/clis/maggie_migration.py +71 -0
- package/bundled-tools/clis/maggie_release_manifest.py +81 -0
- package/bundled-tools/clis/maggie_runtime_preflight.py +76 -0
- package/bundled-tools/clis/site_audit.py +78 -5
- package/bundled-tools/integrations/analytics.md +20 -0
- package/bundled-tools/runtime/maggie_blog_publish.py +38 -0
- package/bundled-tools/runtime/maggie_dash_panels.py +111 -0
- package/bundled-tools/runtime/maggie_quality.py +1 -1
- package/bundled-tools/runtime/maggie_sections.py +39 -1
- package/bundled-tools/runtime/site_baseline.py +23 -2
- package/package.json +1 -1
- package/references/google-integrations-runbook.md +26 -0
|
@@ -68,6 +68,22 @@ cannot render the icon, add an approved runtime definition or inline SVG
|
|
|
68
68
|
fallback and rerun both checks. Never release a source token solely because
|
|
69
69
|
typecheck or build succeeded.
|
|
70
70
|
|
|
71
|
+
Compose the static inventory with browser-painted evidence before declaring a
|
|
72
|
+
design release complete:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
maggie design icon-release-gate \
|
|
76
|
+
--icon-report docs/icon-inventory.json \
|
|
77
|
+
--browser-report .maggie/icon-browser-evidence.json \
|
|
78
|
+
--output docs/icon-release-gate.json
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
The browser evidence must use `maggie-icon-browser-evidence.v1`, include at
|
|
82
|
+
least one route, and report each glyph as visible with positive dimensions,
|
|
83
|
+
a successful `assetStatus` (2xx), and a non-empty accessible name. This
|
|
84
|
+
gate is independent from typecheck/build and is read-only apart from its
|
|
85
|
+
explicit output.
|
|
86
|
+
|
|
71
87
|
## Automatic memory hook
|
|
72
88
|
|
|
73
89
|
Follow [Maggie Memory Hook](../../references/memory-hook.md) at invocation and completion.
|
|
@@ -57,8 +57,17 @@ fields and supplies the normal `type`, `summary`, `expected`, `actual`,
|
|
|
57
57
|
`errorFingerprint`, reproduction, resolution, validation, and screenshot
|
|
58
58
|
metadata fields. It may also provide bounded `routeIds` and
|
|
59
59
|
`validationEvidence` identifiers. Each generated draft stores only the safe
|
|
60
|
-
batch ID, zero-based index, and bounded size.
|
|
61
|
-
|
|
60
|
+
batch ID, canonical zero-based index, and bounded size. Direct `collect` calls
|
|
61
|
+
use zero-based `--batch-index` by default. If an external runner reports
|
|
62
|
+
one-based positions, pass `--batch-base 1`; the final item is then accepted and
|
|
63
|
+
normalized before it is stored. The batch command still creates local drafts
|
|
64
|
+
only.
|
|
65
|
+
|
|
66
|
+
For example, the third item in a three-item, one-based batch is collected as:
|
|
67
|
+
|
|
68
|
+
maggie feedback collect --project . --batch-id run-review-001 \
|
|
69
|
+
--batch-index 3 --batch-size 3 --batch-base 1 \
|
|
70
|
+
--summary "Final item" --type bug
|
|
62
71
|
|
|
63
72
|
Aggregate a batch before manual review:
|
|
64
73
|
|
|
@@ -187,8 +187,17 @@ npx @topy-ai/maggie update --project .
|
|
|
187
187
|
```
|
|
188
188
|
|
|
189
189
|
Use `--force` only after the user explicitly approves replacing locally
|
|
190
|
-
modified managed files.
|
|
191
|
-
|
|
190
|
+
modified managed files. The update output reports preserved files and a bounded
|
|
191
|
+
diff summary for text files so the operator can distinguish stale package
|
|
192
|
+
content from a project customization before choosing `--force`. It never
|
|
193
|
+
publishes the full local file or secrets. An npm skill update does not
|
|
194
|
+
automatically deploy the dashboard or migrate a production database.
|
|
195
|
+
|
|
196
|
+
`maggie doctor` reports the managed skill inventory without changing files. If
|
|
197
|
+
the manifest and on-disk skill names disagree, it prints the missing or extra
|
|
198
|
+
names and exits non-zero; resolve that mismatch before continuing with a
|
|
199
|
+
mutation. A diagnostic result without an installed manifest does not claim
|
|
200
|
+
that bootstrap is complete.
|
|
192
201
|
|
|
193
202
|
### Verify
|
|
194
203
|
|
|
@@ -118,6 +118,22 @@ with a reviewer. Commit the approved change and new contract together; never
|
|
|
118
118
|
automatically replace the baseline following failure. Dynamic dates, class
|
|
119
119
|
names and copy can produce legitimate differences requiring review.
|
|
120
120
|
|
|
121
|
+
To approve an intentional drift, write a new file and require a reason for
|
|
122
|
+
every changed, added, removed, or content-changed URL:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
maggie site-audit https://example.com --crawl --json \
|
|
126
|
+
--baseline docs/seo-baseline-v1.json \
|
|
127
|
+
--recapture-baseline docs/seo-baseline-v2.json \
|
|
128
|
+
--baseline-id seo-v2 --reviewer maintainer \
|
|
129
|
+
--reason 'https://example.com/pricing=approved pricing refresh'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The command fails closed when the current crawl is incomplete, a drift has no
|
|
133
|
+
reason, a reason names a URL without a reported drift, or the destination file
|
|
134
|
+
already exists. The new contract records its `baselineId`, `supersedes` value,
|
|
135
|
+
reviewer, and approved reasons; the old baseline is never overwritten.
|
|
136
|
+
|
|
121
137
|
This covers server-rendered sitemap pages, not CSS rendering, JavaScript-only
|
|
122
138
|
content, database translation keys or browser interactions. Query-string URLs
|
|
123
139
|
are recorded as excluded from byte baselines because they often represent
|
|
@@ -256,7 +272,12 @@ metadata, valid entity JSON-LD, and image alt text. Non-HTML endpoints such as
|
|
|
256
272
|
RSS must not be included in the HTML page sitemap. Sitemap `<loc>` values must
|
|
257
273
|
be absolute HTTP(S) URLs; the audit reports relative values as a failure even
|
|
258
274
|
when it can resolve them for continued crawling, so one malformed entry cannot
|
|
259
|
-
hide a protocol violation.
|
|
275
|
+
hide a protocol violation. Absolute sitemap locations are rebased to the
|
|
276
|
+
origin passed to `site-audit` by default, so `--crawl` cannot silently audit
|
|
277
|
+
production from a staging/local command. The JSON crawl evidence records
|
|
278
|
+
`requestedOrigin` and `sitemapOriginPolicy: "requested-origin"`. Use
|
|
279
|
+
`--preserve-sitemap-origin` only when an explicitly reviewed cross-origin crawl
|
|
280
|
+
is intended; the report then says `"declared-origin"`.
|
|
260
281
|
Use `--output <project>/docs/site-audit.json` to persist the evidence used by
|
|
261
282
|
the release review.
|
|
262
283
|
|
|
@@ -933,6 +933,13 @@ def command_fixture(args: argparse.Namespace) -> int:
|
|
|
933
933
|
return 0
|
|
934
934
|
|
|
935
935
|
|
|
936
|
+
def command_feedback(args: argparse.Namespace) -> int:
|
|
937
|
+
"""Dispatch feedback subcommands through the canonical feedback CLI."""
|
|
938
|
+
cli = Path(__file__).with_name("maggie_feedback.py")
|
|
939
|
+
completed = subprocess.run([sys.executable, str(cli), *args.feedback_args], cwd=Path.cwd(), check=False)
|
|
940
|
+
return completed.returncode
|
|
941
|
+
|
|
942
|
+
|
|
936
943
|
def parser() -> argparse.ArgumentParser:
|
|
937
944
|
p = argparse.ArgumentParser(prog="maggie", description=__doc__)
|
|
938
945
|
sub = p.add_subparsers(dest="command", required=True)
|
|
@@ -984,6 +991,9 @@ def parser() -> argparse.ArgumentParser:
|
|
|
984
991
|
item = fixture_sub.add_parser(fixture_name)
|
|
985
992
|
item.add_argument("project", nargs="?", default=".")
|
|
986
993
|
item.set_defaults(func=command_fixture)
|
|
994
|
+
feedback = sub.add_parser("feedback", help="collect, review, and submit privacy-safe feedback")
|
|
995
|
+
feedback.add_argument("feedback_args", nargs=argparse.REMAINDER, help="arguments for maggie_feedback.py")
|
|
996
|
+
feedback.set_defaults(func=command_feedback)
|
|
987
997
|
doctor = sub.add_parser("doctor", help="check blog routes and SEO output invariants")
|
|
988
998
|
doctor.add_argument("project", nargs="?", default=".")
|
|
989
999
|
doctor.add_argument("--require-bootstrap", action="store_true")
|
|
@@ -1121,5 +1131,7 @@ def parser() -> argparse.ArgumentParser:
|
|
|
1121
1131
|
|
|
1122
1132
|
|
|
1123
1133
|
if __name__ == "__main__":
|
|
1134
|
+
if sys.argv[1:2] == ["feedback"]:
|
|
1135
|
+
raise SystemExit(command_feedback(argparse.Namespace(feedback_args=sys.argv[2:])))
|
|
1124
1136
|
arguments = parser().parse_args()
|
|
1125
1137
|
raise SystemExit(arguments.func(arguments))
|
|
@@ -67,6 +67,18 @@ def release_gate(args: argparse.Namespace) -> int:
|
|
|
67
67
|
check("referral-contract", set(contract["referralSources"]).issubset({"search", "partner", "social", "email", "direct", "unknown"}), "referral categories use the redacted taxonomy", checks)
|
|
68
68
|
else:
|
|
69
69
|
check("referral-contract", False, "contract must declare referralSources", checks)
|
|
70
|
+
consent = contract.get("consent")
|
|
71
|
+
valid_consent = (
|
|
72
|
+
isinstance(consent, dict)
|
|
73
|
+
and consent.get("defaultBeforeContainer") is True
|
|
74
|
+
and consent.get("firstPartyStorageMode") in {"stop", "degrade"}
|
|
75
|
+
and consent.get("intentionalFormMode") in {"record", "omit"}
|
|
76
|
+
)
|
|
77
|
+
check(
|
|
78
|
+
"consent-contract", valid_consent,
|
|
79
|
+
"consent defaults precede the container and first-party/form behavior is explicit",
|
|
80
|
+
checks,
|
|
81
|
+
)
|
|
70
82
|
browser, browser_errors = load_json(Path(args.render_report).resolve(), "render-report")
|
|
71
83
|
errors.extend(browser_errors)
|
|
72
84
|
network, network_errors = load_json(Path(args.network_report).resolve(), "network-report")
|
|
@@ -79,6 +91,28 @@ def release_gate(args: argparse.Namespace) -> int:
|
|
|
79
91
|
check("browser-schema", browser.get("schemaVersion") == "maggie-analytics-browser.v1", "versioned browser evidence", checks)
|
|
80
92
|
check("browser-render", browser.get("passed") is True and isinstance(browser.get("routes"), list) and bool(browser["routes"]), "routes rendered without a browser failure", checks)
|
|
81
93
|
check("browser-console", isinstance(browser.get("consoleErrors"), list) and not browser["consoleErrors"], "no browser console errors", checks)
|
|
94
|
+
consent = contract.get("consent") if contract else None
|
|
95
|
+
browser_consent = browser.get("consent")
|
|
96
|
+
check(
|
|
97
|
+
"consent-ordering", isinstance(consent, dict) and isinstance(browser_consent, dict)
|
|
98
|
+
and browser_consent.get("defaultBeforeContainer") is True
|
|
99
|
+
and browser_consent.get("containerLoadedAfterDefault") is True,
|
|
100
|
+
"browser evidence proves default consent precedes container loading",
|
|
101
|
+
checks,
|
|
102
|
+
)
|
|
103
|
+
check(
|
|
104
|
+
"first-party-scope", isinstance(consent, dict) and isinstance(browser_consent, dict)
|
|
105
|
+
and browser_consent.get("firstPartyStorageMode") == consent.get("firstPartyStorageMode")
|
|
106
|
+
and browser_consent.get("firstPartyStorageObserved") is True,
|
|
107
|
+
"browser evidence proves the declared first-party storage behavior",
|
|
108
|
+
checks,
|
|
109
|
+
)
|
|
110
|
+
check(
|
|
111
|
+
"intentional-form-scope", isinstance(consent, dict) and isinstance(browser_consent, dict)
|
|
112
|
+
and browser_consent.get("intentionalFormMode") == consent.get("intentionalFormMode"),
|
|
113
|
+
"browser evidence records the declared intentional-form behavior",
|
|
114
|
+
checks,
|
|
115
|
+
)
|
|
82
116
|
if network:
|
|
83
117
|
pre = network.get("preConsentRequests")
|
|
84
118
|
check("pre-consent-network", isinstance(pre, list) and not pre, "no analytics requests before consent", checks)
|
|
@@ -12,6 +12,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "runtime"))
|
|
|
12
12
|
from maggie_blog import BlogStore, review_settings_from_adapter # noqa: E402
|
|
13
13
|
from localization_runner import process_adapter
|
|
14
14
|
from integration_state import integration_state # noqa: E402
|
|
15
|
+
from maggie_blog_publish import validate_auto_publish # noqa: E402
|
|
15
16
|
|
|
16
17
|
|
|
17
18
|
def main() -> int:
|
|
@@ -28,6 +29,9 @@ def main() -> int:
|
|
|
28
29
|
ingest = sub.add_parser("ingest"); ingest.add_argument("--project", type=Path, default=Path.cwd()); ingest.add_argument("--input", type=Path, required=True); ingest.add_argument("--source", default="local"); ingest.add_argument("--confirm", action="store_true")
|
|
29
30
|
validate = sub.add_parser("validate"); validate.add_argument("--project", type=Path, default=Path.cwd())
|
|
30
31
|
gate = sub.add_parser("check-gate", help="report whether generated content requires review before publication"); gate.add_argument("--project", type=Path, default=Path.cwd()); gate.add_argument("--settings-file", type=Path, help="sanitized host settings JSON"); gate.add_argument("--adapter-command", help="trusted provider JSON argv array that prints sanitized settings JSON"); gate.add_argument("--timeout", type=int, default=120)
|
|
32
|
+
auto_gate = sub.add_parser("auto-publish-gate", help="validate explicit opt-in evidence before an automated publish")
|
|
33
|
+
auto_gate.add_argument("--evidence", type=Path, required=True)
|
|
34
|
+
auto_gate.add_argument("--output", type=Path, required=True)
|
|
31
35
|
approve = sub.add_parser("approve"); approve.add_argument("--project", type=Path, default=Path.cwd()); approve.add_argument("--slug", required=True); approve.add_argument("--actor", required=True); approve.add_argument("--reason", required=True); approve.add_argument("--confirm", action="store_true")
|
|
32
36
|
publish = sub.add_parser("publish"); publish.add_argument("--project", type=Path, default=Path.cwd()); publish.add_argument("--slug", required=True); publish.add_argument("--actor", required=True); publish.add_argument("--reason", required=True); publish.add_argument("--confirm", action="store_true")
|
|
33
37
|
sitemap = sub.add_parser("sitemap"); sitemap.add_argument("--project", type=Path, default=Path.cwd())
|
|
@@ -35,6 +39,15 @@ def main() -> int:
|
|
|
35
39
|
state = sub.add_parser("integration-state"); state.add_argument("--configured", action="store_true"); state.add_argument("--consent-required", action="store_true"); state.add_argument("--consent", action="store_true"); state.add_argument("--authorized", action="store_true"); state.add_argument("--error")
|
|
36
40
|
rollback = sub.add_parser("rollback"); rollback.add_argument("--project", type=Path, default=Path.cwd()); rollback.add_argument("--backup"); rollback.add_argument("--confirm", action="store_true")
|
|
37
41
|
args = parser.parse_args()
|
|
42
|
+
if args.command == "auto-publish-gate":
|
|
43
|
+
try:
|
|
44
|
+
result = validate_auto_publish(json.loads(args.evidence.resolve().read_text(encoding="utf-8")))
|
|
45
|
+
args.output.resolve().parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
args.output.resolve().write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
47
|
+
except (OSError, ValueError, json.JSONDecodeError) as error:
|
|
48
|
+
print(f"maggie-blog: {error}", file=sys.stderr); return 1
|
|
49
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
50
|
+
return 0 if result["passed"] else 1
|
|
38
51
|
if args.command == "integration-state":
|
|
39
52
|
result = integration_state(configured=args.configured, consent_required=args.consent_required, consent=args.consent, authorized=args.authorized, error=args.error)
|
|
40
53
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
@@ -27,6 +27,7 @@ from maggie_sections import catalogue, remap_translations, section_id_migration,
|
|
|
27
27
|
from maggie_quality import validate_variant_copy, validate_media_uniqueness, classify_inventory, validate_bindings, validate_reconcile_contract # noqa: E402
|
|
28
28
|
from maggie_api_contract import load_api_contract_source, validate_api_contract # noqa: E402
|
|
29
29
|
from maggie_dash_runtime import validate_runtime_evidence # noqa: E402
|
|
30
|
+
from maggie_dash_panels import validate_panel_report # noqa: E402
|
|
30
31
|
from maggie_schema_audit import audit_schema_inventory # noqa: E402
|
|
31
32
|
from route_imports import classify_bindings # noqa: E402
|
|
32
33
|
|
|
@@ -374,6 +375,18 @@ def command_ui(args: argparse.Namespace) -> int:
|
|
|
374
375
|
return 0 if result["passed"] else 1
|
|
375
376
|
|
|
376
377
|
|
|
378
|
+
def command_panels(args: argparse.Namespace) -> int:
|
|
379
|
+
value = json.loads(Path(args.report).resolve().read_text(encoding="utf-8"))
|
|
380
|
+
result = validate_panel_report(value)
|
|
381
|
+
if args.output:
|
|
382
|
+
output = Path(args.output).resolve()
|
|
383
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
384
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
385
|
+
result["output"] = str(output)
|
|
386
|
+
emit(result)
|
|
387
|
+
return 0 if result["passed"] else 1
|
|
388
|
+
|
|
389
|
+
|
|
377
390
|
def command_sections(args: argparse.Namespace) -> int:
|
|
378
391
|
if args.sections_command == "remap-translations":
|
|
379
392
|
require_confirm(args)
|
|
@@ -711,6 +724,10 @@ def parser() -> argparse.ArgumentParser:
|
|
|
711
724
|
conformance.add_argument("--evidence", required=True, help="sanitized endpoint observations JSON")
|
|
712
725
|
conformance.add_argument("--output")
|
|
713
726
|
conformance.set_defaults(func=command_conformance)
|
|
727
|
+
panels = sub.add_parser("panels-validate", help="validate dashboard panel source and freshness evidence")
|
|
728
|
+
panels.add_argument("--report", required=True, help="sanitized measurement-panel evidence JSON")
|
|
729
|
+
panels.add_argument("--output")
|
|
730
|
+
panels.set_defaults(func=command_panels)
|
|
714
731
|
schema_audit = sub.add_parser("schema-audit", help="find declared tables with missing reader evidence")
|
|
715
732
|
schema_audit.add_argument("--inventory", required=True, help="host-produced schema inventory JSON")
|
|
716
733
|
schema_audit.add_argument("--fail-on-unread", action="store_true")
|
|
@@ -6,10 +6,77 @@ import argparse
|
|
|
6
6
|
import json
|
|
7
7
|
import re
|
|
8
8
|
import subprocess
|
|
9
|
+
import sys
|
|
9
10
|
from datetime import datetime, timezone
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
DEPLOYMENT_CREDENTIALS = (
|
|
15
|
+
"DEPLOY_SERVER_IP",
|
|
16
|
+
"DEPLOY_SERVER_SSH_USER",
|
|
17
|
+
"DEPLOY_SERVER_SSH_PASSWORD",
|
|
18
|
+
"DEPLOY_SERVER_SSH_SUDOER",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_env_file(path: Path) -> dict[str, str]:
|
|
23
|
+
values: dict[str, str] = {}
|
|
24
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
25
|
+
line = line.strip()
|
|
26
|
+
if not line or line.startswith("#"):
|
|
27
|
+
continue
|
|
28
|
+
if line.startswith("export "):
|
|
29
|
+
line = line[7:].lstrip()
|
|
30
|
+
if "=" not in line:
|
|
31
|
+
continue
|
|
32
|
+
key, value = line.split("=", 1)
|
|
33
|
+
values[key.strip()] = value.strip().strip("\"'")
|
|
34
|
+
return values
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def credential_preflight(env_file: Path, expected_host: str | None = None) -> dict:
|
|
38
|
+
"""Check deployment variable presence without returning secret values."""
|
|
39
|
+
errors: list[str] = []
|
|
40
|
+
try:
|
|
41
|
+
values = parse_env_file(env_file)
|
|
42
|
+
except OSError as error:
|
|
43
|
+
values = {}
|
|
44
|
+
errors.append(f"cannot read env file: {error}")
|
|
45
|
+
present = {name: bool(values.get(name, "").strip()) for name in DEPLOYMENT_CREDENTIALS}
|
|
46
|
+
if not all(present.values()):
|
|
47
|
+
errors.append("required deployment credentials are missing from the env file")
|
|
48
|
+
host_guard = {"status": "not_requested", "matches": None}
|
|
49
|
+
if expected_host:
|
|
50
|
+
host_guard = {"status": "passed" if values.get("DEPLOY_SERVER_IP") == expected_host else "failed", "matches": values.get("DEPLOY_SERVER_IP") == expected_host}
|
|
51
|
+
if host_guard["status"] == "failed":
|
|
52
|
+
errors.append("DEPLOY_SERVER_IP does not match --expected-host")
|
|
53
|
+
return {
|
|
54
|
+
"schemaVersion": "maggie-deployment-credentials.v1",
|
|
55
|
+
"envFileProvided": True,
|
|
56
|
+
"credentialSource": "project-env-file",
|
|
57
|
+
"variables": present,
|
|
58
|
+
"hostGuard": host_guard,
|
|
59
|
+
"passed": not errors,
|
|
60
|
+
"errors": errors,
|
|
61
|
+
"mutation": "not executed",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def credential_preflight_main(argv: list[str]) -> int:
|
|
66
|
+
parser = argparse.ArgumentParser(description="Check named deployment credentials without printing their values.")
|
|
67
|
+
parser.add_argument("--env-file", required=True)
|
|
68
|
+
parser.add_argument("--expected-host")
|
|
69
|
+
parser.add_argument("--output")
|
|
70
|
+
args = parser.parse_args(argv)
|
|
71
|
+
result = credential_preflight(Path(args.env_file).expanduser().resolve(), args.expected_host)
|
|
72
|
+
if args.output:
|
|
73
|
+
output = Path(args.output).resolve()
|
|
74
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
76
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
77
|
+
return 0 if result["passed"] else 1
|
|
78
|
+
|
|
79
|
+
|
|
13
80
|
def preflight(project: Path, target: str, environment: str) -> dict:
|
|
14
81
|
package_path = project / "package.json"
|
|
15
82
|
package = json.loads(package_path.read_text(encoding="utf-8")) if package_path.exists() else {}
|
|
@@ -78,23 +145,37 @@ def preflight(project: Path, target: str, environment: str) -> dict:
|
|
|
78
145
|
return result
|
|
79
146
|
|
|
80
147
|
|
|
81
|
-
def vps_plan(domain: str, service: str, release_root: str, node_port: int | None) -> dict:
|
|
148
|
+
def vps_plan(domain: str, service: str, release_root: str, node_port: int | None, deployer_user: str = "maggie-deploy") -> dict:
|
|
82
149
|
"""Create reviewable, secret-free VPS service and reverse-proxy artifacts."""
|
|
83
150
|
if not re.fullmatch(r"[A-Za-z0-9](?:[A-Za-z0-9.-]*[A-Za-z0-9])?", domain):
|
|
84
151
|
raise ValueError("domain must be a hostname without a scheme or path")
|
|
85
152
|
if not re.fullmatch(r"[A-Za-z0-9_.@-]+", service):
|
|
86
153
|
raise ValueError("service must contain only safe systemd name characters")
|
|
154
|
+
if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}\$?", deployer_user):
|
|
155
|
+
raise ValueError("deployer user must be a safe local account name")
|
|
87
156
|
if node_port is None:
|
|
88
157
|
raise ValueError("node port must be supplied explicitly; choose an unused port for this host")
|
|
89
158
|
if not 1024 <= node_port <= 65535:
|
|
90
159
|
raise ValueError("node port must be between 1024 and 65535")
|
|
91
160
|
root = release_root.rstrip("/")
|
|
92
161
|
current = f"{root}/current"
|
|
162
|
+
privileged_commands = [
|
|
163
|
+
f"/bin/systemctl restart {service}",
|
|
164
|
+
f"/bin/systemctl is-active {service}",
|
|
165
|
+
]
|
|
93
166
|
return {
|
|
94
167
|
"schema_version": "1.0",
|
|
95
168
|
"provider": "vps-with-cloudflare-dns",
|
|
96
169
|
"domain": domain,
|
|
97
170
|
"service": service,
|
|
171
|
+
"deployer_user": deployer_user,
|
|
172
|
+
"application_user": "www-data",
|
|
173
|
+
"privileged_commands": privileged_commands,
|
|
174
|
+
"sudoers": {
|
|
175
|
+
"user": deployer_user,
|
|
176
|
+
"commands": privileged_commands,
|
|
177
|
+
"policy": "exact service commands only; no shell, wildcard, or broad sudo",
|
|
178
|
+
},
|
|
98
179
|
"release_root": root,
|
|
99
180
|
"current_release": current,
|
|
100
181
|
"node_port": node_port,
|
|
@@ -111,9 +192,9 @@ def vps_plan(domain: str, service: str, release_root: str, node_port: int | None
|
|
|
111
192
|
"commands": {
|
|
112
193
|
"stage": "npm ci && npm run typecheck && npm run build",
|
|
113
194
|
"switch": f"ln -sfn {root}/releases/<release> {current}",
|
|
114
|
-
"restart": f"systemctl restart {service}",
|
|
115
|
-
"verify": f"systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
|
|
116
|
-
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && systemctl restart {service}",
|
|
195
|
+
"restart": f"sudo -n /bin/systemctl restart {service}",
|
|
196
|
+
"verify": f"sudo -n /bin/systemctl is-active {service} && curl -fsS https://{domain}/robots.txt && curl -fsS https://{domain}/sitemap.xml",
|
|
197
|
+
"rollback": f"ln -sfn {root}/releases/<previous-release> {current} && sudo -n /bin/systemctl restart {service}",
|
|
117
198
|
"data_rollback": "restore the matching data checkpoint before restarting the previous code release",
|
|
118
199
|
},
|
|
119
200
|
"files": {
|
|
@@ -285,8 +366,8 @@ if [ -d "$CURRENT/.agents" ]; then cp -a "$CURRENT/.agents" "$RELEASE/.agents";
|
|
|
285
366
|
if [ -d "$CURRENT/.claude" ]; then cp -a "$CURRENT/.claude" "$RELEASE/.claude"; fi
|
|
286
367
|
|
|
287
368
|
ln -sfn "$RELEASE" "$CURRENT"
|
|
288
|
-
systemctl restart "{service}"
|
|
289
|
-
systemctl is-active --quiet "{service}"
|
|
369
|
+
sudo -n /bin/systemctl restart "{service}"
|
|
370
|
+
sudo -n /bin/systemctl is-active --quiet "{service}"
|
|
290
371
|
curl -fsS "https://{domain}/robots.txt" >/dev/null
|
|
291
372
|
curl -fsS "https://{domain}/sitemap.xml" >/dev/null
|
|
292
373
|
|
|
@@ -307,6 +388,8 @@ def write_release_runner(path: Path, plan: dict) -> None:
|
|
|
307
388
|
|
|
308
389
|
|
|
309
390
|
def main() -> int:
|
|
391
|
+
if sys.argv[1:2] == ["credential-preflight"]:
|
|
392
|
+
return credential_preflight_main(sys.argv[2:])
|
|
310
393
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
311
394
|
parser.add_argument("project", nargs="?", default=".")
|
|
312
395
|
parser.add_argument("--target", choices=("detected", "cloudflare", "vps", "vps-with-cloudflare-dns", "gcp", "aws"), default="detected")
|
|
@@ -317,6 +400,7 @@ def main() -> int:
|
|
|
317
400
|
parser.add_argument("--service", default="maggie-site", help="systemd service name")
|
|
318
401
|
parser.add_argument("--release-root", default="/var/www/maggie-site", help="immutable release root")
|
|
319
402
|
parser.add_argument("--node-port", type=int, required=False, default=None, help="unused host port; required with --vps-plan")
|
|
403
|
+
parser.add_argument("--deployer-user", default="maggie-deploy", help="dedicated least-privilege SSH/deploy account")
|
|
320
404
|
parser.add_argument("--plan-dir", help="directory for generated VPS artifacts")
|
|
321
405
|
parser.add_argument("--runner-output", help="write a reviewable ordered VPS release runner")
|
|
322
406
|
parser.add_argument("--retention-plan", action="store_true", help="create a read-only release prune candidate plan")
|
|
@@ -334,7 +418,7 @@ def main() -> int:
|
|
|
334
418
|
if args.vps_plan:
|
|
335
419
|
if not args.domain:
|
|
336
420
|
parser.error("--domain is required with --vps-plan")
|
|
337
|
-
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port)
|
|
421
|
+
plan = vps_plan(args.domain, args.service, args.release_root, args.node_port, args.deployer_user)
|
|
338
422
|
if args.plan_dir:
|
|
339
423
|
write_vps_plan(Path(args.plan_dir).resolve(), plan)
|
|
340
424
|
if args.runner_output:
|
|
@@ -151,10 +151,20 @@ def collect(args: argparse.Namespace) -> int:
|
|
|
151
151
|
if batch_id:
|
|
152
152
|
if not SAFE_BATCH_ID_RE.fullmatch(batch_id):
|
|
153
153
|
raise ValueError("batch ID is invalid")
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
154
|
+
batch_base = getattr(args, "batch_base", 0)
|
|
155
|
+
if batch_base not in (0, 1):
|
|
156
|
+
raise ValueError("batch base must be 0 or 1")
|
|
157
|
+
if not isinstance(batch_index, int) or isinstance(batch_index, bool):
|
|
158
|
+
raise ValueError(f"batch index is required and must be {batch_base}-based when batch ID is provided")
|
|
159
|
+
if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 1:
|
|
160
|
+
raise ValueError("batch size must be a positive integer")
|
|
161
|
+
if batch_base == 1 and not 1 <= batch_index <= batch_size:
|
|
162
|
+
raise ValueError(f"1-based batch index must be between 1 and {batch_size} inclusive")
|
|
163
|
+
if batch_base == 0 and not 0 <= batch_index < batch_size:
|
|
164
|
+
raise ValueError(f"0-based batch index must be between 0 and {batch_size - 1} inclusive; use --batch-base 1 for 1-based input")
|
|
165
|
+
# Persist one canonical representation so batch review and the hosted
|
|
166
|
+
# API do not have to guess which indexing convention was used.
|
|
167
|
+
batch_index -= batch_base
|
|
158
168
|
priority = safe_text(getattr(args, "priority", "") or "normal").lower()
|
|
159
169
|
if priority not in {"low", "normal", "high", "critical"}:
|
|
160
170
|
raise ValueError("priority must be low, normal, high, or critical")
|
|
@@ -428,6 +438,10 @@ def main() -> int:
|
|
|
428
438
|
collect_parser.add_argument("--batch-id", default="")
|
|
429
439
|
collect_parser.add_argument("--batch-index", type=int)
|
|
430
440
|
collect_parser.add_argument("--batch-size", type=int)
|
|
441
|
+
collect_parser.add_argument(
|
|
442
|
+
"--batch-base", type=int, choices=(0, 1), default=0,
|
|
443
|
+
help="indexing convention for --batch-index (default: 0; use 1 for 1-based input)",
|
|
444
|
+
)
|
|
431
445
|
collect_parser.add_argument("--priority", choices=("low", "normal", "high", "critical"), default="normal")
|
|
432
446
|
collect_parser.add_argument("--affected-cli", default="")
|
|
433
447
|
collect_parser.add_argument("--route-id", dest="route_ids", action="append", default=[])
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Validate the complete source-to-browser icon release gate."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import argparse
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def validate(icon_report: object, browser_report: object) -> dict[str, Any]:
|
|
13
|
+
errors: list[str] = []
|
|
14
|
+
if not isinstance(icon_report, dict):
|
|
15
|
+
errors.append("icon report must be an object")
|
|
16
|
+
icon_report = {}
|
|
17
|
+
if not isinstance(browser_report, dict):
|
|
18
|
+
errors.append("browser report must be an object")
|
|
19
|
+
browser_report = {}
|
|
20
|
+
source_ok = (
|
|
21
|
+
icon_report.get("schemaVersion") == "maggie-icon-inventory.v1"
|
|
22
|
+
and icon_report.get("status") == "pass"
|
|
23
|
+
and icon_report.get("missing") == []
|
|
24
|
+
and icon_report.get("unknown") == []
|
|
25
|
+
)
|
|
26
|
+
if not source_ok:
|
|
27
|
+
errors.append("source/runtime icon inventory did not pass")
|
|
28
|
+
browser_ok = browser_report.get("schemaVersion") == "maggie-icon-browser-evidence.v1" and browser_report.get("passed") is True and isinstance(browser_report.get("routes"), list) and bool(browser_report["routes"])
|
|
29
|
+
if not browser_ok:
|
|
30
|
+
errors.append("browser icon evidence did not pass")
|
|
31
|
+
icons = browser_report.get("icons") if isinstance(browser_report.get("icons"), list) else []
|
|
32
|
+
if not icons:
|
|
33
|
+
errors.append("browser evidence must include painted icons")
|
|
34
|
+
invalid: list[str] = []
|
|
35
|
+
for index, icon in enumerate(icons):
|
|
36
|
+
name = str(icon.get("name") or f"icon-{index}") if isinstance(icon, dict) else f"icon-{index}"
|
|
37
|
+
if not isinstance(icon, dict) or icon.get("visible") is not True or not isinstance(icon.get("width"), (int, float)) or icon["width"] <= 0 or not isinstance(icon.get("height"), (int, float)) or icon["height"] <= 0 or not str(icon.get("accessibleName") or "").strip():
|
|
38
|
+
invalid.append(name)
|
|
39
|
+
if not isinstance(icon, dict) or not isinstance(icon.get("assetStatus"), int) or not 200 <= icon["assetStatus"] <= 299:
|
|
40
|
+
invalid.append(name)
|
|
41
|
+
if invalid:
|
|
42
|
+
errors.append("browser icon evidence has invisible, zero-size, inaccessible, or unserved glyphs")
|
|
43
|
+
return {
|
|
44
|
+
"schemaVersion": "maggie-icon-release-gate.v1",
|
|
45
|
+
"passed": not errors,
|
|
46
|
+
"errors": sorted(set(errors)),
|
|
47
|
+
"checks": {
|
|
48
|
+
"sourceRuntime": source_ok,
|
|
49
|
+
"servedAssets": browser_ok and not bool(invalid),
|
|
50
|
+
"paintedGlyphs": bool(icons) and not bool(invalid),
|
|
51
|
+
"accessibility": bool(icons) and not bool(invalid),
|
|
52
|
+
},
|
|
53
|
+
"iconCount": len(icons),
|
|
54
|
+
"invalidIcons": sorted(set(invalid)),
|
|
55
|
+
"mutation": "not executed",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def main() -> int:
|
|
60
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
61
|
+
parser.add_argument("--icon-report", required=True)
|
|
62
|
+
parser.add_argument("--browser-report", required=True)
|
|
63
|
+
parser.add_argument("--output", required=True)
|
|
64
|
+
args = parser.parse_args()
|
|
65
|
+
try:
|
|
66
|
+
icon = json.loads(Path(args.icon_report).resolve().read_text(encoding="utf-8"))
|
|
67
|
+
browser = json.loads(Path(args.browser_report).resolve().read_text(encoding="utf-8"))
|
|
68
|
+
result = validate(icon, browser)
|
|
69
|
+
output = Path(args.output).resolve()
|
|
70
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
72
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
73
|
+
result = {"schemaVersion": "maggie-icon-release-gate.v1", "passed": False, "errors": [f"cannot read icon evidence: {error}"], "mutation": "not executed"}
|
|
74
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
75
|
+
return 0 if result["passed"] else 1
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
if __name__ == "__main__":
|
|
79
|
+
raise SystemExit(main())
|
|
@@ -58,6 +58,8 @@ def validate_project(project: Path, environment: str) -> dict:
|
|
|
58
58
|
IDENTITY_SCHEMA = "maggie-database-identity.v1"
|
|
59
59
|
PREFLIGHT_SCHEMA = "maggie-migration-preflight.v1"
|
|
60
60
|
SAFE_NAME = re.compile(r"^[A-Za-z][A-Za-z0-9_.:-]{0,119}$")
|
|
61
|
+
LEDGER_SCHEMA = "maggie-migration-ledger.v1"
|
|
62
|
+
MIGRATION_FILE = re.compile(r"^(\d{1,9})[_-][A-Za-z0-9][A-Za-z0-9_.-]*\.sql$")
|
|
61
63
|
|
|
62
64
|
|
|
63
65
|
def _identity_fingerprint(identity: dict) -> str:
|
|
@@ -160,11 +162,80 @@ def preflight_main(argv: list[str]) -> int:
|
|
|
160
162
|
print(json.dumps(result, indent=2)); return 0 if not errors else 1
|
|
161
163
|
|
|
162
164
|
|
|
165
|
+
def reconcile_main(argv: list[str]) -> int:
|
|
166
|
+
"""Compare host-reported applied migrations with local migration files."""
|
|
167
|
+
parser = argparse.ArgumentParser(description="Build a read-only ordered plan for migrations missing from the server ledger.")
|
|
168
|
+
parser.add_argument("--evidence", required=True, help="host-produced ledger evidence JSON")
|
|
169
|
+
parser.add_argument("--migration-dir", required=True, help="directory containing numbered .sql migrations")
|
|
170
|
+
parser.add_argument("--output")
|
|
171
|
+
args = parser.parse_args(argv)
|
|
172
|
+
errors: list[str] = []
|
|
173
|
+
try:
|
|
174
|
+
evidence = json.loads(Path(args.evidence).read_text(encoding="utf-8"))
|
|
175
|
+
except (OSError, json.JSONDecodeError) as error:
|
|
176
|
+
evidence = {}
|
|
177
|
+
errors.append(f"cannot read migration ledger evidence: {error}")
|
|
178
|
+
if not isinstance(evidence, dict):
|
|
179
|
+
evidence = {}
|
|
180
|
+
errors.append("migration ledger evidence must be an object")
|
|
181
|
+
if evidence.get("schemaVersion") != LEDGER_SCHEMA:
|
|
182
|
+
errors.append(f"schemaVersion must be {LEDGER_SCHEMA}")
|
|
183
|
+
if evidence.get("migrationTable") != "maggie_schema_migrations":
|
|
184
|
+
errors.append("migrationTable must be maggie_schema_migrations")
|
|
185
|
+
if evidence.get("environment") not in {"development", "staging", "production"}:
|
|
186
|
+
errors.append("environment is invalid")
|
|
187
|
+
applied = evidence.get("appliedVersions")
|
|
188
|
+
if not isinstance(applied, list) or any(not re.fullmatch(r"\d{1,9}", str(item)) for item in applied) or len(set(map(str, applied))) != len(applied):
|
|
189
|
+
errors.append("appliedVersions must be a unique array of numeric versions")
|
|
190
|
+
applied = []
|
|
191
|
+
applied_set = {str(item).zfill(3) for item in applied}
|
|
192
|
+
files: list[tuple[str, str]] = []
|
|
193
|
+
migration_dir = Path(args.migration_dir).resolve()
|
|
194
|
+
try:
|
|
195
|
+
entries = sorted(migration_dir.iterdir())
|
|
196
|
+
except OSError as error:
|
|
197
|
+
entries = []
|
|
198
|
+
errors.append(f"cannot read migration directory: {error}")
|
|
199
|
+
for entry in entries:
|
|
200
|
+
if not entry.is_file():
|
|
201
|
+
continue
|
|
202
|
+
match = MIGRATION_FILE.fullmatch(entry.name)
|
|
203
|
+
if match:
|
|
204
|
+
files.append((match.group(1).zfill(3), entry.name))
|
|
205
|
+
if not files:
|
|
206
|
+
errors.append("migration directory contains no numbered .sql files")
|
|
207
|
+
versions = [version for version, _ in files]
|
|
208
|
+
if len(set(versions)) != len(versions):
|
|
209
|
+
errors.append("migration directory contains duplicate migration versions")
|
|
210
|
+
ordered = [{"version": version, "file": name, "applied": version in applied_set} for version, name in sorted(files)]
|
|
211
|
+
missing = [item for item in ordered if not item["applied"]]
|
|
212
|
+
result = {
|
|
213
|
+
"schemaVersion": "maggie-migration-ledger-result.v1",
|
|
214
|
+
"environment": evidence.get("environment"),
|
|
215
|
+
"migrationTable": evidence.get("migrationTable"),
|
|
216
|
+
"passed": not errors and not missing,
|
|
217
|
+
"errors": errors,
|
|
218
|
+
"appliedVersions": sorted(applied_set),
|
|
219
|
+
"orderedMigrations": ordered,
|
|
220
|
+
"missing": missing,
|
|
221
|
+
"nextAction": "activate only after applying missing migrations in order" if missing else "ledger matches migration directory",
|
|
222
|
+
"mutation": "not executed",
|
|
223
|
+
}
|
|
224
|
+
if args.output:
|
|
225
|
+
output = Path(args.output)
|
|
226
|
+
output.parent.mkdir(parents=True, exist_ok=True)
|
|
227
|
+
output.write_text(json.dumps(result, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
228
|
+
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
229
|
+
return 0 if result["passed"] else 1
|
|
230
|
+
|
|
231
|
+
|
|
163
232
|
def main() -> int:
|
|
164
233
|
if sys.argv[1:2] == ["preflight"]:
|
|
165
234
|
return preflight_main(sys.argv[2:])
|
|
166
235
|
if sys.argv[1:2] == ["identity"]:
|
|
167
236
|
return identity_main(sys.argv[2:])
|
|
237
|
+
if sys.argv[1:2] == ["reconcile"]:
|
|
238
|
+
return reconcile_main(sys.argv[2:])
|
|
168
239
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
169
240
|
parser.add_argument("manifest", nargs="?")
|
|
170
241
|
parser.add_argument("--project")
|