its-magic 0.1.2-39 → 0.1.2-42
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/README.md +4 -0
- package/installer.ps1 +1 -1
- package/installer.sh +649 -643
- package/package.json +5 -1
- package/scripts/guard_installer_publish.py +88 -0
- package/scripts/remote_config_summary.py +243 -0
- package/template/.cursor/commands/auto.md +132 -17
- package/template/.cursor/commands/execute.md +6 -0
- package/template/.cursor/commands/qa.md +5 -0
- package/template/.cursor/rules/coding-standards.mdc +7 -0
- package/template/.cursor/scratchpad.local.example.md +20 -1
- package/template/.cursor/scratchpad.md +35 -10
- package/template/.cursorignore +5 -0
- package/template/.env.example +28 -0
- package/template/README.md +4 -0
- package/template/docs/engineering/auto-orchestration-reference.md +315 -27
- package/template/docs/engineering/context/installer-owned-paths.manifest +78 -72
- package/template/docs/engineering/runbook.md +1772 -1525
- package/template/docs/engineering/runtime-connectivity.md +47 -0
- package/template/docs/engineering/us-0084-remote-e2e.md +44 -0
- package/template/scripts/guard_installer_publish.py +88 -0
- package/template/scripts/remote_config_summary.py +243 -0
|
@@ -32,3 +32,50 @@ For each enabled target include:
|
|
|
32
32
|
- Do not write inline credentials/tokens/private keys.
|
|
33
33
|
- Only env reference names are permitted in connectivity artifacts.
|
|
34
34
|
- Redact auth details in handoffs and release outputs.
|
|
35
|
+
|
|
36
|
+
## `*Env` variable sourcing (US-0085 / DEC-0071)
|
|
37
|
+
|
|
38
|
+
Operators may populate `release-targets.json`-referenced `*Env` variables
|
|
39
|
+
(such as `SSH_HOST`, `DOCKER_TOKEN`, `AWS_PROFILE`, etc.) from a sourced
|
|
40
|
+
`.env` file at repository root. Values are never stored in JSON configs —
|
|
41
|
+
only env-reference **names** appear in committed artifacts. See
|
|
42
|
+
`docs/engineering/runbook.md` for the copy/source recipe and
|
|
43
|
+
`.env.example` for the full 20-name inventory.
|
|
44
|
+
|
|
45
|
+
## Dev/QA remote profiles vs `release-targets.json` (US-0084)
|
|
46
|
+
|
|
47
|
+
Use this table to map **where you run tests** to the **US-0064** release/QA
|
|
48
|
+
connectivity model (**no parallel schema**). Cursor/dev **`REMOTE_CONFIG`** is
|
|
49
|
+
documented in the runbook; it complements, not replaces, **`release-targets.json`**.
|
|
50
|
+
|
|
51
|
+
| Operator path | Maps in `release-targets.json` | Scratchpad / dev config |
|
|
52
|
+
|---------------|----------------------------------|-------------------------|
|
|
53
|
+
| **WSL** | Local Linux on the same machine — not a separate target row by default. | Usually **`REMOTE_EXECUTION=0`**. Cite **environment label** **`WSL`** in QA evidence. |
|
|
54
|
+
| **Bare SSH Linux** | **`ssh-server`**: `hostEnv`, `userEnv`, `authEnv`, `remoteCommand`, `runtime`, ingress. | **`REMOTE_EXECUTION=1`**, **`REMOTE_CONFIG=.cursor/remote.json`** (see **`.cursor/scratchpad.md`**). |
|
|
55
|
+
| **Docker-over-SSH** | **`ssh-server.dockerOverSsh`**: `dockerHostEnv`, `dockerContextEnv`, `composeFile`, `service`. | Same scratchpad keys; operator sets **`DOCKER_HOST`** / context using **env names** only in docs. |
|
|
56
|
+
|
|
57
|
+
### `docker_over_ssh` (operator summary)
|
|
58
|
+
|
|
59
|
+
When **`dockerOverSsh.enabled`** is true on **`ssh-server`**, connectivity flows
|
|
60
|
+
through SSH to a host where Docker commands run; **`dockerHostEnv`** and
|
|
61
|
+
**`dockerContextEnv`** name the operator env vars (values never pasted into
|
|
62
|
+
artifacts). **`composeFile`** and **`service`** identify the stack slice. Full
|
|
63
|
+
fields remain in **`docs/engineering/release-targets.json`** and **DEC-0044**.
|
|
64
|
+
|
|
65
|
+
## Optional deterministic CI routing recipe (US-0086)
|
|
66
|
+
|
|
67
|
+
This recipe is optional and applies only when automation routing is explicitly
|
|
68
|
+
enabled.
|
|
69
|
+
|
|
70
|
+
- Keep `AUTO_REMOTE_AUTOMATION_PROFILE=off` as the default in CI unless the
|
|
71
|
+
workflow intentionally opts into automation routing.
|
|
72
|
+
- Use deterministic path filters to set an execution label:
|
|
73
|
+
- `docker` label for container surfaces (`Dockerfile*`, `docker-compose*.yml`,
|
|
74
|
+
container runtime scripts)
|
|
75
|
+
- `ssh` label for ssh/deploy/runtime host scripts
|
|
76
|
+
- `local` label for all other changes
|
|
77
|
+
- If explicit NL intent `start container <target_id>` is provided by automation,
|
|
78
|
+
target-id resolution takes precedence over path filters.
|
|
79
|
+
- Emit names-only routing evidence:
|
|
80
|
+
`target_id`, `environment_label`, `automation_profile`, `routing_source`,
|
|
81
|
+
`secret_surface=names_only`.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# US-0084 — Minimal remote sanity path (Windows → WSL or SSH Linux)
|
|
2
|
+
|
|
3
|
+
Sprint **S0069** / story **US-0084**. This is a short operator walkthrough; normative
|
|
4
|
+
contracts live in **`docs/engineering/architecture.md`** **`# US-0084`**,
|
|
5
|
+
**`docs/engineering/release-targets.json`**, and **`docs/engineering/runtime-connectivity.md`**
|
|
6
|
+
(**US-0064**).
|
|
7
|
+
|
|
8
|
+
## Path A — WSL (local Linux on the Windows machine)
|
|
9
|
+
|
|
10
|
+
1. Install/launch **WSL** and open a Linux shell in the repo (or clone the repo inside WSL).
|
|
11
|
+
2. Ensure **Node.js** and **Python 3** are available on **PATH** (same as host runbook).
|
|
12
|
+
3. Run **`npm pack`** / local **`its-magic`** or **`sh installer.sh --target <repo> --mode missing --create`** from the package root.
|
|
13
|
+
4. Run **`python tests/installer_shell_bug0004_test.py`** and your stack’s **`TEST_COMMAND`** (from merged runbook / scratchpad).
|
|
14
|
+
5. Evidence: set **environment label** **`WSL`** in QA handoffs; **`REMOTE_EXECUTION=0`** is typical (no **`.cursor/remote.json`** validation required).
|
|
15
|
+
|
|
16
|
+
## Path B — SSH to a Linux host
|
|
17
|
+
|
|
18
|
+
1. SSH into the Linux machine; clone or sync the repo there.
|
|
19
|
+
2. Copy **`.env.example`** to **`.env`** and fill in values for `SSH_HOST`,
|
|
20
|
+
`SSH_USER`, `SSH_PRIVATE_KEY`, and any other relevant `REMOTE_*` vars.
|
|
21
|
+
Source the file (`source .env` or equivalent) before running remote ops.
|
|
22
|
+
See **`docs/engineering/runbook.md`** § Operator `.env` setup.
|
|
23
|
+
3. Run the same **`sh`/`dash`** installer and **`python`** tests as on native Linux.
|
|
24
|
+
4. For **release/QA connectivity** semantics, align with **`ssh-server`** in
|
|
25
|
+
**`docs/engineering/release-targets.json`** (`hostEnv`, `userEnv`, `authEnv`, …).
|
|
26
|
+
5. For **Cursor/dev remote** validation, set **`REMOTE_EXECUTION=1`** and
|
|
27
|
+
**`REMOTE_CONFIG=.cursor/remote.json`** on the merged scratchpad; run
|
|
28
|
+
**`python scripts/remote_config_summary.py`** — stdout must list **names only**
|
|
29
|
+
(no keys/passwords).
|
|
30
|
+
6. Evidence: cite **`ssh:<hostEnv>`** (the **env var name**, not the host value) plus
|
|
31
|
+
**environment label**; never paste private key material.
|
|
32
|
+
|
|
33
|
+
## Path C — Docker-over-SSH
|
|
34
|
+
|
|
35
|
+
1. Follow **Path B** on the SSH host (including **`.env`** setup from
|
|
36
|
+
**`.env.example`**); enable **`dockerOverSsh`** patterns per
|
|
37
|
+
**`runtime-connectivity.md`** (**`dockerHostEnv`**, **`dockerContextEnv`**, **`composeFile`**, **`service`**).
|
|
38
|
+
2. Ensure **`DOCKER_HOST`** and **`DOCKER_CONTEXT`** are set in your **`.env`**;
|
|
39
|
+
source before running Docker commands. Document **env names** only in handoffs.
|
|
40
|
+
|
|
41
|
+
## Related
|
|
42
|
+
|
|
43
|
+
- **`tests/run-tests.sh`** / **`tests/run-tests.ps1`** — **H1–H5** (**US-0084** / AC-10).
|
|
44
|
+
- **`python scripts/guard_installer_publish.py`** — publish-time LF + POSIX guard.
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Prepublish / CI guard: installer.sh LF + POSIX-safe startup tokens (US-0084 / AC-2).
|
|
3
|
+
|
|
4
|
+
BUG-0008: reject CR bytes in installer-owned-paths.manifest (CRLF breaks POSIX awk section match).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
ROOT = Path(__file__).resolve().parents[1]
|
|
15
|
+
INSTALLER_SH = ROOT / "installer.sh"
|
|
16
|
+
INSTALLER_MANIFESTS = (
|
|
17
|
+
ROOT / "docs" / "engineering" / "context" / "installer-owned-paths.manifest",
|
|
18
|
+
ROOT / "template" / "docs" / "engineering" / "context" / "installer-owned-paths.manifest",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
FORBIDDEN_TOKENS = (
|
|
22
|
+
"set -euo",
|
|
23
|
+
"set -o pipefail",
|
|
24
|
+
"set -eu -o pipefail",
|
|
25
|
+
"set -o errexit",
|
|
26
|
+
"set -o nounset",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def main() -> int:
|
|
31
|
+
if not INSTALLER_SH.is_file():
|
|
32
|
+
print("guard_installer_publish: installer.sh missing", file=sys.stderr)
|
|
33
|
+
return 1
|
|
34
|
+
data = INSTALLER_SH.read_bytes()
|
|
35
|
+
if b"\r" in data:
|
|
36
|
+
print(
|
|
37
|
+
"guard_installer_publish: CR/LF (\\r) bytes found in installer.sh — "
|
|
38
|
+
"use LF only; see docs/engineering/runbook.md (US-0084).",
|
|
39
|
+
file=sys.stderr,
|
|
40
|
+
)
|
|
41
|
+
return 1
|
|
42
|
+
for man in INSTALLER_MANIFESTS:
|
|
43
|
+
if not man.is_file():
|
|
44
|
+
continue
|
|
45
|
+
mdata = man.read_bytes()
|
|
46
|
+
if b"\r" in mdata:
|
|
47
|
+
print(
|
|
48
|
+
f"guard_installer_publish: CR/LF (\\r) bytes found in {man.relative_to(ROOT)} — "
|
|
49
|
+
"use LF only (.gitattributes *.manifest; BUG-0008).",
|
|
50
|
+
file=sys.stderr,
|
|
51
|
+
)
|
|
52
|
+
return 1
|
|
53
|
+
text = data.decode("utf-8", errors="replace")
|
|
54
|
+
for token in FORBIDDEN_TOKENS:
|
|
55
|
+
if token in text:
|
|
56
|
+
print(
|
|
57
|
+
f"guard_installer_publish: forbidden startup token {token!r} in installer.sh",
|
|
58
|
+
file=sys.stderr,
|
|
59
|
+
)
|
|
60
|
+
return 1
|
|
61
|
+
dash = shutil.which("dash")
|
|
62
|
+
if dash:
|
|
63
|
+
r = subprocess.run(
|
|
64
|
+
[dash, "-n", str(INSTALLER_SH)],
|
|
65
|
+
cwd=ROOT,
|
|
66
|
+
capture_output=True,
|
|
67
|
+
text=True,
|
|
68
|
+
encoding="utf-8",
|
|
69
|
+
errors="replace",
|
|
70
|
+
)
|
|
71
|
+
if r.returncode != 0:
|
|
72
|
+
print(
|
|
73
|
+
"guard_installer_publish: dash -n installer.sh failed:\n"
|
|
74
|
+
+ (r.stderr or r.stdout or ""),
|
|
75
|
+
file=sys.stderr,
|
|
76
|
+
)
|
|
77
|
+
return 1
|
|
78
|
+
else:
|
|
79
|
+
print(
|
|
80
|
+
"guard_installer_publish: dash not on PATH; skipping dash -n "
|
|
81
|
+
"(Python CRLF + token checks still enforced).",
|
|
82
|
+
file=sys.stderr,
|
|
83
|
+
)
|
|
84
|
+
return 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Summarize .cursor/remote.json for operators (US-0084 / US-0064 alignment).
|
|
4
|
+
|
|
5
|
+
Stdout: non-secret labels and env-reference names only (no key material).
|
|
6
|
+
Stderr: errors and skip reasons.
|
|
7
|
+
|
|
8
|
+
Exit codes:
|
|
9
|
+
0 OK or REMOTE_EXECUTION=0 skip (DEC-0070)
|
|
10
|
+
1 usage / CLI error
|
|
11
|
+
2 config missing or unreadable
|
|
12
|
+
3 invalid JSON
|
|
13
|
+
4 schema / contract mismatch vs runbook remote.json contract
|
|
14
|
+
5 reserved (unused; DEC-0070 maps REMOTE_EXECUTION=0 to 0)
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import os
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
ENV_VAR_NAME = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
28
|
+
ALLOWED_TYPES = frozenset({"docker", "ssh", "vm"})
|
|
29
|
+
AUTH_MODES = frozenset({"none", "env"})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _truthy_remote_execution(raw: str | None) -> bool:
|
|
33
|
+
if raw is None:
|
|
34
|
+
return False
|
|
35
|
+
return raw.strip() in {"1", "true", "TRUE", "yes", "YES", "on", "ON"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _parse_args(argv: list[str]) -> tuple[argparse.Namespace, list[str]]:
|
|
39
|
+
p = argparse.ArgumentParser(description=__doc__.split("\n\n")[0], add_help=True)
|
|
40
|
+
p.add_argument(
|
|
41
|
+
"--config",
|
|
42
|
+
default=None,
|
|
43
|
+
help="Path to remote JSON (default: REMOTE_CONFIG env or .cursor/remote.json)",
|
|
44
|
+
)
|
|
45
|
+
return p.parse_known_args(argv)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _config_path(explicit: str | None) -> Path:
|
|
49
|
+
if explicit:
|
|
50
|
+
return Path(explicit).expanduser()
|
|
51
|
+
env = os.environ.get("REMOTE_CONFIG")
|
|
52
|
+
if env:
|
|
53
|
+
return Path(env).expanduser()
|
|
54
|
+
return Path(".cursor/remote.json")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validate_env_ref(name: str, value: Any, prefix: str) -> str | None:
|
|
58
|
+
if not isinstance(value, str):
|
|
59
|
+
return f"{prefix}: {name} must be a string env-reference name"
|
|
60
|
+
if not ENV_VAR_NAME.match(value):
|
|
61
|
+
return f"{prefix}: {name} must look like an env var name (A-Z_0-9)"
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def validate_and_summarize(path: Path, data: dict[str, Any]) -> tuple[list[str], list[str]]:
|
|
66
|
+
"""Return (stdout_lines, error_messages)."""
|
|
67
|
+
errs: list[str] = []
|
|
68
|
+
out: list[str] = []
|
|
69
|
+
out.append(f"remote_config={path.as_posix()}")
|
|
70
|
+
|
|
71
|
+
if not isinstance(data.get("version"), int):
|
|
72
|
+
errs.append("[REMOTE_CONFIG_ERROR] root.version: expected integer")
|
|
73
|
+
if not isinstance(data.get("defaultTarget"), str) or not data["defaultTarget"]:
|
|
74
|
+
errs.append("[REMOTE_CONFIG_ERROR] root.defaultTarget: expected non-empty string")
|
|
75
|
+
targets = data.get("targets")
|
|
76
|
+
if not isinstance(targets, list) or not targets:
|
|
77
|
+
errs.append("[REMOTE_CONFIG_ERROR] root.targets: expected non-empty array")
|
|
78
|
+
|
|
79
|
+
if errs:
|
|
80
|
+
return out, errs
|
|
81
|
+
|
|
82
|
+
by_id: dict[str, dict[str, Any]] = {}
|
|
83
|
+
for i, t in enumerate(targets):
|
|
84
|
+
prefix = f"targets[{i}]"
|
|
85
|
+
if not isinstance(t, dict):
|
|
86
|
+
errs.append(f"[REMOTE_CONFIG_ERROR] {prefix}: expected object")
|
|
87
|
+
continue
|
|
88
|
+
tid = t.get("id")
|
|
89
|
+
if not isinstance(tid, str) or not tid:
|
|
90
|
+
errs.append(f"[REMOTE_CONFIG_ERROR] {prefix}.id: expected non-empty string")
|
|
91
|
+
ttype = t.get("type")
|
|
92
|
+
if ttype not in ALLOWED_TYPES:
|
|
93
|
+
errs.append(
|
|
94
|
+
f"[REMOTE_CONFIG_ERROR] {prefix}.type: expected one of {sorted(ALLOWED_TYPES)}"
|
|
95
|
+
)
|
|
96
|
+
if not isinstance(t.get("enabled"), bool):
|
|
97
|
+
errs.append(f"[REMOTE_CONFIG_ERROR] {prefix}.enabled: expected boolean")
|
|
98
|
+
if not isinstance(t.get("host"), str) or not t["host"]:
|
|
99
|
+
errs.append(f"[REMOTE_CONFIG_ERROR] {prefix}.host: expected non-empty string")
|
|
100
|
+
port = t.get("port")
|
|
101
|
+
if not isinstance(port, int) or not (1 <= port <= 65535):
|
|
102
|
+
errs.append(f"[REMOTE_CONFIG_ERROR] {prefix}.port: expected integer 1..65535")
|
|
103
|
+
if not isinstance(t.get("workspaceRoot"), str) or not t["workspaceRoot"]:
|
|
104
|
+
errs.append(
|
|
105
|
+
f"[REMOTE_CONFIG_ERROR] {prefix}.workspaceRoot: expected non-empty string"
|
|
106
|
+
)
|
|
107
|
+
auth = t.get("auth")
|
|
108
|
+
if auth is not None:
|
|
109
|
+
if not isinstance(auth, dict):
|
|
110
|
+
errs.append(f"[REMOTE_CONFIG_ERROR] {prefix}.auth: expected object")
|
|
111
|
+
else:
|
|
112
|
+
mode = auth.get("mode")
|
|
113
|
+
if mode not in AUTH_MODES:
|
|
114
|
+
errs.append(
|
|
115
|
+
f"[REMOTE_CONFIG_ERROR] {prefix}.auth.mode: expected one of {sorted(AUTH_MODES)}"
|
|
116
|
+
)
|
|
117
|
+
elif mode == "env":
|
|
118
|
+
env_keys = (
|
|
119
|
+
"tokenEnv",
|
|
120
|
+
"passwordEnv",
|
|
121
|
+
"privateKeyPathEnv",
|
|
122
|
+
"usernameEnv",
|
|
123
|
+
)
|
|
124
|
+
any_ref = False
|
|
125
|
+
for k in env_keys:
|
|
126
|
+
if k not in auth:
|
|
127
|
+
continue
|
|
128
|
+
any_ref = True
|
|
129
|
+
err = _validate_env_ref(k, auth[k], f"{prefix}.auth")
|
|
130
|
+
if err:
|
|
131
|
+
errs.append(f"[REMOTE_CONFIG_ERROR] {err}")
|
|
132
|
+
if not any_ref:
|
|
133
|
+
errs.append(
|
|
134
|
+
f"[REMOTE_CONFIG_ERROR] {prefix}.auth: mode=env requires at least one *Env field"
|
|
135
|
+
)
|
|
136
|
+
if isinstance(tid, str) and tid:
|
|
137
|
+
by_id[tid] = t
|
|
138
|
+
|
|
139
|
+
default = data["defaultTarget"]
|
|
140
|
+
if default not in by_id:
|
|
141
|
+
errs.append(
|
|
142
|
+
f"[REMOTE_CONFIG_ERROR] defaultTarget={default!r} not found in targets[].id"
|
|
143
|
+
)
|
|
144
|
+
elif not by_id[default].get("enabled"):
|
|
145
|
+
errs.append(
|
|
146
|
+
f"[REMOTE_CONFIG_ERROR] defaultTarget={default!r} must reference an enabled target"
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
if errs:
|
|
150
|
+
return out, errs
|
|
151
|
+
|
|
152
|
+
out.append(f"defaultTarget={data['defaultTarget']}")
|
|
153
|
+
for t in targets:
|
|
154
|
+
assert isinstance(t, dict)
|
|
155
|
+
tid = t["id"]
|
|
156
|
+
parts = [
|
|
157
|
+
f"id={tid}",
|
|
158
|
+
f"type={t['type']}",
|
|
159
|
+
f"enabled={t['enabled']!s}",
|
|
160
|
+
f"host={t['host']}",
|
|
161
|
+
f"port={t['port']}",
|
|
162
|
+
f"workspaceRoot={t['workspaceRoot']}",
|
|
163
|
+
]
|
|
164
|
+
auth = t.get("auth")
|
|
165
|
+
if isinstance(auth, dict):
|
|
166
|
+
parts.append(f"auth.mode={auth.get('mode')}")
|
|
167
|
+
for k in (
|
|
168
|
+
"tokenEnv",
|
|
169
|
+
"passwordEnv",
|
|
170
|
+
"privateKeyPathEnv",
|
|
171
|
+
"usernameEnv",
|
|
172
|
+
):
|
|
173
|
+
if k in auth and isinstance(auth[k], str):
|
|
174
|
+
parts.append(f"auth.{k}={auth[k]}")
|
|
175
|
+
out.append("target:" + " ".join(parts))
|
|
176
|
+
|
|
177
|
+
return out, []
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def main(argv: list[str] | None = None) -> int:
|
|
181
|
+
args, rest = _parse_args(argv or sys.argv[1:])
|
|
182
|
+
if rest:
|
|
183
|
+
print(
|
|
184
|
+
f"[REMOTE_CONFIG_SUMMARY] unknown arguments: {' '.join(rest)}",
|
|
185
|
+
file=sys.stderr,
|
|
186
|
+
)
|
|
187
|
+
return 1
|
|
188
|
+
if not _truthy_remote_execution(os.environ.get("REMOTE_EXECUTION")):
|
|
189
|
+
print(
|
|
190
|
+
"[REMOTE_CONFIG_SUMMARY] REMOTE_EXECUTION!=1: skip validation/summary "
|
|
191
|
+
"(zero overhead; DEC-0070 / US-0084).",
|
|
192
|
+
file=sys.stderr,
|
|
193
|
+
)
|
|
194
|
+
return 0
|
|
195
|
+
|
|
196
|
+
path = _config_path(args.config)
|
|
197
|
+
if not path.is_file():
|
|
198
|
+
print(
|
|
199
|
+
f"[REMOTE_CONFIG_ERROR] {path}: file missing or unreadable. "
|
|
200
|
+
"Fix: create config or set REMOTE_EXECUTION=0.",
|
|
201
|
+
file=sys.stderr,
|
|
202
|
+
)
|
|
203
|
+
return 2
|
|
204
|
+
try:
|
|
205
|
+
raw = path.read_text(encoding="utf-8")
|
|
206
|
+
except OSError as e:
|
|
207
|
+
print(
|
|
208
|
+
f"[REMOTE_CONFIG_ERROR] {path}: read failed ({e}). Fix: permissions/path.",
|
|
209
|
+
file=sys.stderr,
|
|
210
|
+
)
|
|
211
|
+
return 2
|
|
212
|
+
try:
|
|
213
|
+
data = json.loads(raw)
|
|
214
|
+
except json.JSONDecodeError as e:
|
|
215
|
+
print(
|
|
216
|
+
f"[REMOTE_CONFIG_ERROR] {path}: invalid JSON ({e}). Fix: syntax.",
|
|
217
|
+
file=sys.stderr,
|
|
218
|
+
)
|
|
219
|
+
return 3
|
|
220
|
+
if not isinstance(data, dict):
|
|
221
|
+
print(
|
|
222
|
+
f"[REMOTE_CONFIG_ERROR] {path}: expected JSON object at root.",
|
|
223
|
+
file=sys.stderr,
|
|
224
|
+
)
|
|
225
|
+
return 4
|
|
226
|
+
|
|
227
|
+
lines, errs = validate_and_summarize(path, data)
|
|
228
|
+
if errs:
|
|
229
|
+
for line in errs:
|
|
230
|
+
print(line, file=sys.stderr)
|
|
231
|
+
print(
|
|
232
|
+
f"[REMOTE_CONFIG_ERROR] {path}: contract check failed. "
|
|
233
|
+
"Fix: align with docs/engineering/runbook.md remote contract (US-0084).",
|
|
234
|
+
file=sys.stderr,
|
|
235
|
+
)
|
|
236
|
+
return 4
|
|
237
|
+
for line in lines:
|
|
238
|
+
print(line)
|
|
239
|
+
return 0
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
if __name__ == "__main__":
|
|
243
|
+
raise SystemExit(main())
|