Skip to content

Commit 232ebc2

Browse files
author
lpb-docs
committed
fix(lpb-config): render runtime config from templates after reset/update/merge
The config repo ships templates (settings.json.template, lpb-memory-config.json.template); the rendered runtime files are gitignored and only start.sh's first-boot block renders them — gated by ~/.pi/.initialized, which survives 'lpb-config reset'. Consequences: - 'reset' wiped the rendered config and the re-clone only restored templates — Pi then ran with no settings.json (no extensions/packages) and no memory config, unrecoverable via lpb-config. - 'update'/'merge' refreshed templates but left rendered pins frozen at the old stack version, with no warning. lpb-config now renders itself: - 'render [--force]' command: non-forcing creates missing files and warns on stale pins / new template packages; forcing merges (user keys and user-added packages preserved; template pins win in settings.json, local keys win in the memory config) - auto-render after reset (force), update and merge (non-forcing) - version substitution mirrors start.sh (LPB_VERSION env first, then the devstack VERSION file) - clone_or_init survives a stale .git with an existing origin (remote set-url fallback) - reset verifies the wipe and warns when entries survive rmtree - align notes that its pin edits are local (gitignored) state - status marks the remote HEAD as last-fetch Tests: 9 new cases in test_localpibox_config.py (20/20 passing; test_lpb.py 52/52 + all 13 localpibox suites green).
1 parent 03721aa commit 232ebc2

3 files changed

Lines changed: 455 additions & 9 deletions

File tree

.pi/skills/localpibox-repo-workflow/SKILL.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,10 +179,20 @@ push. `bump` (without `--push`) warns about this.
179179

180180
```bash
181181
lpb-config status | update | reset [--force] | merge # config repo
182+
lpb-config render [--force] # regen runtime config from templates
182183
lpb-config align # pins → latest GitHub tags
183184
lpb-config memory show | setup # lpb-memory config
184185
```
185186

187+
`render` recreates the gitignored runtime files (`settings.json`,
188+
`lpb-memory-config.json`) from the repo's templates. lpb-config auto-renders
189+
after `reset` (force) / `update` / `merge` (non-forcing): without it the
190+
rendered config was unrecoverable after `reset` (start.sh only renders on
191+
first boot, gated by `~/.pi/.initialized`). Non-forcing render never
192+
overwrites — it creates missing files and warns on stale pins; `--force`
193+
merges (user keys/packages preserved, template pins win in settings.json,
194+
local keys win in the memory config).
195+
186196
**`lpb-devstack`** — DevOps workspace tool (container + host):
187197

