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.
@@ -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())