froid-loop 0.11.1__py3-none-any.whl
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.
- froid_loop/__init__.py +11 -0
- froid_loop/__main__.py +12 -0
- froid_loop/adapters/__init__.py +3 -0
- froid_loop/adapters/base.py +254 -0
- froid_loop/adapters/entrypoints.py +63 -0
- froid_loop/adapters/env_fault.py +290 -0
- froid_loop/adapters/generic.py +2013 -0
- froid_loop/adapters/mock.py +49 -0
- froid_loop/adapters/multiplexer.py +914 -0
- froid_loop/adapters/opencode_http.py +1687 -0
- froid_loop/adapters/profile.py +650 -0
- froid_loop/adapters/psmux_backend.py +1428 -0
- froid_loop/adapters/registry.py +322 -0
- froid_loop/adapters/tmux_backend.py +35 -0
- froid_loop/adapters/tmux_base.py +630 -0
- froid_loop/checks.py +187 -0
- froid_loop/cli.py +5041 -0
- froid_loop/data/__init__.py +0 -0
- froid_loop/data/froid_loop_hook.py +228 -0
- froid_loop/data/froid_loop_probe_hook.py +88 -0
- froid_loop/data/plugins/example/plugin.toml +21 -0
- froid_loop/data/plugins/tea/plugin.toml +184 -0
- froid_loop/data/plugins/tea/tea_plugin.py +258 -0
- froid_loop/data/plugins/unity/plugin.toml +140 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef +16 -0
- froid_loop/data/plugins/unity/unity_assets/FroidLoop.Unity.Editor.asmdef.meta +7 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs +221 -0
- froid_loop/data/plugins/unity/unity_assets/SceneAutoSaveGuard.cs.meta +11 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/Editor.meta +8 -0
- froid_loop/data/plugins/unity/unity_assets/_folders/FroidLoop.meta +8 -0
- froid_loop/data/plugins/unity/unity_cleanup.py +125 -0
- froid_loop/data/plugins/unity/unity_dialog_probe.py +239 -0
- froid_loop/data/plugins/unity/unity_facts.md +17 -0
- froid_loop/data/plugins/unity/unity_plugin.py +415 -0
- froid_loop/data/plugins/unity/unity_quiesce.py +234 -0
- froid_loop/data/plugins/unity/unity_ready.py +230 -0
- froid_loop/data/plugins/unity/unity_seed_assets.py +298 -0
- froid_loop/data/plugins/unity/unity_setup.py +551 -0
- froid_loop/data/plugins/unity/unity_teardown.py +362 -0
- froid_loop/data/profiles/antigravity.toml +52 -0
- froid_loop/data/profiles/claude.toml +85 -0
- froid_loop/data/profiles/codex.toml +22 -0
- froid_loop/data/profiles/copilot.toml +52 -0
- froid_loop/data/profiles/gemini.toml +26 -0
- froid_loop/data/profiles/opencode.toml +54 -0
- froid_loop/data/settings/core.toml +458 -0
- froid_loop/data/skills/README.md +93 -0
- froid_loop/data/skills/froid-loop-resolve/SKILL.md +288 -0
- froid_loop/data/skills/froid-loop-setup/SKILL.md +161 -0
- froid_loop/data/skills/froid-loop-setup/assets/module-help.csv +3 -0
- froid_loop/data/skills/froid-loop-setup/assets/module.yaml +19 -0
- froid_loop/data/skills/froid-loop-sweep/SKILL.md +100 -0
- froid_loop/data/skills/froid-loop-sweep/automation-mode.md +127 -0
- froid_loop/data/skills/froid-loop-sweep/deferred-work-format.md +302 -0
- froid_loop/data/skills/froid-loop-sweep/migration-mode.md +86 -0
- froid_loop/decisions.py +202 -0
- froid_loop/deferredwork.py +2282 -0
- froid_loop/devcontract.py +892 -0
- froid_loop/diagnostics.py +1104 -0
- froid_loop/documents.py +532 -0
- froid_loop/engine.py +7732 -0
- froid_loop/envvars.py +111 -0
- froid_loop/escalation.py +225 -0
- froid_loop/events.py +266 -0
- froid_loop/fences.py +103 -0
- froid_loop/froidconfig.py +226 -0
- froid_loop/frontmatter.py +526 -0
- froid_loop/gates.py +133 -0
- froid_loop/install.py +2936 -0
- froid_loop/journal.py +178 -0
- froid_loop/machine.py +148 -0
- froid_loop/model.py +898 -0
- froid_loop/operatoractions.py +474 -0
- froid_loop/platform_util.py +1490 -0
- froid_loop/plugins/__init__.py +64 -0
- froid_loop/plugins/bus.py +259 -0
- froid_loop/plugins/context.py +319 -0
- froid_loop/plugins/loader.py +145 -0
- froid_loop/plugins/manifest.py +279 -0
- froid_loop/plugins/model.py +296 -0
- froid_loop/plugins/registry.py +245 -0
- froid_loop/plugins/trust.py +75 -0
- froid_loop/policy.py +1569 -0
- froid_loop/probe.py +1044 -0
- froid_loop/process_host.py +408 -0
- froid_loop/recovery_flow.py +1561 -0
- froid_loop/resolve.py +283 -0
- froid_loop/runs.py +4715 -0
- froid_loop/runsetup.py +1293 -0
- froid_loop/sanitize.py +593 -0
- froid_loop/settings_schema.py +276 -0
- froid_loop/signals.py +160 -0
- froid_loop/sprintstatus.py +609 -0
- froid_loop/statemachine.py +57 -0
- froid_loop/stories.py +615 -0
- froid_loop/stories_engine.py +796 -0
- froid_loop/sweep.py +1892 -0
- froid_loop/tokens.py +196 -0
- froid_loop/tui/__init__.py +11 -0
- froid_loop/tui/app.py +1584 -0
- froid_loop/tui/data.py +840 -0
- froid_loop/tui/launch.py +1003 -0
- froid_loop/tui/screens/__init__.py +1 -0
- froid_loop/tui/screens/dashboard.py +1071 -0
- froid_loop/tui/screens/modals.py +943 -0
- froid_loop/tui/screens/settings_screen.py +477 -0
- froid_loop/tui/settings.py +135 -0
- froid_loop/tui/widgets.py +981 -0
- froid_loop/verify.py +4545 -0
- froid_loop/workspace.py +320 -0
- froid_loop/worktree_flow.py +2301 -0
- froid_loop-0.11.1.dist-info/METADATA +728 -0
- froid_loop-0.11.1.dist-info/RECORD +116 -0
- froid_loop-0.11.1.dist-info/WHEEL +4 -0
- froid_loop-0.11.1.dist-info/entry_points.txt +2 -0
- froid_loop-0.11.1.dist-info/licenses/LICENSE +30 -0
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Readiness gate for the froid-loop Unity engine plugin.
|
|
3
|
+
|
|
4
|
+
Blocks until the Unity Editor + MCP bridge are ready to accept commands, so a
|
|
5
|
+
dev/sweep session never starts against a half-open Editor. The engine runs this
|
|
6
|
+
as the plugin's ``ready_cmd`` and injects its configuration via the environment.
|
|
7
|
+
|
|
8
|
+
Supported MCP servers (FROID_LOOP_ENGINE_MCP):
|
|
9
|
+
- ivanmurzak : shells out to the Unity-MCP CLI's ``wait-for-ready`` (passing an
|
|
10
|
+
explicit ``--timeout`` — the CLI's own default is only 120s — and
|
|
11
|
+
retrying so a fast connection-refused against a not-yet-listening
|
|
12
|
+
Editor doesn't abort the gate early). Because the per_worktree
|
|
13
|
+
Editor hosts its *own* MCP server (unity_setup launches it with
|
|
14
|
+
``--start-server true``), the Editor↔server bridge — and therefore
|
|
15
|
+
``wait-for-ready`` — comes up *without* any MCP client connected, so
|
|
16
|
+
it is a sound readiness signal before the agent ever runs. That is
|
|
17
|
+
the default gate. Optionally (FROID_LOOP_UNITY_READY_TOOL set to a
|
|
18
|
+
tool name) it also confirms with a real read-only ``run-tool``
|
|
19
|
+
round-trip that actually executes in the Editor. This is OFF by
|
|
20
|
+
default: tool names are version-specific (e.g. ``ping``, ``unity-
|
|
21
|
+
tool-list``) and some return null/non-zero even when healthy, so the
|
|
22
|
+
round-trip is opt-in for operators who've picked a tool that works.
|
|
23
|
+
- coplaydev : connectivity check against the MCP HTTP server (best effort —
|
|
24
|
+
see note below; override engine.ready_cmd for a stricter probe).
|
|
25
|
+
|
|
26
|
+
Cold-launch grace: a per_worktree Editor is launched fresh per unit and needs
|
|
27
|
+
time to start (and import) before it can answer at all. The gate waits
|
|
28
|
+
FROID_LOOP_ENGINE_READY_GRACE seconds before the first probe; ``-1`` (the default)
|
|
29
|
+
auto-picks 120s for per_worktree and 0s for shared (a warm, already-open Editor).
|
|
30
|
+
The grace counts against the overall readiness budget.
|
|
31
|
+
|
|
32
|
+
Exit 0 = ready; non-zero = not ready (the engine defers the unit and notifies).
|
|
33
|
+
|
|
34
|
+
Env knobs (all optional):
|
|
35
|
+
FROID_LOOP_ENGINE_MCP ivanmurzak | coplaydev (default ivanmurzak)
|
|
36
|
+
FROID_LOOP_ENGINE_EDITOR_MODE shared | per_worktree (selects the grace default)
|
|
37
|
+
FROID_LOOP_WORKTREE project the Editor has open (falls back to REPO_ROOT)
|
|
38
|
+
FROID_LOOP_REPO_ROOT main repo root
|
|
39
|
+
FROID_LOOP_ENGINE_READY_TIMEOUT seconds to keep polling (default 600)
|
|
40
|
+
FROID_LOOP_ENGINE_READY_GRACE pre-probe delay seconds; -1=auto (default -1)
|
|
41
|
+
FROID_LOOP_UNITY_READY_TOOL opt-in read-only run-tool to confirm (default empty = off)
|
|
42
|
+
UNITY_MCP_CLI IvanMurzak CLI binary (default unity-mcp-cli)
|
|
43
|
+
UNITY_MCP_URL CoplayDev MCP server URL (default http://localhost:8080)
|
|
44
|
+
|
|
45
|
+
NOTE: the exact IvanMurzak CLI name/subcommand and CoplayDev readiness endpoint
|
|
46
|
+
move between releases — verify against the version installed in your project and
|
|
47
|
+
override ``engine.ready_cmd`` in a project-local plugin if they differ.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import os
|
|
53
|
+
import shutil
|
|
54
|
+
import socket
|
|
55
|
+
import subprocess
|
|
56
|
+
import sys
|
|
57
|
+
import time
|
|
58
|
+
from urllib.parse import urlparse
|
|
59
|
+
|
|
60
|
+
# return this many seconds before the engine's outer timeout would fire, so the
|
|
61
|
+
# gate yields a clean "not ready" rc rather than being hard-killed mid-probe.
|
|
62
|
+
_SAFETY_MARGIN = 10.0
|
|
63
|
+
# default pre-probe grace per editor_mode when FROID_LOOP_ENGINE_READY_GRACE = -1.
|
|
64
|
+
_AUTO_GRACE = {"per_worktree": 120.0, "shared": 0.0}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _project() -> str:
|
|
68
|
+
return os.environ.get("FROID_LOOP_WORKTREE") or os.environ.get("FROID_LOOP_REPO_ROOT") or "."
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _timeout() -> float:
|
|
72
|
+
try:
|
|
73
|
+
return float(os.environ.get("FROID_LOOP_ENGINE_READY_TIMEOUT", "600"))
|
|
74
|
+
except ValueError:
|
|
75
|
+
return 600.0
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _grace() -> float:
|
|
79
|
+
"""Seconds to wait before the first probe. -1/unset → per-mode auto default."""
|
|
80
|
+
raw = os.environ.get("FROID_LOOP_ENGINE_READY_GRACE", "-1").strip()
|
|
81
|
+
try:
|
|
82
|
+
val = float(raw)
|
|
83
|
+
except ValueError:
|
|
84
|
+
val = -1.0
|
|
85
|
+
if val >= 0:
|
|
86
|
+
return val
|
|
87
|
+
mode = (os.environ.get("FROID_LOOP_ENGINE_EDITOR_MODE") or "shared").strip().lower()
|
|
88
|
+
return _AUTO_GRACE.get(mode, 0.0)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# run-tool prints these even on a "successful" (rc 0) invocation when the call
|
|
92
|
+
# actually failed — the CLI returns 0 on a connection-refused, and a tool that
|
|
93
|
+
# returns null comes back as an HTTP 500. Treat any of these as not-ready.
|
|
94
|
+
_TOOL_ERROR_MARKERS = ("error", "not found", "refused", "internal server error", "is null")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _ready_tool() -> str:
|
|
98
|
+
"""The optional read-only run-tool used to confirm the Editor accepts tool
|
|
99
|
+
calls. Empty (the default) disables the round-trip: wait-for-ready alone gates
|
|
100
|
+
readiness, which is sound now that the Editor hosts its own MCP server."""
|
|
101
|
+
return os.environ.get("FROID_LOOP_UNITY_READY_TOOL", "").strip()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _wait_for_ready(cli: str, remaining: float) -> tuple[int, str]:
|
|
105
|
+
"""One `wait-for-ready` call that polls for the rest of the budget. Returns
|
|
106
|
+
(rc, output); rc 0 means the Editor↔server bridge is up."""
|
|
107
|
+
cli_timeout_ms = max(1000, int(remaining * 1000))
|
|
108
|
+
try:
|
|
109
|
+
proc = subprocess.run(
|
|
110
|
+
[cli, "wait-for-ready", _project(), "--timeout", str(cli_timeout_ms)],
|
|
111
|
+
timeout=remaining + 5,
|
|
112
|
+
capture_output=True,
|
|
113
|
+
text=True,
|
|
114
|
+
)
|
|
115
|
+
except subprocess.TimeoutExpired:
|
|
116
|
+
return 1, "wait-for-ready process timed out"
|
|
117
|
+
return proc.returncode, (proc.stdout + proc.stderr).strip()[-500:]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _run_tool_probe(cli: str, tool: str, remaining: float) -> tuple[int, str]:
|
|
121
|
+
"""A real read-only tool round-trip against the Editor over HTTP — proof it
|
|
122
|
+
can accept tool calls, independent of any MCP client. Returns (rc, output)."""
|
|
123
|
+
tool_timeout_ms = max(1000, min(60000, int(remaining * 1000)))
|
|
124
|
+
try:
|
|
125
|
+
proc = subprocess.run(
|
|
126
|
+
[cli, "run-tool", tool, _project(), "--timeout", str(tool_timeout_ms)],
|
|
127
|
+
timeout=remaining + 5,
|
|
128
|
+
capture_output=True,
|
|
129
|
+
text=True,
|
|
130
|
+
)
|
|
131
|
+
except subprocess.TimeoutExpired:
|
|
132
|
+
return 1, f"run-tool {tool} timed out"
|
|
133
|
+
out = (proc.stdout + proc.stderr).strip()
|
|
134
|
+
rc = proc.returncode
|
|
135
|
+
# the CLI's exit code is unreliable (0 on connection-refused; non-zero on a
|
|
136
|
+
# null-returning but healthy tool) — also scan the output for error markers.
|
|
137
|
+
if rc == 0 and any(m in out.lower() for m in _TOOL_ERROR_MARKERS):
|
|
138
|
+
rc = 1
|
|
139
|
+
return rc, out[-500:]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _ready_ivanmurzak(deadline: float) -> int:
|
|
143
|
+
cli = os.environ.get("UNITY_MCP_CLI", "unity-mcp-cli")
|
|
144
|
+
if shutil.which(cli) is None:
|
|
145
|
+
print(
|
|
146
|
+
f"unity_ready: {cli!r} not found on PATH; install the Unity-MCP CLI, set "
|
|
147
|
+
"UNITY_MCP_CLI, or override engine.ready_cmd",
|
|
148
|
+
file=sys.stderr,
|
|
149
|
+
)
|
|
150
|
+
return 2
|
|
151
|
+
tool = _ready_tool()
|
|
152
|
+
last = ""
|
|
153
|
+
while True:
|
|
154
|
+
remaining = deadline - time.monotonic()
|
|
155
|
+
if remaining <= 0:
|
|
156
|
+
break
|
|
157
|
+
# phase 1: wait until the Editor↔server bridge reports ready. If it
|
|
158
|
+
# fast-fails (Editor not listening yet) we retry until the deadline.
|
|
159
|
+
rc, last = _wait_for_ready(cli, remaining)
|
|
160
|
+
if rc != 0:
|
|
161
|
+
if deadline - time.monotonic() <= 0:
|
|
162
|
+
break
|
|
163
|
+
time.sleep(3) # brief pause before retrying a fast-fail
|
|
164
|
+
continue
|
|
165
|
+
# phase 2: confirm with a real tool round-trip (proves the Editor accepts
|
|
166
|
+
# tool calls with no client attached). Skipped when the tool is empty.
|
|
167
|
+
if not tool:
|
|
168
|
+
return 0
|
|
169
|
+
remaining = deadline - time.monotonic()
|
|
170
|
+
if remaining <= 0:
|
|
171
|
+
break
|
|
172
|
+
rc, last = _run_tool_probe(cli, tool, remaining)
|
|
173
|
+
if rc == 0:
|
|
174
|
+
return 0
|
|
175
|
+
if deadline - time.monotonic() <= 0:
|
|
176
|
+
break
|
|
177
|
+
time.sleep(3) # bridge up but tool not answering yet (still importing?)
|
|
178
|
+
print(f"unity_ready: Editor not ready within budget: {last}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _ready_coplaydev(deadline: float) -> int:
|
|
183
|
+
url = os.environ.get("UNITY_MCP_URL", "http://localhost:8080")
|
|
184
|
+
parsed = urlparse(url)
|
|
185
|
+
host = parsed.hostname or "localhost"
|
|
186
|
+
port = parsed.port or (443 if parsed.scheme == "https" else 8080)
|
|
187
|
+
last = ""
|
|
188
|
+
while time.monotonic() < deadline:
|
|
189
|
+
try:
|
|
190
|
+
with socket.create_connection((host, port), timeout=5):
|
|
191
|
+
print(
|
|
192
|
+
f"unity_ready: connected to CoplayDev MCP at {host}:{port} "
|
|
193
|
+
"(connectivity check only — not a full Editor-ready probe)",
|
|
194
|
+
file=sys.stderr,
|
|
195
|
+
)
|
|
196
|
+
return 0
|
|
197
|
+
except OSError as exc: # server not up yet
|
|
198
|
+
last = str(exc)
|
|
199
|
+
time.sleep(2)
|
|
200
|
+
print(
|
|
201
|
+
f"unity_ready: could not reach CoplayDev MCP at {host}:{port}: {last}",
|
|
202
|
+
file=sys.stderr,
|
|
203
|
+
)
|
|
204
|
+
return 1
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def main() -> int:
|
|
208
|
+
mcp = (os.environ.get("FROID_LOOP_ENGINE_MCP") or "ivanmurzak").strip().lower()
|
|
209
|
+
# the whole gate must finish a hair before the engine's outer timeout fires.
|
|
210
|
+
deadline = time.monotonic() + max(1.0, _timeout() - _SAFETY_MARGIN)
|
|
211
|
+
grace = min(_grace(), max(0.0, deadline - time.monotonic()))
|
|
212
|
+
if grace > 0:
|
|
213
|
+
print(
|
|
214
|
+
f"unity_ready: waiting {grace:.0f}s for the Editor to start before probing",
|
|
215
|
+
file=sys.stderr,
|
|
216
|
+
)
|
|
217
|
+
time.sleep(grace)
|
|
218
|
+
if mcp == "ivanmurzak":
|
|
219
|
+
return _ready_ivanmurzak(deadline)
|
|
220
|
+
if mcp == "coplaydev":
|
|
221
|
+
return _ready_coplaydev(deadline)
|
|
222
|
+
print(
|
|
223
|
+
f"unity_ready: unknown FROID_LOOP_ENGINE_MCP={mcp!r} (expected ivanmurzak|coplaydev)",
|
|
224
|
+
file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
return 2
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
if __name__ == "__main__":
|
|
230
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Seed the Unity scene auto-save guard into a froid-loop-driven project.
|
|
3
|
+
|
|
4
|
+
Unity-MCP GameObject tools mark a scene dirty but never save it, so a project
|
|
5
|
+
driven by the shared editor accumulates a chronically dirty scene. That dirty
|
|
6
|
+
state is what raises the two run-stalling modal dialogs ("scene changed on disk —
|
|
7
|
+
reload?" when git/an agent rewrites the open .unity file, and "save changes before
|
|
8
|
+
quitting?" on editor exit). A modal freezes ``EditorApplication.update`` — which
|
|
9
|
+
the MCP plugin uses to dispatch tool calls — so every MCP call then times out.
|
|
10
|
+
|
|
11
|
+
This helper copies an editor-only auto-save guard (``SceneAutoSaveGuard.cs`` + its
|
|
12
|
+
asmdef, with pre-generated ``.meta`` files carrying fixed GUIDs) into the project's
|
|
13
|
+
``Assets`` tree so the editor's very first import already sees it. It is invoked by
|
|
14
|
+
the Unity plugin *before* ``unity_setup.py`` launches the per_worktree Editor, and
|
|
15
|
+
at the readiness gate in shared mode (where ``unity_setup.py`` never runs).
|
|
16
|
+
|
|
17
|
+
The install is idempotent and version-aware: it copies the payload only when the
|
|
18
|
+
target ``.cs`` is absent or its ``froid-loop-scene-guard-version`` header is older
|
|
19
|
+
than the payload's. It never deletes or rewrites any file it did not ship, and a
|
|
20
|
+
missing asset tree is a graceful skip (not every worktree is a Unity project yet).
|
|
21
|
+
|
|
22
|
+
The seeded guard is committed into the consumer project by story-finalize's
|
|
23
|
+
``git add -A`` — that is intended: the guard travels with the repo so any editor
|
|
24
|
+
that opens the project is protected. Because seeding happens pre-baseline (before
|
|
25
|
+
the unit's untracked-file baseline is snapshotted), ``verify.safe_rollback`` never
|
|
26
|
+
treats it as a created-this-unit file and never deletes it.
|
|
27
|
+
|
|
28
|
+
Env (injected by the Unity plugin):
|
|
29
|
+
FROID_LOOP_WORKTREE project root (Assets = <worktree>/Assets)
|
|
30
|
+
FROID_LOOP_UNITY_INSTALL_SCENE_GUARD "1" (default) enables; "0"/false skips
|
|
31
|
+
FROID_LOOP_UNITY_SCENE_GUARD_DIR install dir (default Assets/FroidLoop/Editor)
|
|
32
|
+
|
|
33
|
+
Exit 0 = seeded, already-current, or a benign skip (disabled / no asset tree);
|
|
34
|
+
non-zero = a real error (no worktree, unreadable payload, a failed copy).
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
from __future__ import annotations
|
|
38
|
+
|
|
39
|
+
import os
|
|
40
|
+
import re
|
|
41
|
+
import shutil
|
|
42
|
+
import sys
|
|
43
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
44
|
+
|
|
45
|
+
# The guard's version header line, e.g. "// froid-loop-scene-guard-version: 1.0.0".
|
|
46
|
+
# The payload's value is the source of truth; a target with an older (or missing)
|
|
47
|
+
# value is reinstalled, a newer/equal one is left alone.
|
|
48
|
+
_VERSION_RE = re.compile(r"froid-loop-scene-guard-version:\s*([0-9]+(?:\.[0-9]+)*)")
|
|
49
|
+
# The guard's canonical source file — its header carries the version, and its
|
|
50
|
+
# presence/absence in the target decides a fresh install.
|
|
51
|
+
_GUARD_CS = "SceneAutoSaveGuard.cs"
|
|
52
|
+
# Payload subdir holding the parent-folder ``.meta`` files, keyed by folder name;
|
|
53
|
+
# separated from the content files so "every file in the payload root" is exactly
|
|
54
|
+
# the set copied into the install dir.
|
|
55
|
+
_FOLDERS_SUBDIR = "_folders"
|
|
56
|
+
_DEFAULT_GUARD_DIR = "Assets/FroidLoop/Editor"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _is_absolute(value: str) -> bool:
|
|
60
|
+
"""True if ``value`` is rooted or drive-qualified in *either* POSIX or Windows
|
|
61
|
+
terms (mirrors ``froid_loop.platform_util.is_absolute_path`` — this deployed
|
|
62
|
+
script is stdlib-only and cannot import core)."""
|
|
63
|
+
win = PureWindowsPath(value)
|
|
64
|
+
return PurePosixPath(value).is_absolute() or bool(win.drive or win.root)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _has_parent_ref(value: str) -> bool:
|
|
68
|
+
"""True if ``value`` contains a ``..`` segment in *either* POSIX or Windows
|
|
69
|
+
terms (mirrors ``froid_loop.platform_util.has_parent_ref``)."""
|
|
70
|
+
return ".." in PurePosixPath(value).parts or ".." in PureWindowsPath(value).parts
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _names_tree_root(value: str) -> bool:
|
|
74
|
+
"""True if ``value`` names the worktree rather than anything inside it
|
|
75
|
+
(mirrors ``froid_loop.platform_util.names_tree_root``).
|
|
76
|
+
|
|
77
|
+
The third member of the family, and the one this script was missing. Win32
|
|
78
|
+
strips every trailing period and space from a path's final component, so
|
|
79
|
+
``"..."`` names the worktree root there while both pure flavours read it as an
|
|
80
|
+
ordinary one-segment name. That mattered here: the asset-root probe below
|
|
81
|
+
would find the worktree itself for such a value, so the payload landed in the
|
|
82
|
+
worktree root instead of under ``Assets/``. (The caller once ``.strip()``-ed
|
|
83
|
+
the env var before validating, which collapsed the *space* spellings into
|
|
84
|
+
``"."``; validation now sees the authored value, so every spelling lands
|
|
85
|
+
here.)"""
|
|
86
|
+
if PurePosixPath(value) == PurePosixPath(".") or PureWindowsPath(value) == PureWindowsPath("."):
|
|
87
|
+
return True
|
|
88
|
+
parts = [part for part in value.replace("\\", "/").split("/") if part]
|
|
89
|
+
return bool(parts) and all(part.strip(" .") == "" and part != ".." for part in parts)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Reserved on Windows regardless of extension: CON.txt is as illegal as CON. Mirrors
|
|
93
|
+
# ``froid_loop.platform_util._RESERVED_BASENAMES`` member for member, including its
|
|
94
|
+
# deliberate over-refusals: Microsoft's published list names only COM1-COM9 /
|
|
95
|
+
# LPT1-LPT9 and omits the console pair, while Wine's ``RtlIsDosDeviceName_U`` matches
|
|
96
|
+
# CONIN$/CONOUT$ but rejects the 0 forms, so COM0/LPT0 are backed by neither. Mirror
|
|
97
|
+
# the set, not any claim about it.
|
|
98
|
+
_RESERVED_BASENAMES = frozenset(
|
|
99
|
+
{"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
|
|
100
|
+
| {f"COM{i}" for i in range(10)}
|
|
101
|
+
| {f"LPT{i}" for i in range(10)}
|
|
102
|
+
| {f"COM{s}" for s in "¹²³"}
|
|
103
|
+
| {f"LPT{s}" for s in "¹²³"}
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _is_reserved_basename(seg: str) -> bool:
|
|
108
|
+
"""True if ``seg``'s basename (before the first dot, trailing spaces trimmed —
|
|
109
|
+
``CON .txt`` counts) is a Windows reserved device name (mirrors
|
|
110
|
+
``froid_loop.platform_util._is_reserved_basename``)."""
|
|
111
|
+
stem = seg.split(".", 1)[0].rstrip(" ")
|
|
112
|
+
return stem.upper() in _RESERVED_BASENAMES
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _names_win32_alias(value: str) -> bool:
|
|
116
|
+
"""True if any component of ``value`` names something other than itself on Win32 —
|
|
117
|
+
a reserved device name, or a name whose trailing periods and spaces Win32 trims
|
|
118
|
+
away before the path ever reaches the filesystem (mirrors
|
|
119
|
+
``froid_loop.platform_util.names_win32_alias`` — this deployed script is stdlib-only
|
|
120
|
+
and cannot import core).
|
|
121
|
+
|
|
122
|
+
The fourth member of the family, and the only one about *determinism* rather than
|
|
123
|
+
containment: ``"Assets/NUL"`` and ``"Assets/FroidLoop/Editor."`` are both inside the
|
|
124
|
+
worktree by every measure the other three apply, and both name a different thing on
|
|
125
|
+
Windows than they spell here. The ``not root_naming`` term hands a value made
|
|
126
|
+
entirely of period/space components back to :func:`_names_tree_root` — while still
|
|
127
|
+
catching one such component embedded beside a real one (``"Assets/..."``), which is
|
|
128
|
+
nobody's root and nobody's parent — and the ``part not in (".", "..")`` carve-out
|
|
129
|
+
hands plain ``".."`` back to :func:`_has_parent_ref`, so all four refuse disjoint
|
|
130
|
+
spelling classes. Core's docstring carries the two rules, their sources, and the
|
|
131
|
+
Windows 11 narrowing this deliberately does not track."""
|
|
132
|
+
parts = [part for part in value.replace("\\", "/").split("/") if part]
|
|
133
|
+
root_naming = _names_tree_root(value)
|
|
134
|
+
return any(
|
|
135
|
+
_is_reserved_basename(part)
|
|
136
|
+
or (part != part.rstrip(" .") and part not in (".", "..") and not root_naming)
|
|
137
|
+
for part in parts
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _truthy(value: str | None, default: bool) -> bool:
|
|
142
|
+
if value is None or value.strip() == "":
|
|
143
|
+
return default
|
|
144
|
+
return value.strip().lower() in ("1", "true", "yes", "on")
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _worktree() -> Path | None:
|
|
148
|
+
wt = os.environ.get("FROID_LOOP_WORKTREE")
|
|
149
|
+
return Path(wt) if wt else None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _payload_dir() -> Path:
|
|
153
|
+
"""The bundled ``unity_assets/`` payload, resolved relative to this script so it
|
|
154
|
+
travels with a project-local copy of the plugin too."""
|
|
155
|
+
return Path(__file__).resolve().parent / "unity_assets"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _parse_version(text: str) -> tuple[int, ...] | None:
|
|
159
|
+
"""The ``froid-loop-scene-guard-version`` tuple from a guard source, or None if
|
|
160
|
+
the header is absent (an absent/unrecognized header sorts as oldest)."""
|
|
161
|
+
match = _VERSION_RE.search(text)
|
|
162
|
+
if not match:
|
|
163
|
+
return None
|
|
164
|
+
return tuple(int(part) for part in match.group(1).split("."))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _read_version(cs_path: Path) -> tuple[int, ...] | None:
|
|
168
|
+
try:
|
|
169
|
+
return _parse_version(cs_path.read_text(encoding="utf-8", errors="replace"))
|
|
170
|
+
except OSError:
|
|
171
|
+
return None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _content_files(payload: Path) -> list[Path]:
|
|
175
|
+
"""The payload files copied verbatim into the install dir: the guard source,
|
|
176
|
+
the asmdef, and their ``.meta`` companions (everything directly in the payload
|
|
177
|
+
root — the ``_folders`` subdir is handled separately)."""
|
|
178
|
+
return sorted(p for p in payload.iterdir() if p.is_file())
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _ensure_dir_with_meta(directory: Path, payload: Path) -> None:
|
|
182
|
+
"""Create ``directory`` if absent and, when the payload ships a matching
|
|
183
|
+
folder ``.meta`` (keyed by the folder's name), drop it beside the folder with
|
|
184
|
+
its fixed GUID. Never clobbers an existing folder meta — its GUID may already
|
|
185
|
+
be referenced by the project."""
|
|
186
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
187
|
+
folder_meta = payload / _FOLDERS_SUBDIR / f"{directory.name}.meta"
|
|
188
|
+
target_meta = directory.parent / f"{directory.name}.meta"
|
|
189
|
+
if folder_meta.is_file() and not target_meta.exists():
|
|
190
|
+
shutil.copy2(folder_meta, target_meta)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _install(worktree: Path, target_dir: Path, payload: Path) -> int:
|
|
194
|
+
"""Copy the payload into ``target_dir``, creating each parent folder (with its
|
|
195
|
+
fixed-GUID meta) along the way. Returns 0 on success, non-zero on a real I/O
|
|
196
|
+
error."""
|
|
197
|
+
rel = target_dir.relative_to(worktree)
|
|
198
|
+
try:
|
|
199
|
+
# Create every path segment from the worktree down to the install dir,
|
|
200
|
+
# laying a fixed-GUID folder meta beside each that the payload ships one for
|
|
201
|
+
# (Assets itself has no payload meta — Unity owns it — so it is skipped).
|
|
202
|
+
current = worktree
|
|
203
|
+
for segment in rel.parts:
|
|
204
|
+
current = current / segment
|
|
205
|
+
_ensure_dir_with_meta(current, payload)
|
|
206
|
+
for src in _content_files(payload):
|
|
207
|
+
shutil.copy2(src, target_dir / src.name)
|
|
208
|
+
except OSError as exc:
|
|
209
|
+
print(f"unity_seed_assets: install failed: {exc}", file=sys.stderr)
|
|
210
|
+
return 1
|
|
211
|
+
print(
|
|
212
|
+
f"unity_seed_assets: seeded scene auto-save guard into {rel}",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
return 0
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def main() -> int:
|
|
219
|
+
if not _truthy(os.environ.get("FROID_LOOP_UNITY_INSTALL_SCENE_GUARD"), True):
|
|
220
|
+
print("unity_seed_assets: scene guard disabled; skipping", file=sys.stderr)
|
|
221
|
+
return 0
|
|
222
|
+
|
|
223
|
+
worktree = _worktree()
|
|
224
|
+
if worktree is None:
|
|
225
|
+
print("unity_seed_assets: FROID_LOOP_WORKTREE is not set", file=sys.stderr)
|
|
226
|
+
return 2
|
|
227
|
+
|
|
228
|
+
payload = _payload_dir()
|
|
229
|
+
guard_src = payload / _GUARD_CS
|
|
230
|
+
if not guard_src.is_file():
|
|
231
|
+
print(f"unity_seed_assets: payload guard missing at {guard_src}", file=sys.stderr)
|
|
232
|
+
return 2
|
|
233
|
+
payload_version = _read_version(guard_src)
|
|
234
|
+
|
|
235
|
+
# `.strip()` decides only whether the env var is SET — the authored value is
|
|
236
|
+
# what gets validated. Stripping before validation silently trimmed the exact
|
|
237
|
+
# spelling the guard below promises to refuse ("Assets/FroidLoop/Editor " was
|
|
238
|
+
# trimmed and installed into Editor), and made this the one site of seven whose
|
|
239
|
+
# config value was normalized before the family saw it.
|
|
240
|
+
raw = os.environ.get("FROID_LOOP_UNITY_SCENE_GUARD_DIR", "")
|
|
241
|
+
guard_dir = raw if raw.strip() else _DEFAULT_GUARD_DIR
|
|
242
|
+
rel = Path(guard_dir)
|
|
243
|
+
# The install dir must stay inside the worktree AND name something in it: an
|
|
244
|
+
# absolute/drive-qualified path would make _install's relative_to() raise, a
|
|
245
|
+
# ".." segment would let the copy escape the project tree, and a root-naming
|
|
246
|
+
# spelling would scatter the payload across the worktree root itself. The fourth
|
|
247
|
+
# term is about determinism rather than containment: Win32 trims a component's
|
|
248
|
+
# trailing periods and spaces, so "Assets/FroidLoop/Editor." installs into "Editor"
|
|
249
|
+
# while the configured string still spells "Editor.", and a component naming a
|
|
250
|
+
# reserved device writes to the device instead of the tree.
|
|
251
|
+
if (
|
|
252
|
+
not rel.parts
|
|
253
|
+
or _names_tree_root(guard_dir)
|
|
254
|
+
or _is_absolute(guard_dir)
|
|
255
|
+
or _has_parent_ref(guard_dir)
|
|
256
|
+
or _names_win32_alias(guard_dir)
|
|
257
|
+
):
|
|
258
|
+
print(
|
|
259
|
+
f"unity_seed_assets: invalid scene guard dir {guard_dir!r}: it must name a "
|
|
260
|
+
"path inside the worktree and must not name a Windows device or end a "
|
|
261
|
+
"component in a period or space",
|
|
262
|
+
file=sys.stderr,
|
|
263
|
+
)
|
|
264
|
+
return 2
|
|
265
|
+
target_dir = worktree / guard_dir
|
|
266
|
+
|
|
267
|
+
# Only seed into a project whose asset root is actually checked out. A worktree
|
|
268
|
+
# without it is not (yet) a Unity project, and scattering an Assets/ tree into
|
|
269
|
+
# it would be wrong — a benign skip, never an error, never destructive.
|
|
270
|
+
asset_root = worktree / rel.parts[0]
|
|
271
|
+
if not asset_root.is_dir():
|
|
272
|
+
print(
|
|
273
|
+
f"unity_seed_assets: {rel.parts[0]}/ not present under the worktree; "
|
|
274
|
+
"nothing to seed",
|
|
275
|
+
file=sys.stderr,
|
|
276
|
+
)
|
|
277
|
+
return 0
|
|
278
|
+
|
|
279
|
+
target_cs = target_dir / _GUARD_CS
|
|
280
|
+
if target_cs.is_file():
|
|
281
|
+
target_version = _read_version(target_cs)
|
|
282
|
+
# An unreadable/absent header sorts as oldest, so a foreign or stale guard
|
|
283
|
+
# is refreshed; an equal-or-newer guard is left untouched (idempotent).
|
|
284
|
+
current = target_version or ()
|
|
285
|
+
incoming = payload_version or ()
|
|
286
|
+
if current >= incoming:
|
|
287
|
+
print(
|
|
288
|
+
"unity_seed_assets: scene guard already current "
|
|
289
|
+
f"({'.'.join(map(str, current)) or 'unversioned'}); nothing to do",
|
|
290
|
+
file=sys.stderr,
|
|
291
|
+
)
|
|
292
|
+
return 0
|
|
293
|
+
|
|
294
|
+
return _install(worktree, target_dir, payload)
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
if __name__ == "__main__":
|
|
298
|
+
raise SystemExit(main())
|