188198
```bash
@@ -207,11 +217,14 @@ Both tools are thin CLIs over the shared `scripts/localpibox/stack/` library
207217

208218
1. Config repo ships `settings.json.template` with `__LPB_VERSION__` placeholders
209219
2. First boot: `start.sh` generates `settings.json` (replaces placeholders)
210-
3. No model/provider preconfigured — user runs `/login lemonade`
211-
4. Pin sync: `lpb-devstack workspace sync --extensions`
220+
3. `lpb-config render` regenerates it on demand (auto after reset/update/
221+
merge) — this is the recovery path when the rendered file is lost or
222+
its pins are stale after a stack version move
223+
4. No model/provider preconfigured — user runs `/login lemonade`
224+
5. Pin sync: `lpb-devstack workspace sync --extensions`
212225
(main pipeline reads the stable version from devstack `origin/main`)
213-
5. `lpb-devstack validate` checks pins match the current stack version
214-
6. Persistent on the host volume — survives container rebuilds
226+
6. `lpb-devstack validate` checks pins match the current stack version
227+
7. Persistent on the host volume — survives container rebuilds
215228

216229
Pins look like: `git:github.com/lpb-stack/pi-subagents@0.0.57-lpb-dev`
217230

scripts/test_localpibox_config.py

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
from testharness import run_lpbx_suite, _quiet_console, log_mod, _load_script, SUPPORT_DIR
88

99
import io
10+
import json
11+
import os
1012
import subprocess
1113

1214
lc = _load_script('lpb_config', SUPPORT_DIR / 'lpb-config')
@@ -146,6 +148,256 @@ def test_lpb_config_merge_uptodate(tmpdir):
146148
assert lc.cmd_merge(tmpdir / "nope", str(remote), "main", _quiet_console()) == 1
147149

148150

151+
# ─── Template rendering (render / auto-render after reset/update/merge) ──
152+
153+
154+
def _make_templates(dir_):
155+
(dir_ / "settings.json.template").write_text(json.dumps({
156+
"packages": ["git:github.com/lpb-stack/demo@__LPB_VERSION__", "npm:somepkg"],
157+
"theme": "dark",
158+
}))
159+
(dir_ / "lpb-memory-config.json.template").write_text(json.dumps({
160+
"reviewTransport": "subprocess",
161+
"memoryCharLimit": 3000,
162+
}))
163+
164+
165+
def _push_templates(work):
166+
_make_templates(work)
167+
# Mirror the real config repo: rendered runtime files are gitignored,
168+
# otherwise they'd trip lpb-config's local-change guard.
169+
(work / ".gitignore").write_text("settings.json\nlpb-memory-config.json\n")
170+
subprocess.run(["git", "-C", str(work), "add", "."], check=True)
171+
subprocess.run(["git", "-C", str(work), "commit", "-qm", "templates"], check=True)
172+
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", "main"], check=True)
173+
174+
175+
def _with_lpb_version(value: str):
176+
"""Set LPB_VERSION for the duration of a test; returns restore closure."""
177+
old = os.environ.get("LPB_VERSION")
178+
os.environ["LPB_VERSION"] = value
179+
180+
def _restore():
181+
if old is None:
182+
os.environ.pop("LPB_VERSION", None)
183+
else:
184+
os.environ["LPB_VERSION"] = old
185+
return _restore
186+
187+
188+
def _capture_console():
189+
out, err = io.StringIO(), io.StringIO()
190+
cons = log_mod.Console(color=False, out=out, err=err)
191+
return cons, lambda: out.getvalue() + err.getvalue()
192+
193+
194+
def test_render_creates_from_template(tmpdir):
195+
agent = tmpdir / "agent"
196+
agent.mkdir()
197+
_make_templates(agent)
198+
restore = _with_lpb_version("0.9.9-lpb")
199+
try:
200+
cons, text = _capture_console()
201+
assert lc.cmd_render(agent, cons) == 0
202+
finally:
203+
restore()
204+
text = text()
205+
raw = (agent / "settings.json").read_text()
206+
settings = json.loads(raw)
207+
assert settings["packages"][0] == "git:github.com/lpb-stack/demo@0.9.9-lpb"
208+
assert "__LPB_VERSION__" not in raw
209+
mem = json.loads((agent / "lpb-memory-config.json").read_text())
210+
assert mem["reviewTransport"] == "subprocess"
211+
assert "Generated" in text
212+
213+
214+
def test_render_no_env_uses_version_file_fallback(tmpdir):
215+
"""With LPB_VERSION unset, falls back to the devstack VERSION file."""
216+
agent = tmpdir / "agent"
217+
agent.mkdir()
218+
_make_templates(agent)
219+
old = os.environ.pop("LPB_VERSION", None)
220+
try:
221+
cons, text = _capture_console()
222+
assert lc.cmd_render(agent, cons) == 0
223+
finally:
224+
if old is not None:
225+
os.environ["LPB_VERSION"] = old
226+
raw = (agent / "settings.json").read_text()
227+
# Must be a real version (no placeholder left, no empty pin)
228+
assert "__LPB_VERSION__" not in raw
229+
assert "demo@" in raw
230+
231+
232+
def test_render_nonforce_never_overwrites(tmpdir):
233+
agent = tmpdir / "agent"
234+
agent.mkdir()
235+
_make_templates(agent)
236+
customized = {
237+
"packages": ["git:github.com/lpb-stack/demo@0.1.0-lpb", "npm:user-pkg"],
238+
"theme": "light",
239+
"defaultProvider": "lemonade",
240+
}
241+
(agent / "settings.json").write_text(json.dumps(customized))
242+
restore = _with_lpb_version("0.2.0-lpb")
243+
try:
244+
cons, text = _capture_console()
245+
assert lc.cmd_render(agent, cons) == 0
246+
finally:
247+
restore()
248+
# untouched file, but stale pins reported
249+
text = text()
250+
assert json.loads((agent / "settings.json").read_text()) == customized
251+
assert "stale" in text and "0.1.0-lpb -> 0.2.0-lpb" in text
252+
253+
254+
def test_render_force_repins_and_keeps_user_keys(tmpdir):
255+
agent = tmpdir / "agent"
256+
agent.mkdir()
257+
_make_templates(agent)
258+
customized = {
259+
"packages": ["git:github.com/lpb-stack/demo@0.1.0-lpb", "npm:user-pkg"],
260+
"theme": "light",
261+
"defaultProvider": "lemonade",
262+
}
263+
(agent / "settings.json").write_text(json.dumps(customized))
264+
restore = _with_lpb_version("0.2.0-lpb")
265+
try:
266+
cons, text = _capture_console()
267+
assert lc.cmd_render(agent, cons, force=True) == 0
268+
finally:
269+
restore()
270+
s = json.loads((agent / "settings.json").read_text())
271+
assert s["packages"][0] == "git:github.com/lpb-stack/demo@0.2.0-lpb" # re-pinned
272+
assert "npm:user-pkg" in s["packages"] # user package preserved
273+
assert s["defaultProvider"] == "lemonade" # user key preserved
274+
assert s["theme"] == "dark" # template key wins
275+
276+
277+
def test_render_force_memory_local_wins(tmpdir):
278+
agent = tmpdir / "agent"
279+
agent.mkdir()
280+
_make_templates(agent)
281+
(agent / "lpb-memory-config.json").write_text(json.dumps({
282+
"reviewTransport": "direct",
283+
"llmModelOverride": "qwen-x",
284+
}))
285+
restore = _with_lpb_version("0.2.0-lpb")
286+
try:
287+
cons, text = _capture_console()
288+
assert lc.cmd_render(agent, cons, force=True) == 0
289+
finally:
290+
restore()
291+
m = json.loads((agent / "lpb-memory-config.json").read_text())
292+
assert m["reviewTransport"] == "direct" # local (wizard) wins
293+
assert m["llmModelOverride"] == "qwen-x" # wizard key kept
294+
assert m["memoryCharLimit"] == 3000 # template fills gaps
295+
296+
297+
def test_reset_renders_runtime_config(tmpdir):
298+
remote = _setup_git_remote(tmpdir)
299+
_push_templates(tmpdir / "work")
300+
agent = tmpdir / "agent"
301+
restore = _with_lpb_version("0.5.5-lpb")
302+
try:
303+
lc.cmd_update(agent, str(remote), "main", _quiet_console())
304+
(agent / "junk").write_text("x")
305+
cons, text = _capture_console()
306+
assert lc.cmd_reset(agent, str(remote), "main", cons, force=True) == 0
307+
finally:
308+
restore()
309+
assert not (agent / "junk").exists()
310+
s = json.loads((agent / "settings.json").read_text())
311+
assert s["packages"][0] == "git:github.com/lpb-stack/demo@0.5.5-lpb"
312+
assert (agent / "lpb-memory-config.json").is_file()
313+
assert "regenerated from templates" in text()
314+
315+
316+
def test_update_restores_missing_rendered(tmpdir):
317+
remote = _setup_git_remote(tmpdir)
318+
_push_templates(tmpdir / "work")
319+
agent = tmpdir / "agent"
320+
restore = _with_lpb_version("0.6.6-lpb")
321+
try:
322+
lc.cmd_update(agent, str(remote), "main", _quiet_console())
323+
assert (agent / "settings.json").is_file()
324+
(agent / "settings.json").unlink()
325+
_push_commit(tmpdir / "work", "bump", "three")
326+
cons, text = _capture_console()
327+
assert lc.cmd_update(agent, str(remote), "main", cons) == 0
328+
finally:
329+
restore()
330+
raw = (agent / "settings.json").read_text()
331+
assert "0.6.6-lpb" in raw
332+
assert "Generated settings.json" in text()
333+
334+
335+
def test_update_warns_stale_pins_without_clobbering(tmpdir):
336+
"""Stack version moves (new image), rendered file keeps old pins:
337+
update must warn, not silently overwrite."""
338+
remote = _setup_git_remote(tmpdir)
339+
_push_templates(tmpdir / "work")
340+
agent = tmpdir / "agent"
341+
restore = _with_lpb_version("0.1.0-lpb")
342+
try:
343+
lc.cmd_update(agent, str(remote), "main", _quiet_console())
344+
finally:
345+
restore()
346+
restore2 = _with_lpb_version("0.2.0-lpb")
347+
try:
348+
_push_commit(tmpdir / "work", "x", "four")
349+
cons, text = _capture_console()
350+
assert lc.cmd_update(agent, str(remote), "main", cons) == 0
351+
finally:
352+
restore2()
353+
assert "stale" in text()
354+
s = json.loads((agent / "settings.json").read_text())
355+
assert s["packages"][0].endswith("@0.1.0-lpb") # untouched
356+
357+
358+
def test_clone_or_init_survives_stale_remote(tmpdir):
359+
"""Partial wipe: leftover .git already has an origin — repoint, don't abort."""
360+
remote = _setup_git_remote(tmpdir)
361+
agent = tmpdir / "agent"
362+
agent.mkdir()
363+
subprocess.run(["git", "-C", str(agent), "init", "-q"], check=True)
364+
subprocess.run(["git", "-C", str(agent), "remote", "add", "origin",
365+
"/old/nonexistent.git"], check=True)
366+
(agent / "preexisting").write_text("keep me")
367+
cons, text = _capture_console()
368+
assert lc.clone_or_init(agent, str(remote), "main", cons) is True
369+
assert (agent / "f").read_text() == "one"
370+
r = subprocess.run(
371+
["git", "-C", str(agent), "remote", "get-url", "origin"],
372+
capture_output=True, text=True, check=False)
373+
assert r.returncode == 0 and r.stdout.strip() == str(remote)
374+
assert (agent / "preexisting").exists()
375+
376+
377+
def test_reset_warns_on_incomplete_wipe(tmpdir):
378+
"""rmtree that leaves survivors (files in use) must warn, not claim a clean reset."""
379+
remote = _setup_git_remote(tmpdir)
380+
agent = tmpdir / "agent"
381+
lc.cmd_update(agent, str(remote), "main", _quiet_console())
382+
(agent / "busy").write_text("in use")
383+
real_rmtree = lc.shutil.rmtree
384+
lc.shutil.rmtree = lambda *a, **k: None # simulate failure
385+
try:
386+
cons, text = _capture_console()
387+
assert lc.cmd_reset(agent, str(remote), "main", cons, force=True) == 0
388+
finally:
389+
lc.shutil.rmtree = real_rmtree
390+
assert "Wipe incomplete" in text() and "busy" in text()
391+
assert (agent / "busy").exists() # untracked survivor preserved
392+
assert (agent / "f").read_text() == "one" # tracked tree still reset
393+
394+
395+
def test_render_missing_dir_errors(tmpdir):
396+
cons, text = _capture_console()
397+
assert lc.cmd_render(tmpdir / "nope", cons) == 1
398+
assert "No config area" in text()
399+
400+
149401
def main() -> int:
150402
return run_lpbx_suite("lpb-config tests", globals())
151403

0 commit comments

Comments
 (0)