@mriekki/project-forge 4.23.0 → 4.24.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/assets/components.json +3 -3
- package/assets/core/distribution.json +8 -0
- package/assets/generated/ASSEMBLY.json +16 -6
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +2 -0
- package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +5 -0
- package/assets/generated/core/core/process/WORKSTATION_MAINTENANCE.md +19 -0
- package/assets/generated/core/core/skills/setup/SKILL.md +6 -1
- package/assets/generated/core/tools/setup_maintenance/maintenance.py +172 -0
- package/package.json +1 -1
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.24.0",
|
|
5
5
|
"defaultProfile": "greenfield-project",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-project": {
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
},
|
|
14
14
|
"components": {
|
|
15
15
|
"core": {
|
|
16
|
-
"revision": "
|
|
17
|
-
"version": "4.
|
|
16
|
+
"revision": "1e5d8488b00b1a6e7cb0aa131757aaf859849d43",
|
|
17
|
+
"version": "4.2.0",
|
|
18
18
|
"distribution": "greenfield-workspace",
|
|
19
19
|
"descriptor": "assets/core/distribution.json",
|
|
20
20
|
"sourceRoot": "assets/generated/core",
|
|
@@ -290,6 +290,14 @@
|
|
|
290
290
|
{
|
|
291
291
|
"source": "core/instructions/project-forge-web-guidance.instructions.md",
|
|
292
292
|
"destination": ".github/instructions/project-forge-web-guidance.instructions.md"
|
|
293
|
+
},
|
|
294
|
+
{
|
|
295
|
+
"source": "tools/setup_maintenance/maintenance.py",
|
|
296
|
+
"destination": "tools/project-forge-setup/maintenance.py"
|
|
297
|
+
},
|
|
298
|
+
{
|
|
299
|
+
"source": "core/process/WORKSTATION_MAINTENANCE.md",
|
|
300
|
+
"destination": "docs/process/WORKSTATION_MAINTENANCE.md"
|
|
293
301
|
}
|
|
294
302
|
]
|
|
295
303
|
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 2,
|
|
3
3
|
"package": "@mriekki/project-forge",
|
|
4
|
-
"version": "4.
|
|
4
|
+
"version": "4.24.0",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
|
-
"revision": "
|
|
8
|
-
"version": "4.
|
|
7
|
+
"revision": "1e5d8488b00b1a6e7cb0aa131757aaf859849d43",
|
|
8
|
+
"version": "4.2.0",
|
|
9
9
|
"distribution": "greenfield-workspace",
|
|
10
10
|
"files": [
|
|
11
11
|
{
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
{
|
|
22
22
|
"source": "core/instructions/project-forge-workflow.instructions.md",
|
|
23
23
|
"destination": ".github/instructions/project-forge-workflow.instructions.md",
|
|
24
|
-
"sha256": "
|
|
24
|
+
"sha256": "c2ab2b578c3b5b47d79f9aa64daeafcd40c89cfa8924244177950bdf4e10115b"
|
|
25
25
|
},
|
|
26
26
|
{
|
|
27
27
|
"source": "core/instructions/project-forge-local-development.instructions.md",
|
|
@@ -56,7 +56,7 @@
|
|
|
56
56
|
{
|
|
57
57
|
"source": "core/skills/setup/SKILL.md",
|
|
58
58
|
"destination": ".github/skills/setup/SKILL.md",
|
|
59
|
-
"sha256": "
|
|
59
|
+
"sha256": "8f92aed1684a3dfd2ecffeb584b5da4daa9c7d7d7ab7ca7f01c8c6a4c82eaa1d"
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
"source": "core/skills/build/SKILL.md",
|
|
@@ -226,7 +226,7 @@
|
|
|
226
226
|
{
|
|
227
227
|
"source": "core/process/SETUP_ACCEPTANCE_CHECKLIST.md",
|
|
228
228
|
"destination": "docs/process/SETUP_ACCEPTANCE_CHECKLIST.md",
|
|
229
|
-
"sha256": "
|
|
229
|
+
"sha256": "8bf7936d8ae33b1656532669a278b3c73aaf4d47f87e641d48bfb38ecd8c56b5"
|
|
230
230
|
},
|
|
231
231
|
{
|
|
232
232
|
"source": "core/process/OPERATION_JOURNAL.md",
|
|
@@ -372,6 +372,16 @@
|
|
|
372
372
|
"source": "core/instructions/project-forge-web-guidance.instructions.md",
|
|
373
373
|
"destination": ".github/instructions/project-forge-web-guidance.instructions.md",
|
|
374
374
|
"sha256": "5e9c32ea86d0ad868882ec8d6c5263931cd12eb26d7f9a432dfae1895f8b0738"
|
|
375
|
+
},
|
|
376
|
+
{
|
|
377
|
+
"source": "tools/setup_maintenance/maintenance.py",
|
|
378
|
+
"destination": "tools/project-forge-setup/maintenance.py",
|
|
379
|
+
"sha256": "38cb6bff3153b48ef2dfd099a168dada4079455a4e1798f0e6758034c0faf3df"
|
|
380
|
+
},
|
|
381
|
+
{
|
|
382
|
+
"source": "core/process/WORKSTATION_MAINTENANCE.md",
|
|
383
|
+
"destination": "docs/process/WORKSTATION_MAINTENANCE.md",
|
|
384
|
+
"sha256": "79c8e74afa3e219b1486664a570c5c7a7d8f007652d0bcb3f26cf11d7b415fde"
|
|
375
385
|
}
|
|
376
386
|
]
|
|
377
387
|
},
|
|
@@ -14,6 +14,8 @@ Possible sources include Product Briefs, Feature Briefs, Engineering Enablers, J
|
|
|
14
14
|
|
|
15
15
|
Use proportional rigor: `LIGHT`, `STANDARD`, `ELEVATED`.
|
|
16
16
|
|
|
17
|
+
For ongoing tool maintenance use `/setup update` with the catalog-driven preview/approval/check contract. Do not turn normal `/setup` into a blanket package upgrade or change project-pinned runtimes and managed assets through a workstation update.
|
|
18
|
+
|
|
17
19
|
Keep explicit human gates for material requirements, substantive plans, meaningful implementation acceptance, the final merge/delivery decision, and production deployment.
|
|
18
20
|
|
|
19
21
|
Before any substantive approval gate, identify the exact PRIMARY review subject for the current work item/scope and put the best available review surface in front of the human when the host supports it. Treat supporting documents as CONTEXT or TRACEABILITY instead of opening everything. For Markdown prefer rendered preview, for structured approval artifacts prefer formatted source plus a concise summary, for implementation acceptance prefer the meaningful diff/Source Control surface, and for plans prefer the native plan surface or durable plan document. Opening or foregrounding a review surface is read-only presentation and never implies approval.
|
|
@@ -14,6 +14,11 @@ Use this checklist after creating or adopting a Project Forge workspace and befo
|
|
|
14
14
|
- [ ] Optional tools are either available or consciously deferred.
|
|
15
15
|
- [ ] For browser-facing Projects, the exact Modern Web Guidance CLI/Node prerequisites are verified or explicitly deferred; telemetry is disabled without third-party agent-file overwrites.
|
|
16
16
|
|
|
17
|
+
## Ongoing tool maintenance (optional)
|
|
18
|
+
|
|
19
|
+
- [ ] `/setup status` can report known installed versions and catalog update eligibility without any write.
|
|
20
|
+
- [ ] `/setup update` requires an exact reviewed plan before upgrading selected tools; pinned Project dependencies and managed Project Forge profiles are excluded.
|
|
21
|
+
|
|
17
22
|
## Repository / workspace
|
|
18
23
|
|
|
19
24
|
- [ ] Git repository and remote state are understood.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Workstation maintenance via /setup
|
|
2
|
+
|
|
3
|
+
The existing `/setup` command installs missing declared prerequisites; `/setup repair` repairs broken prerequisites; `/setup status` inspects workstation tools read-only; and `/setup update` is the explicit, reviewed ongoing upgrade path.
|
|
4
|
+
|
|
5
|
+
## Exact source and preview
|
|
6
|
+
|
|
7
|
+
Use `python tools/project-forge-setup/maintenance.py status` to inspect installed versions and eligibility, or `status --record-check` during normal non-read-only `/setup` to retain only a local check timestamp in ignored `.project-forge/`. At a normal `/setup` call, suggest a check approximately every 30 days if there is no newer recorded check. This is not a scheduled/background service.
|
|
8
|
+
|
|
9
|
+
Use `python tools/project-forge-setup/maintenance.py update [--tool rtk ...]` for the default non-mutating preview of selected safe candidates. The exact tool IDs, manager commands, installed versions, pins and SHA-256 in this output define the approval scope. Only after the Builder explicitly accepts the reviewed action, run the same selection with `--apply --plan-sha256 <approved-value>`. Changed data invalidates approval.
|
|
10
|
+
|
|
11
|
+
The helper reads `DEV_ENVIRONMENT.json` and the selected `tools/dev-environment/catalog/*.json` copies generated by Bootstrap. It never guesses package IDs or uses `upgrade --all`. The states `MANAGER_CHECK` and `PINNED_RELEASE_UPGRADE` indicate upgrade eligibility, not a claim that a newer registry version exists. Preview the underlying manager's available version when feasible and expose it to the Builder. Homebrew/winget apply commands are intentionally package-specific; those managers may resolve a newer release at execution time.
|
|
12
|
+
|
|
13
|
+
## Boundaries
|
|
14
|
+
|
|
15
|
+
- Never automatically update project-pinned toolchain versions, application dependencies/lockfiles, generated package provenance, Project Forge-managed assets, Docker Desktop, SSH trust, or deployment targets.
|
|
16
|
+
- Package-managed Project Forge updates remain owned by `/project-forge-update`. Modern Web Guidance remains on its approved exact version and independent from normal workstation tool updates.
|
|
17
|
+
- Missing selected catalog entries, unsupported managers or unknown pinned-release integrity are manual-review gaps. An adopted repository without Bootstrap's selected tool catalog must use its existing, human-approved setup/update mechanisms instead of an invented Project Forge catalog.
|
|
18
|
+
- A platform package-manager update may affect other programs and is not transactionally reversible. Report failures honestly and verify installed versions, doctor and relevant runtime checks afterward. Never deploy or restart production as a consequence.
|
|
19
|
+
- Version checks and update previews never install packages or mutate repository state. Only explicit apply can update tools, and the existing setup approval and Project conventions remain authoritative.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: setup
|
|
3
3
|
description: "Guide and execute setup for this workstation/workspace instance, including verified local runtime convergence, optional remote-enabled capability, named deployment-environment onboarding, and disposable deployment-path proof."
|
|
4
|
-
argument-hint: "[status|repair|local|remote|<environment>]"
|
|
4
|
+
argument-hint: "[status|update|repair|local|remote|<environment>]"
|
|
5
5
|
user-invocable: true
|
|
6
6
|
disable-model-invocation: true
|
|
7
7
|
---
|
|
@@ -21,6 +21,7 @@ Supported focused forms include:
|
|
|
21
21
|
```text
|
|
22
22
|
/setup status
|
|
23
23
|
/setup repair
|
|
24
|
+
/setup update
|
|
24
25
|
/setup local
|
|
25
26
|
/setup remote
|
|
26
27
|
/setup <environment>
|
|
@@ -99,6 +100,10 @@ NEXT
|
|
|
99
100
|
|
|
100
101
|
Do not silently repair state during `status`.
|
|
101
102
|
|
|
103
|
+
## 2A. Periodic tool maintenance
|
|
104
|
+
|
|
105
|
+
`/setup update` previews catalog-backed workstation upgrades and stops at one approval gate. Read `docs/process/WORKSTATION_MAINTENANCE.md` and use `python tools/project-forge-setup/maintenance.py status` for read-only tool inventory when present. Run `update` without apply to get the reviewed command list and SHA-256; only then run `update --apply --plan-sha256 <reviewed-hash>` with the same selected tool IDs after explicit approval. A changed plan must be reviewed again. `/setup status` makes no writes; regular `/setup` may record a 30-day check in ignored workstation-local state. An update never implicitly alters Project-pinned runtimes, product dependencies, Project Forge package baselines, Docker/SSH trust, production environments or third-party guidance versions. Missing tool catalogs or unsupported managers are `MANUAL REVIEW`, not guesses.
|
|
106
|
+
|
|
102
107
|
## 3. Run generated workstation setup
|
|
103
108
|
|
|
104
109
|
Select the launcher for the actual workstation OS:
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Project Forge catalog-bound workstation updates. Status/preview never mutate."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import argparse
|
|
5
|
+
import datetime as dt
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
TOOL_ID = re.compile(r"^[a-z0-9][a-z0-9-]*$")
|
|
15
|
+
VERSION = re.compile(r"(?<!\d)(\d+\.\d+\.\d+)(?!\d)")
|
|
16
|
+
PACKAGE_ID = re.compile(r"^[a-zA-Z0-9_.@/+:-]+$")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def platform() -> str:
|
|
20
|
+
return "windows" if sys.platform == "win32" else "macos" if sys.platform == "darwin" else "linux" if sys.platform.startswith("linux") else "unsupported"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def load(path: Path) -> dict:
|
|
24
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
25
|
+
if not isinstance(value, dict):
|
|
26
|
+
raise ValueError(f"expected object in {path}")
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run(argv: list[str], timeout: int = 15) -> tuple[int, str]:
|
|
31
|
+
try:
|
|
32
|
+
result = subprocess.run(argv, capture_output=True, text=True, errors="replace", timeout=timeout, check=False)
|
|
33
|
+
return result.returncode, (result.stdout + "\n" + result.stderr).strip()[:1000]
|
|
34
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
35
|
+
return 127, type(error).__name__
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def semver(s: str | None) -> tuple[int, int, int] | None:
|
|
39
|
+
match = VERSION.search(s or "")
|
|
40
|
+
return tuple(map(int, match.group(1).split("."))) if match else None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def inventory(root: Path) -> list[dict]:
|
|
44
|
+
env = load(root / "DEV_ENVIRONMENT.json")
|
|
45
|
+
if env.get("schemaVersion") != 1:
|
|
46
|
+
raise ValueError("unsupported DEV_ENVIRONMENT.json schema")
|
|
47
|
+
values: list[dict] = []
|
|
48
|
+
seen = set()
|
|
49
|
+
for item in env.get("tools", []):
|
|
50
|
+
name = item.get("id")
|
|
51
|
+
if not isinstance(name, str) or not TOOL_ID.fullmatch(name) or name in seen:
|
|
52
|
+
raise ValueError(f"unsafe or duplicate declared tool: {name}")
|
|
53
|
+
seen.add(name)
|
|
54
|
+
src = root / "tools/dev-environment/catalog" / (name + ".json")
|
|
55
|
+
if not src.is_file():
|
|
56
|
+
values.append({"id": name, "status": "CATALOG_MISSING", "update": "MANUAL_REVIEW"})
|
|
57
|
+
continue
|
|
58
|
+
data = load(src)
|
|
59
|
+
if data.get("id") != name:
|
|
60
|
+
raise ValueError(f"tool catalog identity mismatch: {name}")
|
|
61
|
+
executable = data.get("command")
|
|
62
|
+
verify = data.get("verifyArgs", ["--version"])
|
|
63
|
+
if not isinstance(executable, str) or not isinstance(verify, list) or not all(isinstance(v, str) for v in verify):
|
|
64
|
+
raise ValueError(f"invalid verification command for {name}")
|
|
65
|
+
path = shutil.which(executable)
|
|
66
|
+
rc, output = run([path, *verify]) if path else (127, "")
|
|
67
|
+
installed = semver(output) if rc == 0 else None
|
|
68
|
+
spec = data.get("install", {}).get(platform(), {})
|
|
69
|
+
spec = spec if isinstance(spec, dict) else {}
|
|
70
|
+
manager = spec.get("manager")
|
|
71
|
+
package = spec.get("package")
|
|
72
|
+
pin = item.get("version")
|
|
73
|
+
update = "MANUAL_REVIEW"
|
|
74
|
+
if pin:
|
|
75
|
+
update = "PROJECT_PINNED"
|
|
76
|
+
elif rc == 0 and manager in ("winget", "brew") and isinstance(package, str) and PACKAGE_ID.fullmatch(package):
|
|
77
|
+
update = "MANAGER_CHECK"
|
|
78
|
+
elif rc == 0 and manager == "project-forge-pinned-release":
|
|
79
|
+
expected = semver(spec.get("version"))
|
|
80
|
+
update = "APPROVED_PIN_CURRENT" if installed == expected and expected else (
|
|
81
|
+
"PINNED_RELEASE_UPGRADE" if installed and expected and installed < expected and name == "rtk" else "MANUAL_REVIEW"
|
|
82
|
+
)
|
|
83
|
+
values.append({"id": name, "status": "INSTALLED" if rc == 0 else "NOT_INSTALLED",
|
|
84
|
+
"installedVersion": ".".join(map(str, installed)) if installed else None,
|
|
85
|
+
"projectPin": pin, "update": update, "manager": manager, "package": package,
|
|
86
|
+
"approvedVersion": spec.get("version") if manager == "project-forge-pinned-release" else None})
|
|
87
|
+
return values
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def make_plan(root: Path, selected: list[str]) -> dict:
|
|
91
|
+
items = inventory(root)
|
|
92
|
+
candidates = {x["id"]: x for x in items}
|
|
93
|
+
if not selected:
|
|
94
|
+
selected = [v["id"] for v in items if v["update"] in ("MANAGER_CHECK", "PINNED_RELEASE_UPGRADE")]
|
|
95
|
+
if len(selected) != len(set(selected)):
|
|
96
|
+
raise ValueError("duplicate requested tool")
|
|
97
|
+
ops = []
|
|
98
|
+
for name in sorted(selected):
|
|
99
|
+
if name not in candidates:
|
|
100
|
+
raise ValueError(f"undeclared tool {name}")
|
|
101
|
+
item = candidates[name]
|
|
102
|
+
if item["update"] == "MANAGER_CHECK":
|
|
103
|
+
manager = item["manager"]
|
|
104
|
+
if not shutil.which(manager):
|
|
105
|
+
raise ValueError(f"package manager {manager} is missing")
|
|
106
|
+
cmd = ["brew", "upgrade", item["package"]] if manager == "brew" else [
|
|
107
|
+
"winget", "upgrade", "--id", item["package"], "--exact",
|
|
108
|
+
"--accept-source-agreements", "--accept-package-agreements"
|
|
109
|
+
]
|
|
110
|
+
elif item["update"] == "PINNED_RELEASE_UPGRADE":
|
|
111
|
+
launcher = root / "scripts/setup.sh"
|
|
112
|
+
if platform() != "linux" or not launcher.is_file() or "--update-rtk" not in launcher.read_text(encoding="utf-8"):
|
|
113
|
+
raise ValueError("approved pinned RTK update launcher unavailable; use an updated Bootstrap setup script")
|
|
114
|
+
cmd = ["bash", str(launcher), "--update-rtk"]
|
|
115
|
+
else:
|
|
116
|
+
raise ValueError(f"{name} cannot be automatically updated: {item['update']}")
|
|
117
|
+
ops.append({"id": name, "command": cmd, "fromVersion": item["installedVersion"],
|
|
118
|
+
"approvedVersion": item["approvedVersion"]})
|
|
119
|
+
plan = {"schemaVersion": 1, "platform": platform(), "operations": ops}
|
|
120
|
+
digest = hashlib.sha256(json.dumps(plan, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
|
|
121
|
+
return {"plan": plan, "sha256": digest}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def main() -> int:
|
|
125
|
+
parser = argparse.ArgumentParser()
|
|
126
|
+
parser.add_argument("action", choices=["status", "update"])
|
|
127
|
+
parser.add_argument("--root", type=Path, default=Path(__file__).resolve().parents[2])
|
|
128
|
+
parser.add_argument("--tool", action="append", default=[])
|
|
129
|
+
parser.add_argument("--apply", action="store_true")
|
|
130
|
+
parser.add_argument("--plan-sha256")
|
|
131
|
+
parser.add_argument("--record-check", action="store_true")
|
|
132
|
+
args = parser.parse_args()
|
|
133
|
+
if args.action == "status" and (args.tool or args.apply or args.plan_sha256):
|
|
134
|
+
parser.error("status is read-only and cannot accept update controls")
|
|
135
|
+
if args.action == "update" and args.record_check:
|
|
136
|
+
parser.error("--record-check applies only to status")
|
|
137
|
+
if args.apply != bool(args.plan_sha256):
|
|
138
|
+
parser.error("apply requires a reviewed --plan-sha256 and vice versa")
|
|
139
|
+
root = args.root.resolve()
|
|
140
|
+
try:
|
|
141
|
+
if args.action == "status":
|
|
142
|
+
result = {"platform": platform(), "tools": inventory(root)}
|
|
143
|
+
marker = root / ".project-forge/setup-maintenance/last-check.json"
|
|
144
|
+
if marker.is_file():
|
|
145
|
+
result["previousCheckUtc"] = load(marker).get("checkedAtUtc")
|
|
146
|
+
if args.record_check:
|
|
147
|
+
marker.parent.mkdir(parents=True, exist_ok=True)
|
|
148
|
+
marker.write_text(json.dumps({"checkedAtUtc": dt.datetime.now(dt.timezone.utc).isoformat()}) + "\n", encoding="utf-8")
|
|
149
|
+
print(json.dumps(result, indent=2))
|
|
150
|
+
return 0
|
|
151
|
+
result = make_plan(root, args.tool)
|
|
152
|
+
if not args.apply:
|
|
153
|
+
print(json.dumps({"mode": "PREVIEW", **result}, indent=2))
|
|
154
|
+
return 0
|
|
155
|
+
if args.plan_sha256 != result["sha256"]:
|
|
156
|
+
raise ValueError("update plan changed; re-preview and reapprove")
|
|
157
|
+
applied = []
|
|
158
|
+
for op in result["plan"]["operations"]:
|
|
159
|
+
code, _ = run(op["command"], timeout=900)
|
|
160
|
+
applied.append({"id": op["id"], "exitCode": code})
|
|
161
|
+
if code:
|
|
162
|
+
print(json.dumps({"mode": "PARTIAL_FAILURE", "applied": applied}, indent=2))
|
|
163
|
+
return 2
|
|
164
|
+
print(json.dumps({"mode": "APPLIED", "applied": applied, "after": inventory(root)}, indent=2))
|
|
165
|
+
return 0
|
|
166
|
+
except (OSError, ValueError, KeyError, json.JSONDecodeError) as exc:
|
|
167
|
+
print(json.dumps({"error": str(exc)}))
|
|
168
|
+
return 2
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
if __name__ == "__main__":
|
|
172
|
+
raise SystemExit(main())
|