sylo-ignition 0.1.0

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.
@@ -0,0 +1,171 @@
1
+ #!/usr/bin/env python3
2
+ """Allowlist-gated atomic write of a project resource file.
3
+
4
+ For ignition_resource_write tool. Enforces:
5
+ - operator-managed write-allowlist (project must be present + enabled)
6
+ - forbidden paths (.resources, digest files, .bin, var/, local config, thumbnails)
7
+ - JSON files must parse before writing (no corruption on disk)
8
+ - atomic replace via temp file
9
+ - new resource folders get a scaffold resource.json (scan-compatible)
10
+
11
+ resource.json of EXISTING resources is never written — the gateway owns it.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import hashlib
18
+ import json
19
+ from datetime import datetime, timezone
20
+ from pathlib import Path
21
+ from typing import Any
22
+
23
+ from _allowlist import gate_writes, load_allowlist
24
+ from _ignition import load_config, project_dir, resolve_project
25
+ from _json_out import emit, emit_error
26
+
27
+ FORBIDDEN_SUFFIXES = (".bin", ".idb", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".zip", ".gwbk")
28
+ FORBIDDEN_NAMES = {"thumbnail.png"}
29
+
30
+
31
+ def main() -> None:
32
+ parser = argparse.ArgumentParser()
33
+ parser.add_argument("--project", default="")
34
+ parser.add_argument("--path", required=True, help="file path relative to project root")
35
+ parser.add_argument("--content", default=None, help="file content (string)")
36
+ parser.add_argument("--content-file", default=None, help="read content from this file instead")
37
+ args = parser.parse_args()
38
+
39
+ if args.content is None and args.content_file is None:
40
+ emit_error("Provide --content or --content-file.")
41
+ content = args.content if args.content is not None else Path(args.content_file).read_text(encoding="utf-8")
42
+
43
+ allow = load_allowlist()
44
+ cfg = load_config(require_token=False)
45
+ name = resolve_project(cfg, args.project)
46
+ gate_writes(allow, name)
47
+
48
+ root = project_dir(cfg, name)
49
+ if not root.is_dir():
50
+ emit_error(f"Project folder not found: {root} — create the project first (ignition_project_create).")
51
+
52
+ target = safe_join(root, args.path)
53
+ rel = target.relative_to(root).as_posix()
54
+
55
+ # Forbidden targets
56
+ parts = rel.split("/")
57
+ if any(p == ".resources" for p in parts):
58
+ emit_error(f"{rel}: .resources/ folders are gateway-internal — never write them.")
59
+ if target.name.endswith(".digest.json"):
60
+ emit_error(f"{rel}: digest files are gateway-managed — never write them.")
61
+ if parts[0] in ("config", "var"):
62
+ emit_error(f"{rel}: gateway config/var is out of scope for project writes.")
63
+ if target.name in FORBIDDEN_NAMES or target.suffix.lower() in FORBIDDEN_SUFFIXES:
64
+ emit_error(
65
+ f"{rel}: binary/managed files (images, .bin, thumbnails) are produced by the "
66
+ "gateway/Designer — write the text payload instead."
67
+ )
68
+
69
+ # JSON must parse (protects the gateway scan from bad files)
70
+ if target.suffix == ".json":
71
+ try:
72
+ json.loads(content)
73
+ except (json.JSONDecodeError, ValueError) as e:
74
+ emit_error(f"Refusing to write invalid JSON to {rel}: {e}")
75
+
76
+ # resource.json policy: only scaffold NEW resource folders; never edit existing
77
+ writing_resource_json = target.name == "resource.json"
78
+ if writing_resource_json and target.exists():
79
+ emit_error(
80
+ f"{rel} already exists — the gateway owns resource.json (signatures/timestamps). "
81
+ "Write the payload files instead; the scan updates attributes itself."
82
+ )
83
+
84
+ created_resource_json = False
85
+ if writing_resource_json:
86
+ created_resource_json = True
87
+
88
+ target.parent.mkdir(parents=True, exist_ok=True)
89
+
90
+ # Atomic write
91
+ existed = target.exists()
92
+ tmp = target.with_suffix(target.suffix + ".sylo-tmp")
93
+ tmp.write_text(content, encoding="utf-8", newline="\n")
94
+ tmp.replace(target)
95
+
96
+ # Resource-folder bookkeeping
97
+ scaffolded = False
98
+ files_list_updated = False
99
+ if not created_resource_json and target.parent != root:
100
+ res_meta = target.parent / "resource.json"
101
+ if not res_meta.exists():
102
+ files = sorted({p.name for p in target.parent.iterdir() if p.name != "resource.json"})
103
+ sig = hashlib.sha256(content.encode("utf-8")).hexdigest()
104
+ meta = {
105
+ "scope": "G",
106
+ "version": 1,
107
+ "restricted": False,
108
+ "overridable": True,
109
+ "files": files,
110
+ "attributes": {
111
+ "lastModificationSignature": sig,
112
+ "lastModification": {
113
+ "actor": "Sylo",
114
+ "timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
115
+ },
116
+ },
117
+ }
118
+ tmp2 = res_meta.with_suffix(".json.sylo-tmp")
119
+ tmp2.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8", newline="\n")
120
+ tmp2.replace(res_meta)
121
+ scaffolded = True
122
+ else:
123
+ # Existing resource folder: keep the files[] list in sync (attributes
124
+ # stay gateway-owned — only the files array is touched).
125
+ try:
126
+ meta = json.loads(res_meta.read_text(encoding="utf-8"))
127
+ files = meta.get("files")
128
+ if isinstance(files, list) and target.name not in files:
129
+ files.append(target.name)
130
+ meta["files"] = sorted(files)
131
+ tmp2 = res_meta.with_suffix(".json.sylo-tmp")
132
+ tmp2.write_text(json.dumps(meta, indent=2) + "\n", encoding="utf-8", newline="\n")
133
+ tmp2.replace(res_meta)
134
+ files_list_updated = True
135
+ except (json.JSONDecodeError, OSError):
136
+ pass # malformed meta — the scan will flag it
137
+
138
+ emit(
139
+ {
140
+ "ok": True,
141
+ "project": name,
142
+ "path": rel,
143
+ "abs_path": str(target),
144
+ "bytes": len(content.encode("utf-8")),
145
+ "created": not existed,
146
+ "resource_json_scaffolded": scaffolded,
147
+ "resource_files_list_updated": files_list_updated,
148
+ "next": (
149
+ "Run ignition_validate (optional) then ignition_scan to hot-apply, "
150
+ "then ignition_screenshot to verify."
151
+ ),
152
+ }
153
+ )
154
+
155
+
156
+ def safe_join(root: Path, rel: str) -> Path:
157
+ rel_clean = rel.replace("\\", "/").strip("/")
158
+ if not rel_clean:
159
+ emit_error("Empty path.")
160
+ if any(part == ".." for part in rel_clean.split("/")) or ":" in rel_clean:
161
+ emit_error(f"Illegal path: {rel!r}")
162
+ target = (root / rel_clean).resolve()
163
+ try:
164
+ target.relative_to(root.resolve())
165
+ except ValueError:
166
+ emit_error(f"Path escapes project root: {rel!r}")
167
+ return target
168
+
169
+
170
+ if __name__ == "__main__":
171
+ main()
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env python3
2
+ """Trigger a gateway scan (hot-apply of on-disk edits), for ignition_scan tool.
3
+
4
+ GATED by the write-allowlist (allow_scan). Protocol:
5
+ 1. Check open Designer sessions — warn loudly if any (unsaved-edit risk)
6
+ 2. Try to acquire the project scan lock (mutual exclusion with other scanners)
7
+ 3. POST /data/api/v1/scan/{scope} → poll GET until the scan completes
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ import time
14
+ from typing import Any
15
+
16
+ from _allowlist import gate_scan, load_allowlist
17
+ from _ignition import api, api_expect, load_config
18
+ from _json_out import emit, emit_error
19
+
20
+
21
+ def main() -> None:
22
+ parser = argparse.ArgumentParser()
23
+ parser.add_argument("--scope", choices=("projects", "config"), default="projects")
24
+ parser.add_argument("--wait", type=float, default=60.0, help="max seconds to poll for scan completion")
25
+ parser.add_argument("--no-lock", action="store_true", help="skip the scan-lock acquire attempt")
26
+ args = parser.parse_args()
27
+
28
+ allow = load_allowlist()
29
+ gate_scan(allow)
30
+ cfg = load_config()
31
+
32
+ out: dict[str, Any] = {"ok": True, "scope": args.scope}
33
+
34
+ # Designer conflict warning
35
+ status, designers, _ = api(cfg, "/data/api/v1/designers", timeout=10)
36
+ open_projects: list[str] = []
37
+ if status == 200 and isinstance(designers, list):
38
+ for s in designers:
39
+ if isinstance(s, dict):
40
+ open_projects.append(str(s.get("projectName") or s.get("project") or s.get("id") or "unknown"))
41
+ out["designer_sessions_open"] = open_projects
42
+ if open_projects and args.scope == "projects":
43
+ out["designer_warning"] = (
44
+ f"Designer is OPEN on: {', '.join(open_projects)}. If there are unsaved edits, "
45
+ "this scan may cause conflicts. Ask the operator to save/close first, or proceed only "
46
+ "if the operator confirms."
47
+ )
48
+
49
+ # Scan lock (best-effort; gateway rejects if already held)
50
+ if not args.no_lock and args.scope == "projects":
51
+ status, lock, _ = api(cfg, "/data/api/v1/scan-lock/projects", method="POST", body={}, timeout=10)
52
+ if status == 200:
53
+ out["scan_lock_acquired"] = True
54
+ elif status == 409:
55
+ out["scan_lock_acquired"] = False
56
+ out["scan_lock_note"] = "Lock held elsewhere — another scan in progress or Designer-locked."
57
+ else:
58
+ out["scan_lock_acquired"] = False
59
+ out["scan_lock_note"] = f"Lock acquire returned HTTP {status} (continuing without lock)."
60
+
61
+ # Trigger
62
+ api_expect(cfg, f"/data/api/v1/scan/{args.scope}", method="POST", timeout=30, what="scan trigger")
63
+ out["scan_triggered"] = True
64
+
65
+ # Poll
66
+ deadline = time.time() + args.wait
67
+ final: dict[str, Any] = {}
68
+ while time.time() < deadline:
69
+ status, st, _ = api(cfg, f"/data/api/v1/scan/{args.scope}", timeout=10)
70
+ if status == 200 and isinstance(st, dict):
71
+ final = st
72
+ if not st.get("scanActive", False):
73
+ break
74
+ time.sleep(1.0)
75
+ out["scan_status"] = final or "poll timed out — check ignition_gateway_logs"
76
+ out["completed"] = bool(final and not final.get("scanActive", False))
77
+ out["next"] = (
78
+ "Verify in the Designer / browser session, or ignition_screenshot for visual check. "
79
+ "If the resource did not appear, check ignition_gateway_logs for scan errors."
80
+ )
81
+ emit(out)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main()
@@ -0,0 +1,96 @@
1
+ #!/usr/bin/env python3
2
+ """Screenshot a Perspective session with Playwright — ignition_screenshot.
3
+
4
+ Uses the installed Chrome or Edge via Playwright channels (no browser
5
+ download); falls back to bundled Chromium if present. Output PNG is saved to
6
+ ~/.ignition-sylo/screenshots/ by default — read it back with the analyze_image
7
+ tool for vision critique (the design-quality loop).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import argparse
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Any
16
+
17
+ from _ignition import load_config
18
+ from _json_out import emit, emit_error
19
+
20
+
21
+ def main() -> None:
22
+ parser = argparse.ArgumentParser()
23
+ parser.add_argument("--project", default="")
24
+ parser.add_argument("--path", default="", help="view path segment appended to the client URL (optional)")
25
+ parser.add_argument("--out", default="", help="output PNG path (default ~/.ignition-sylo/screenshots/...)")
26
+ parser.add_argument("--width", type=int, default=1600)
27
+ parser.add_argument("--height", type=int, default=900)
28
+ parser.add_argument("--wait-ms", type=int, default=9000, help="render settle time before capture")
29
+ parser.add_argument("--url", default="", help="full override URL (advanced)")
30
+ args = parser.parse_args()
31
+
32
+ try:
33
+ from playwright.sync_api import sync_playwright
34
+ except ImportError:
35
+ emit_error(
36
+ "playwright is not installed. pip install playwright "
37
+ "(browsers not required — this tool uses installed Chrome/Edge). "
38
+ "Then retry."
39
+ )
40
+
41
+ cfg = load_config(require_token=False)
42
+ base = cfg["gateway_url"]
43
+ project = (args.project or cfg.get("default_project") or "").strip()
44
+ if not args.url and not project:
45
+ emit_error("No --url and no project (pass --project or set default_project).")
46
+
47
+ url = args.url or f"{base}/data/perspective/client/{project}"
48
+ if args.path:
49
+ url = url.rstrip("/") + "/" + args.path.strip("/")
50
+ out_path = (
51
+ Path(args.out).expanduser()
52
+ if args.out.strip()
53
+ else Path.home() / ".ignition-sylo" / "screenshots" / f"{project or 'session'}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.png"
54
+ )
55
+ out_path.parent.mkdir(parents=True, exist_ok=True)
56
+
57
+ last_error = ""
58
+ with sync_playwright() as p:
59
+ browser = None
60
+ for channel in ("chrome", "msedge", None):
61
+ try:
62
+ # --no-proxy-server: headless verifier always targets the local
63
+ # gateway; a system/env proxy would block 127.0.0.1/localhost
64
+ # (observed live: Edge hung on goto until this arg was added).
65
+ browser = p.chromium.launch(channel=channel, headless=True, args=["--no-proxy-server"]) if channel else p.chromium.launch(headless=True, args=["--no-proxy-server"])
66
+ break
67
+ except Exception as e: # noqa: BLE001 — try next channel
68
+ last_error = str(e)
69
+ if browser is None:
70
+ emit_error(f"Could not launch a browser: {last_error}")
71
+ page = browser.new_page(viewport={"width": args.width, "height": args.height})
72
+ page.goto(url, wait_until="domcontentloaded", timeout=30_000)
73
+ # Perspective is a websocket SPA: give it time to mount views
74
+ try:
75
+ page.wait_for_load_state("networkidle", timeout=args.wait_ms)
76
+ except Exception: # noqa: BLE001 — settle-time exceeded, capture anyway
77
+ pass
78
+ page.wait_for_timeout(1500)
79
+ page.screenshot(path=str(out_path), full_page=False)
80
+ browser.close()
81
+
82
+ emit(
83
+ {
84
+ "ok": True,
85
+ "url": url,
86
+ "screenshot": str(out_path),
87
+ "operator_chat": (
88
+ f"Session screenshot saved: {out_path}. Read it with analyze_image to critique "
89
+ "layout/spacing/hierarchy, then iterate (edit → scan → re-screenshot)."
90
+ ),
91
+ }
92
+ )
93
+
94
+
95
+ if __name__ == "__main__":
96
+ main()
@@ -0,0 +1,91 @@
1
+ #!/usr/bin/env python3
2
+ """Gateway + config + allowlist status for ignition_status tool."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import time
7
+ from typing import Any
8
+
9
+ from _allowlist import load_allowlist, summary
10
+ from _ignition import api, load_config, mask_token, projects_dir
11
+ from _json_out import emit
12
+
13
+
14
+ def main() -> None:
15
+ cfg = load_config(require_token=False)
16
+ allow = load_allowlist()
17
+ out: dict[str, Any] = {
18
+ "ok": True,
19
+ "config_path": cfg["_config_path"],
20
+ "gateway_url": cfg["gateway_url"],
21
+ "api_token_set": bool(cfg.get("api_token")),
22
+ "api_token_hint": mask_token(cfg.get("api_token", "")),
23
+ "data_dir": cfg.get("data_dir"),
24
+ "default_project": cfg.get("default_project"),
25
+ "data_dir_exists": bool(cfg.get("data_dir")) and projects_dir(cfg).parent.is_dir(),
26
+ "allowlist": summary(allow),
27
+ }
28
+
29
+ # Gateway reachability + identity
30
+ status, info, _ = api(cfg, "/data/api/v1/gateway-info", timeout=10)
31
+ out["gateway_reachable"] = status == 200
32
+ if status == 200 and isinstance(info, dict):
33
+ out["gateway"] = {
34
+ "version": info.get("ignitionVersion"),
35
+ "edition": info.get("edition"),
36
+ "deployment_mode": info.get("deploymentMode"),
37
+ "hostname": info.get("hostname"),
38
+ "jvm": info.get("jvmVersion"),
39
+ "redundancy_role": info.get("redundancyRole"),
40
+ }
41
+ out["gateway"] = {k: v for k, v in out["gateway"].items() if v is not None}
42
+ elif status in (401, 403):
43
+ out["auth_problem"] = (
44
+ f"gateway-info returned HTTP {status}. "
45
+ + (
46
+ "Token missing — create an API key and put it in config."
47
+ if status == 401
48
+ else "Token lacks permission — assign the key a custom security level granted "
49
+ "Gateway Read+Write (Platform > Security > General Settings > Roles and Permissions)."
50
+ )
51
+ )
52
+
53
+ # Projects
54
+ status, projects, _ = api(cfg, "/data/api/v1/projects/list", timeout=10)
55
+ if status == 200 and isinstance(projects, list):
56
+ out["projects"] = [
57
+ {
58
+ "name": p.get("name"),
59
+ "title": p.get("title"),
60
+ "enabled": p.get("enabled"),
61
+ "writable_by_agent": p.get("name") in (out["allowlist"]["writable_projects"] or []),
62
+ }
63
+ for p in projects
64
+ if isinstance(p, dict)
65
+ ]
66
+
67
+ # Open Designer sessions (conflict warning for file writes / scans)
68
+ designers: list[str] = []
69
+ status, dsessions, _ = api(cfg, "/data/api/v1/designers", timeout=10)
70
+ if status == 200 and isinstance(dsessions, list):
71
+ for s in dsessions:
72
+ if isinstance(s, dict):
73
+ designers.append(str(s.get("projectName") or s.get("project") or s.get("id") or "unknown"))
74
+ out["designer_sessions_open"] = designers
75
+ if designers:
76
+ out["operator_chat"] = (
77
+ f"⚠️ Designer is open on: {', '.join(designers)}. Save or close those projects before "
78
+ "scanning — a scan while the Designer has unsaved edits can conflict. Read-only work is fine."
79
+ )
80
+ else:
81
+ out["operator_chat"] = (
82
+ "Ignition gateway online — file-based 8.3 workflow ready. Typical flow: "
83
+ "ignition_project_resources → ignition_resource_read → edit → ignition_resource_write → "
84
+ "ignition_scan → ignition_screenshot to verify. Writes are gated by the allowlist: "
85
+ f"writable projects = {out['allowlist']['writable_projects'] or 'none'}."
86
+ )
87
+ emit(out)
88
+
89
+
90
+ if __name__ == "__main__":
91
+ main()