@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.
Files changed (31) hide show
  1. package/README-zh-TW.md +2 -2
  2. package/README.md +99 -17
  3. package/bin/maggie.js +105 -17
  4. package/bundled-references/google-integrations-runbook.md +26 -0
  5. package/bundled-skills/maggie-blog/SKILL.md +18 -1
  6. package/bundled-skills/maggie-dash/SKILL.md +18 -3
  7. package/bundled-skills/maggie-deployment/SKILL.md +72 -0
  8. package/bundled-skills/maggie-deployment/references/vps.md +26 -0
  9. package/bundled-skills/maggie-design/SKILL.md +16 -0
  10. package/bundled-skills/maggie-feedback/SKILL.md +11 -2
  11. package/bundled-skills/maggie-ops/SKILL.md +11 -2
  12. package/bundled-skills/maggie-seo-geo/SKILL.md +22 -1
  13. package/bundled-tools/clis/maggie.py +12 -0
  14. package/bundled-tools/clis/maggie_analytics.py +34 -0
  15. package/bundled-tools/clis/maggie_blog.py +13 -0
  16. package/bundled-tools/clis/maggie_dash.py +17 -0
  17. package/bundled-tools/clis/maggie_deployment.py +91 -7
  18. package/bundled-tools/clis/maggie_feedback.py +18 -4
  19. package/bundled-tools/clis/maggie_icon_release_gate.py +79 -0
  20. package/bundled-tools/clis/maggie_migration.py +71 -0
  21. package/bundled-tools/clis/maggie_release_manifest.py +81 -0
  22. package/bundled-tools/clis/maggie_runtime_preflight.py +76 -0
  23. package/bundled-tools/clis/site_audit.py +78 -5
  24. package/bundled-tools/integrations/analytics.md +20 -0
  25. package/bundled-tools/runtime/maggie_blog_publish.py +38 -0
  26. package/bundled-tools/runtime/maggie_dash_panels.py +111 -0
  27. package/bundled-tools/runtime/maggie_quality.py +1 -1
  28. package/bundled-tools/runtime/maggie_sections.py +39 -1
  29. package/bundled-tools/runtime/site_baseline.py +23 -2
  30. package/package.json +1 -1
  31. 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. The batch command still creates
61
- local drafts only.
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. An npm skill update does not automatically deploy the
191
- dashboard or migrate a production database.
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
- if not isinstance(batch_index, int) or isinstance(batch_index, bool) or batch_index < 0:
155
- raise ValueError("batch index is required when batch ID is provided")
156
- if not isinstance(batch_size, int) or isinstance(batch_size, bool) or batch_size < 1 or batch_index >= batch_size:
157
- raise ValueError("batch size must be positive and greater than batch index")
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")