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.
- package/LICENSE +21 -0
- package/README.md +25 -0
- package/assets/write-allowlist.json +19 -0
- package/extensions/index.ts +349 -0
- package/package.json +36 -0
- package/references/README.md +45 -0
- package/references/gateway-rest-api-8.3.md +119 -0
- package/references/quickref/README.md +12 -0
- package/references/quickref/formats-quickref.md +94 -0
- package/references/quickref/gateway-rest-api-8.3.md +119 -0
- package/scripts/_allowlist.py +119 -0
- package/scripts/_ignition.py +181 -0
- package/scripts/_json_out.py +19 -0
- package/scripts/api_get.py +71 -0
- package/scripts/backup.py +49 -0
- package/scripts/fetch_docs.py +278 -0
- package/scripts/gateway_logs.py +57 -0
- package/scripts/project_create.py +64 -0
- package/scripts/project_resources.py +127 -0
- package/scripts/requirements.txt +3 -0
- package/scripts/resource_read.py +102 -0
- package/scripts/resource_write.py +171 -0
- package/scripts/scan.py +85 -0
- package/scripts/screenshot.py +96 -0
- package/scripts/status.py +91 -0
- package/scripts/validate.py +252 -0
- package/skills/ignition/SKILL.md +131 -0
- package/skills/ignition-reference/SKILL.md +157 -0
|
@@ -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()
|
package/scripts/scan.py
ADDED
|
@@ -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()
|