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,94 @@
1
+ # Ignition 8.3 formats quickref
2
+
3
+ Verified against a live 8.3.9 install (2026-08-30).
4
+
5
+ ## On-disk project layout
6
+
7
+ ```
8
+ data/projects/<Name>/
9
+ ├── project.json # {"title","description","enabled","inheritable","parent"}
10
+ └── com.inductiveautomation.perspective/
11
+ ├── views/<viewPath>/ # resource.json + view.json + thumbnail.png
12
+ ├── page-config/config.json/ # page config resource
13
+ ├── page-startup/onPageStartup.py/
14
+ └── session-props/props.json/
15
+ ```
16
+
17
+ ## resource.json (per resource folder — gateway-owned, never hand-edit existing)
18
+
19
+ ```json
20
+ {
21
+ "scope": "G",
22
+ "version": 1,
23
+ "restricted": false,
24
+ "overridable": true,
25
+ "files": ["view.json", "thumbnail.png"],
26
+ "attributes": {
27
+ "lastModificationSignature": "<sha256-hex>",
28
+ "lastModification": { "actor": "operator", "timestamp": "2026-08-30T20:54:34Z" }
29
+ }
30
+ }
31
+ ```
32
+
33
+ ## Perspective view.json skeleton (VERIFIED live on 8.3.9)
34
+
35
+ ```json
36
+ {
37
+ "custom": {},
38
+ "params": {},
39
+ "props": {},
40
+ "root": {
41
+ "type": "ia.container.coord",
42
+ "meta": { "name": "root" },
43
+ "children": [
44
+ {
45
+ "type": "ia.display.label",
46
+ "meta": { "name": "titleLabel" },
47
+ "position": { "x": 24, "y": 24, "width": 400, "height": 40 },
48
+ "props": { "text": "Overview", "style": { "fontSize": "24px" } }
49
+ }
50
+ ]
51
+ }
52
+ }
53
+ ```
54
+
55
+ - `root` is a **top-level key holding the root component** — NOT `props.root.children`.
56
+ Malformed views are silently dropped by the scan.
57
+ - `meta.name` unique per view. Position: absolute px inside coord (verified);
58
+ `mode: "flow"` inside flex containers (verified); `mode: "percent"` unverified —
59
+ labels stacked at 0,0 in live testing.
60
+ - Bindings wrap values: `"text": {"binding": {"type": "property", "config": {"path": "view.params.title"}}}`
61
+ — types: property, tag (`[provider]path/to/tag`), expr (Perspective expression
62
+ language), query; `config.transforms` chain output transforms.
63
+
64
+ ## Page-config (required for a view to be reachable)
65
+
66
+ `com.inductiveautomation.perspective/page-config/config.json`:
67
+
68
+ ```json
69
+ {
70
+ "pages": { "/": { "viewPath": "Home" } },
71
+ "sharedDocks": { "cornerPriority": "top-bottom" }
72
+ }
73
+ ```
74
+
75
+ The URL after the project name is the PAGE path; pages map to view paths here.
76
+
77
+ ## project.json
78
+
79
+ ```json
80
+ { "title": "Example Project", "description": "", "enabled": true, "inheritable": false, "parent": "" }
81
+ ```
82
+
83
+ ## Gateway scripts = Jython 2.7
84
+
85
+ No f-strings / async / pathlib / match. Use `%`/`.format()`. `system.*` functions
86
+ (e.g. `system.tag.readBlocking(["[default]Line1/Speed"])`).
87
+
88
+ ## Auth (API key)
89
+
90
+ Header `X-Ignition-API-Token`. Working recipe (8.3.9): API key (Basic Token,
91
+ secure-connections unchecked for plain HTTP) holding a custom security level
92
+ (e.g. `SyloAPI` under `Authenticated`), granted Gateway Read + Write in
93
+ Security > General Settings > Roles and Permissions. 401 = bad token;
94
+ 403 = insufficient level; 404 = route doesn't exist.
@@ -0,0 +1,119 @@
1
+ # Ignition 8.3 Gateway REST API — verified route map
2
+
3
+ Verified live 2026-08-30 against a **native Windows Ignition 8.3.9** install
4
+ (Perspective module 3.3.9), gateway at `http://localhost:8088`. Full OpenAPI
5
+ spec: `GET /openapi.json` with the `X-Ignition-API-Token` header (12 MB on a
6
+ full-module install, 588 routes). This file is the curated subset the
7
+ sylo-ignition tools actually use.
8
+
9
+ ## Auth (verified recipe)
10
+
11
+ - Create API key: **Platform → Security → API Keys → Create API Key +**
12
+ (Basic Token). Token shown once.
13
+ - Key must have **"Require secure connections" unchecked** for plain-HTTP
14
+ local gateways (checked → 403 over HTTP).
15
+ - Role-derived levels (Roles→Administrator) are **greyed out** for API keys.
16
+ Custom levels are assignable. Working setup (verified):
17
+ 1. **Platform → Security → Levels**: add custom level `SyloAPI` under
18
+ `Authenticated`
19
+ 2. Assign `SyloAPI` to the API key
20
+ 3. **Platform → Security → General Settings → Roles and Permissions**:
21
+ check `SyloAPI` for Gateway Read AND Gateway Write permissions
22
+ - Every request: header `X-Ignition-API-Token: <token>`.
23
+ - Status codes: bad/dead token → 401; insufficient security level → 403
24
+ (route exists); no such route → 404 (`No route match for path: /v1/...`).
25
+
26
+ ## Scan / safe-edit protocol
27
+
28
+ | Method | Route | Purpose |
29
+ |--------|-------|---------|
30
+ | GET | `/data/api/v1/scan/projects` | Project scan status (`scanActive`, `lastScanTimestamp`) |
31
+ | POST | `/data/api/v1/scan/projects` | **Trigger project scan** — hot-loads disk edits into gateway + Designer |
32
+ | GET/POST | `/data/api/v1/scan/config` | Same for gateway config (`data/config/**`) |
33
+ | GET | `/data/api/v1/scan-lock/projects` | Scan-lock info |
34
+ | POST | `/data/api/v1/scan-lock/projects` | Acquire project scan lock (mutual exclusion while writing on disk) |
35
+ | GET | `/data/api/v1/sync/items` | Config sync status |
36
+ | POST | `/data/api/v1/sync/reset` | Reset config sync |
37
+
38
+ ## Projects API
39
+
40
+ | Method | Route | Purpose |
41
+ |--------|-------|---------|
42
+ | GET | `/data/api/v1/projects/list` | List all projects (with resource counts) |
43
+ | GET | `/data/api/v1/projects/names` | Project names |
44
+ | POST | `/data/api/v1/projects` | Create project |
45
+ | GET | `/data/api/v1/projects/find/{name}` | Project details |
46
+ | PUT | `/data/api/v1/projects/{name}` | Modify project (title/description/enabled/parent) |
47
+ | DELETE | `/data/api/v1/projects/{name}` | Delete project |
48
+ | POST | `/data/api/v1/projects/copy` | Copy project |
49
+ | POST | `/data/api/v1/projects/rename/{name}` | Rename project |
50
+ | GET | `/data/api/v1/projects/export/{name}` | **Export project zip** |
51
+ | POST | `/data/api/v1/projects/import/{name}` | **Import project zip** |
52
+
53
+ ## Resources API (gateway config, pattern per type)
54
+
55
+ Pattern per resource type (393 routes total):
56
+ `GET /data/api/v1/resources/list|names|type|find|rename|delete/{moduleId}/{typeId}`,
57
+ `PUT|POST /data/api/v1/resources/{moduleId}/{typeId}` (create/update),
58
+ `GET|PUT|DELETE .../datafile/{moduleId}/{typeId}/{name}/{filename}` (attached files),
59
+ `GET .../singleton/{moduleId}/{typeId}` (singletons).
60
+
61
+ Key types for sylo-ignition:
62
+
63
+ | Module | Type | Use |
64
+ |--------|------|-----|
65
+ | `ignition` | `tag-provider` | Tag provider CRUD |
66
+ | `com.inductiveautomation.opcua` | `device` | OPC UA device connections |
67
+ | `ignition` | `opc-connection` | Legacy OPC connections |
68
+ | `ignition` | `database-connection` | DB connections |
69
+ | `com.inductiveautomation.perspective` | `themes` | **Theme resources** (theme.json + css files via datafile routes) |
70
+ | `com.inductiveautomation.perspective` | `fonts`, `icons` | Fonts / icon sets |
71
+ | `ignition` | `security-levels`, `security-properties`, `security-zone` | Security config |
72
+ | `ignition` | `identity-provider`, `user-source` | Authn config |
73
+
74
+ ## Tags
75
+
76
+ | Method | Route | Purpose |
77
+ |--------|-------|---------|
78
+ | GET | `/data/api/v1/tags/export` | **Download tag export (JSON)** — provider/tag paths, recursive |
79
+ | POST | `/data/api/v1/tags/import` | **Import tags (JSON)** — create/override, collision handling |
80
+ | PUT | `/data/api/v1/managed-tag-provider` | Write managed tag provider definitions |
81
+
82
+ No REST route for tag **values** (definitions only). Value read/write goes
83
+ through the gateway's OPC-UA server or scripting, not this API.
84
+
85
+ ## Perspective module API
86
+
87
+ | Method | Route | Purpose |
88
+ |--------|-------|---------|
89
+ | GET | `/data/perspective/api/v1/sessions/` | Live sessions |
90
+ | GET | `/data/perspective/api/v1/session/{sessionId}` | Session detail |
91
+ | GET | `/data/perspective/api/v1/session/{sessionId}/pages` | Pages in session |
92
+ | GET | `/data/perspective/api/v1/session/{sessionId}/page/{pageId}/views` | Mounted views |
93
+ | DELETE | `/data/perspective/api/v1/sessions` | Terminate session(s) |
94
+ | POST | `/data/perspective/api/v1/themes/copy-base-themes` | **Copy base themes into project** (start of theme customization) |
95
+
96
+ ## Gateway status / diagnostics
97
+
98
+ | Method | Route | Purpose |
99
+ |--------|-------|---------|
100
+ | GET | `/data/api/v1/gateway-info` | Version/edition/platform info |
101
+ | GET | `/data/api/v1/overview` | Gateway overview (modules, system) |
102
+ | GET | `/data/api/v1/overview/problems` | Health problems list |
103
+ | GET | `/data/api/v1/overview/connections` | Connection statuses |
104
+ | GET | `/data/api/v1/logs` | Gateway logs (debug scan failures etc.) |
105
+ | GET | `/data/api/v1/logs/download` | Download logs |
106
+ | GET | `/data/api/v1/backup` | **Gateway backup download** (pre-change safety) |
107
+ | POST | `/data/api/v1/backup` | Restore backup |
108
+ | GET | `/data/api/v1/designers` | Open Designer sessions (conflict detection!) |
109
+ | GET | `/data/api/v1/entity/browse` | Browse config entities |
110
+ | GET/POST/PUT/DELETE | `/data/api/v1/mode*` | Deployment modes |
111
+ | GET | `/data/api/v1/licenses` | License info |
112
+ | GET | `/data/api/v1/trial` | Trial info |
113
+
114
+ ## OPC-UA module API (client/server PKI)
115
+
116
+ `/data/opc-ua/api/v1/...` — trust/reject/download/upload certificates for
117
+ client & server PKI, regenerate server certificate. Needed for connecting to
118
+ real PLCs (Logix/Modbus/Siemens etc. are driver configs via resources API:
119
+ `com.inductiveautomation.opcua/device`).
@@ -0,0 +1,119 @@
1
+ #!/usr/bin/env python3
2
+ """Shared write-allowlist loader / gates for sylo-ignition.
3
+
4
+ The allowlist lives at packages/sylo-ignition/assets/write-allowlist.json and is
5
+ OPERATOR-MANAGED — the agent must never edit it. Every mutating tool
6
+ (resource_write, scan, project_create, REST writes) enforces it in Python:
7
+ refuse anything not explicitly allowed here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import os
14
+ from datetime import datetime, timezone
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ from _json_out import emit_error
19
+
20
+
21
+ def package_root() -> Path:
22
+ return Path(__file__).resolve().parent.parent
23
+
24
+
25
+ def allowlist_path() -> Path:
26
+ env = os.environ.get("IGNITION_WRITE_ALLOWLIST", "").strip()
27
+ if env:
28
+ return Path(env).expanduser().resolve()
29
+ return package_root() / "assets" / "write-allowlist.json"
30
+
31
+
32
+ def default_allowlist() -> dict[str, Any]:
33
+ return {
34
+ "allow_writes": False,
35
+ "allow_scan": False,
36
+ "allow_project_create": False,
37
+ "projects": [],
38
+ "updated_at": None,
39
+ "notes": "Operator-managed. The agent cannot edit this file; mutating tools enforce it.",
40
+ }
41
+
42
+
43
+ def load_allowlist() -> dict[str, Any]:
44
+ path = allowlist_path()
45
+ if not path.is_file():
46
+ return default_allowlist()
47
+ try:
48
+ data = json.loads(path.read_text(encoding="utf-8"))
49
+ except (json.JSONDecodeError, OSError):
50
+ return default_allowlist()
51
+ if not isinstance(data, dict):
52
+ return default_allowlist()
53
+ base = default_allowlist()
54
+ base.update(data)
55
+ if not isinstance(base.get("projects"), list):
56
+ base["projects"] = []
57
+ return base
58
+
59
+
60
+ def _project_entry(allow: dict[str, Any], project: str) -> dict[str, Any] | None:
61
+ for entry in allow.get("projects", []):
62
+ if isinstance(entry, dict) and entry.get("name") == project:
63
+ return entry
64
+ return None
65
+
66
+
67
+ def gate_writes(allow: dict[str, Any], project: str) -> None:
68
+ """Exit with a clear error unless project file-writes are allowed."""
69
+ if not allow.get("allow_writes"):
70
+ emit_error(
71
+ "File writes are disabled in the Ignition write-allowlist "
72
+ f"({allowlist_path()}). The operator must set allow_writes=true."
73
+ )
74
+ entry = _project_entry(allow, project)
75
+ if entry is None:
76
+ emit_error(
77
+ f"Project '{project}' is not in the Ignition write-allowlist. "
78
+ "The operator must add it there (assets/write-allowlist.json) before any write. "
79
+ "The agent must not edit that file."
80
+ )
81
+ if not entry.get("enabled", False):
82
+ emit_error(
83
+ f"Project '{project}' is present but DISABLED in the Ignition write-allowlist. "
84
+ "Ask the operator to enable it (or use a different allowed project)."
85
+ )
86
+
87
+
88
+ def gate_scan(allow: dict[str, Any]) -> None:
89
+ if not allow.get("allow_scan"):
90
+ emit_error(
91
+ "Gateway scan is disabled in the Ignition write-allowlist "
92
+ f"({allowlist_path()}). The operator must set allow_scan=true to hot-apply edits."
93
+ )
94
+
95
+
96
+ def gate_project_create(allow: dict[str, Any], name: str) -> None:
97
+ if not allow.get("allow_project_create"):
98
+ emit_error(
99
+ "Project creation is disabled in the Ignition write-allowlist. "
100
+ "The operator must set allow_project_create=true."
101
+ )
102
+ if not name or any(c in name for c in '/\\:*?"<>|') or name.strip() != name:
103
+ emit_error(f"Invalid project name: {name!r}")
104
+
105
+
106
+ def summary(allow: dict[str, Any]) -> dict[str, Any]:
107
+ return {
108
+ "allow_writes": bool(allow.get("allow_writes")),
109
+ "allow_scan": bool(allow.get("allow_scan")),
110
+ "allow_project_create": bool(allow.get("allow_project_create")),
111
+ "writable_projects": [
112
+ e.get("name") for e in allow.get("projects", []) if isinstance(e, dict) and e.get("enabled")
113
+ ],
114
+ "path": str(allowlist_path()),
115
+ }
116
+
117
+
118
+ def note_update() -> dict[str, Any]:
119
+ return {"updated_at": datetime.now(timezone.utc).isoformat()}
@@ -0,0 +1,181 @@
1
+ #!/usr/bin/env python3
2
+ """Shared Ignition gateway plumbing: config file, REST client, data-dir paths.
3
+
4
+ Connection config lives OUTSIDE any repo at ~/.ignition-sylo/config.json so the
5
+ API token never lands in git:
6
+
7
+ {
8
+ "gateway_url": "http://localhost:8088",
9
+ "api_token": "Sylo:...",
10
+ "data_dir": "C:\\Program Files\\Inductive Automation\\Ignition\\data",
11
+ "default_project": "SyloSandbox"
12
+ }
13
+
14
+ Env overrides (testing): IGNITION_SYLO_CONFIG (path), IGNITION_SYLO_URL,
15
+ IGNITION_SYLO_TOKEN, IGNITION_SYLO_DATA_DIR, IGNITION_SYLO_PROJECT.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import os
22
+ import ssl
23
+ import urllib.error
24
+ import urllib.parse
25
+ import urllib.request
26
+ from pathlib import Path
27
+ from typing import Any
28
+
29
+ from _json_out import emit_error
30
+
31
+ CONFIG_FILENAME = "config.json"
32
+ COMMON_DATA_DIRS = (
33
+ r"C:\Program Files\Inductive Automation\Ignition\data",
34
+ r"C:\Program Files (x86)\Inductive Automation\Ignition\data",
35
+ )
36
+
37
+
38
+ def config_path() -> Path:
39
+ env = os.environ.get("IGNITION_SYLO_CONFIG", "").strip()
40
+ if env:
41
+ return Path(env).expanduser().resolve()
42
+ return Path.home() / ".ignition-sylo" / CONFIG_FILENAME
43
+
44
+
45
+ def _first_existing_data_dir() -> str | None:
46
+ for candidate in COMMON_DATA_DIRS:
47
+ if Path(candidate).is_dir():
48
+ return candidate
49
+ return None
50
+
51
+
52
+ def load_config(require_token: bool = True) -> dict[str, Any]:
53
+ """Load connection config; emit_error exits on missing pieces."""
54
+ path = config_path()
55
+ cfg: dict[str, Any] = {}
56
+ if path.is_file():
57
+ try:
58
+ cfg = json.loads(path.read_text(encoding="utf-8"))
59
+ except (json.JSONDecodeError, OSError) as e:
60
+ emit_error(f"Could not parse {path}: {e}")
61
+ else:
62
+ emit_error(
63
+ f"No Ignition connection config at {path}. "
64
+ "Create it (see skill: setup section) with gateway_url + api_token "
65
+ "(Platform > Security > API Keys in the gateway web UI)."
66
+ )
67
+ # Env overrides
68
+ cfg["gateway_url"] = os.environ.get("IGNITION_SYLO_URL", "").strip() or cfg.get("gateway_url", "")
69
+ token = os.environ.get("IGNITION_SYLO_TOKEN", "").strip() or cfg.get("api_token", "").strip()
70
+ cfg["api_token"] = token
71
+ cfg["data_dir"] = (
72
+ os.environ.get("IGNITION_SYLO_DATA_DIR", "").strip() or cfg.get("data_dir") or _first_existing_data_dir()
73
+ )
74
+ cfg["default_project"] = (
75
+ os.environ.get("IGNITION_SYLO_PROJECT", "").strip() or cfg.get("default_project") or ""
76
+ )
77
+ cfg["_config_path"] = str(path)
78
+
79
+ if not cfg.get("gateway_url"):
80
+ emit_error(f"gateway_url missing in {path}")
81
+ if require_token and not cfg.get("api_token"):
82
+ emit_error(
83
+ f"api_token missing in {path}. Create an API key (Platform > Security > API Keys), "
84
+ "give it a custom security level granted Gateway Read+Write (see skill setup), "
85
+ f"then put the token in {path}."
86
+ )
87
+ cfg["gateway_url"] = str(cfg["gateway_url"]).rstrip("/")
88
+ return cfg
89
+
90
+
91
+ def projects_dir(cfg: dict[str, Any]) -> Path:
92
+ return Path(cfg["data_dir"]) / "projects"
93
+
94
+
95
+ def project_dir(cfg: dict[str, Any], project: str) -> Path:
96
+ return projects_dir(cfg) / project
97
+
98
+
99
+ def api(
100
+ cfg: dict[str, Any],
101
+ route: str,
102
+ method: str = "GET",
103
+ body: Any = None,
104
+ timeout: float = 30.0,
105
+ raw: bool = False,
106
+ ) -> tuple[int, Any, dict[str, str]]:
107
+ """Call the gateway REST API with the X-Ignition-API-Token header.
108
+
109
+ Returns (status, parsed_json_or_text_or_bytes, headers). Auth/permission
110
+ failures are returned as status codes — callers decide how to surface.
111
+ """
112
+ url = route if route.startswith("http") else cfg["gateway_url"] + route
113
+ data: bytes | None = None
114
+ headers = {"Accept": "application/json"}
115
+ if cfg.get("api_token"):
116
+ headers["X-Ignition-API-Token"] = cfg["api_token"]
117
+ if body is not None:
118
+ if isinstance(body, (bytes, bytearray)):
119
+ data = bytes(body)
120
+ headers["Content-Type"] = "application/octet-stream"
121
+ else:
122
+ data = json.dumps(body).encode("utf-8")
123
+ headers["Content-Type"] = "application/json"
124
+ req = urllib.request.Request(url, data=data, headers=headers, method=method.upper())
125
+ ctx = ssl.create_default_context()
126
+ # Local gateways commonly run plain HTTP or self-signed HTTPS.
127
+ ctx.check_hostname = False
128
+ ctx.verify_mode = ssl.CERT_NONE
129
+ try:
130
+ with urllib.request.urlopen(req, timeout=timeout, context=ctx) as resp:
131
+ payload = resp.read()
132
+ status = resp.status
133
+ resp_headers = {k.lower(): v for k, v in resp.headers.items()}
134
+ except urllib.error.HTTPError as e:
135
+ payload = e.read()
136
+ status = e.code
137
+ resp_headers = {k.lower(): v for k, v in e.headers.items()}
138
+ except (urllib.error.URLError, TimeoutError, OSError) as e:
139
+ emit_error(f"Gateway unreachable at {url}: {e}")
140
+ if raw:
141
+ return status, payload, resp_headers
142
+ text = payload.decode("utf-8", errors="replace")
143
+ try:
144
+ return status, json.loads(text), resp_headers
145
+ except (json.JSONDecodeError, ValueError):
146
+ return status, text, resp_headers
147
+
148
+
149
+ def api_expect(
150
+ cfg: dict[str, Any],
151
+ route: str,
152
+ method: str = "GET",
153
+ body: Any = None,
154
+ timeout: float = 30.0,
155
+ what: str = "",
156
+ ) -> Any:
157
+ """api() that emit_errors on any non-2xx status."""
158
+ status, parsed, _ = api(cfg, route, method=method, body=body, timeout=timeout)
159
+ if status >= 400:
160
+ if isinstance(parsed, dict):
161
+ hint = parsed.get("message") or parsed.get("error") or json.dumps(parsed)[:200]
162
+ else:
163
+ hint = str(parsed)[:200]
164
+ emit_error(f"{method} {route} -> HTTP {status}: {hint}" + (f" ({what})" if what else ""))
165
+ return parsed
166
+
167
+
168
+ def mask_token(token: str) -> str:
169
+ if not token:
170
+ return ""
171
+ return token[:6] + "..." if len(token) > 9 else "***"
172
+
173
+
174
+ def resolve_project(cfg: dict[str, Any], project: str | None) -> str:
175
+ name = (project or "").strip() or cfg.get("default_project", "").strip()
176
+ if not name:
177
+ emit_error(
178
+ "No project specified and no default_project in config. "
179
+ "Pass --project or set default_project in ~/.ignition-sylo/config.json."
180
+ )
181
+ return name
@@ -0,0 +1,19 @@
1
+ #!/usr/bin/env python3
2
+ """Shared JSON stdout helpers for sylo-ignition scripts."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import json
7
+ import sys
8
+ from typing import Any
9
+
10
+
11
+ def emit(payload: dict[str, Any]) -> None:
12
+ """Print JSON to stdout and exit with code 0 or 1."""
13
+ print(json.dumps(payload, indent=2))
14
+ if payload.get("ok") is False:
15
+ sys.exit(1)
16
+
17
+
18
+ def emit_error(message: str, **extra: Any) -> None:
19
+ emit({"ok": False, "error": message, **extra})
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env python3
2
+ """Read-only GET passthrough for gateway REST routes — ignition_api_get.
3
+
4
+ Lets the agent explore the 588-route API (resource lists, session detail,
5
+ overviews, tag exports) without a dedicated tool per route. GET ONLY — every
6
+ mutating verb is refused; the mutating tools have their own gates.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ from pathlib import Path
14
+ from typing import Any
15
+
16
+ from _ignition import api, load_config
17
+ from _json_out import emit, emit_error
18
+
19
+ ALLOWED_PREFIXES = ("/data/", "/openapi.json", "/system/")
20
+ MAX_INLINE_CHARS = 24_000
21
+
22
+
23
+ def main() -> None:
24
+ parser = argparse.ArgumentParser()
25
+ parser.add_argument("--route", required=True, help="GET route, e.g. /data/api/v1/projects/list (query string allowed)")
26
+ parser.add_argument("--save", default="", help="save the full JSON payload to this file (recommended for big routes)")
27
+ parser.add_argument("--timeout", type=float, default=30.0)
28
+ args = parser.parse_args()
29
+
30
+ route = args.route.strip()
31
+ if not route.startswith(ALLOWED_PREFIXES) or route.startswith("//"):
32
+ emit_error(
33
+ f"Route must start with one of {ALLOWED_PREFIXES}. Got: {route!r}. "
34
+ "Mutating methods are not available through this tool."
35
+ )
36
+ if "?" in route and any(
37
+ kw in route.lower() for kw in ("method=", "delete", "put", "post")
38
+ ):
39
+ # route query strings don't carry verbs, but guard obviously wrong usage
40
+ pass # no-op: query params like ?scope=projects are legitimate
41
+
42
+ cfg = load_config()
43
+ status, data, _ = api(cfg, route, method="GET", timeout=args.timeout)
44
+
45
+ out: dict[str, Any] = {"ok": status < 400, "route": route, "http_status": status}
46
+ if status >= 400:
47
+ out["error"] = str(data)[:300]
48
+ emit(out)
49
+
50
+ if args.save:
51
+ save_path = Path(args.save).expanduser()
52
+ save_path.parent.mkdir(parents=True, exist_ok=True)
53
+ if isinstance(data, (dict, list)):
54
+ save_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
55
+ else:
56
+ save_path.write_text(str(data), encoding="utf-8")
57
+ out["saved_to"] = str(save_path)
58
+ out["hint"] = "Full payload saved to disk — read it with the read tool."
59
+ else:
60
+ text = json.dumps(data, indent=2) if not isinstance(data, str) else data
61
+ if len(text) > MAX_INLINE_CHARS:
62
+ out["payload"] = text[:MAX_INLINE_CHARS]
63
+ out["truncated"] = True
64
+ out["hint"] = "Truncated — re-run with --save <path> to capture the full payload."
65
+ else:
66
+ out["payload"] = data
67
+ emit(out)
68
+
69
+
70
+ if __name__ == "__main__":
71
+ main()
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env python3
2
+ """Download a gateway backup (.gwbk) — the pre-change safety net.
3
+
4
+ For ignition_backup tool. Read-only (GET /data/api/v1/backup). Backups land in
5
+ ~/.ignition-sylo/backups/ by default (outside any repo).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from _ignition import api, load_config
16
+ from _json_out import emit, emit_error
17
+
18
+
19
+ def main() -> None:
20
+ parser = argparse.ArgumentParser()
21
+ parser.add_argument("--out", default="", help="output .gwbk file (default ~/.ignition-sylo/backups/<ts>.gwbk)")
22
+ args = parser.parse_args()
23
+
24
+ cfg = load_config()
25
+ out_path = Path(args.out).expanduser() if args.out.strip() else (
26
+ Path.home() / ".ignition-sylo" / "backups" / f"gateway-{datetime.now().strftime('%Y%m%d-%H%M%S')}.gwbk"
27
+ )
28
+ out_path.parent.mkdir(parents=True, exist_ok=True)
29
+
30
+ status, payload, headers = api(cfg, "/data/api/v1/backup", method="GET", timeout=300, raw=True)
31
+ if status >= 400:
32
+ emit_error(f"Backup -> HTTP {status} ({payload[:200]!r})")
33
+ if not payload or len(payload) < 10_000:
34
+ emit_error(f"Backup response suspiciously small ({len(payload)} bytes) — not saved.")
35
+
36
+ out_path.write_bytes(payload)
37
+ emit(
38
+ {
39
+ "ok": True,
40
+ "backup_path": str(out_path),
41
+ "bytes": len(payload),
42
+ "content_type": headers.get("content-type", ""),
43
+ "note": "Restore via gateway web UI (Gateway > Restore) or POST /data/api/v1/backup. Keep this file until changes are verified.",
44
+ }
45
+ )
46
+
47
+
48
+ if __name__ == "__main__":
49
+ main()