From cbb2c0d2e348d6a0d850f9477171c3d3e76462a7 Mon Sep 17 00:00:00 2001 From: Matt <174058705+asdf8675309@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:24:52 -0400 Subject: [PATCH 1/3] Add CoreUpgrade skill: scoped major-version upgrade with optional CLAUDE.md customization split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The installer is additive (DeployCore never overwrites), so no path exists to replace the core across a major version. CoreUpgrade is that primitive: payload-aware clear of only the SYSTEM entries the new payload ships, per-entry-verified scoped backup before any delete, guarded rollback with named failures, dry-run by default. Optional --split-claude-md diffs the user's CLAUDE.md against CLAUDE.template.md, auto-extracts pure additions into an imported USER/CUSTOMIZATIONS/GLOBAL.md, flags modified base lines for manual placement, and replaces CLAUDE.md with the fresh base β€” so future base updates pull cleanly. Template gains the commented GLOBAL import, activated by the existing ActivateImports.ts flow. 20 tests / 111 assertions, standalone via bun test. Co-authored-by: Claude --- LifeOS/install/CLAUDE.template.md | 1 + LifeOS/install/skills/CoreUpgrade/SKILL.md | 80 +++ .../CoreUpgrade/Tools/LifeosUpgrade.test.ts | 283 ++++++++++ .../skills/CoreUpgrade/Tools/LifeosUpgrade.ts | 517 ++++++++++++++++++ 4 files changed, 881 insertions(+) create mode 100644 LifeOS/install/skills/CoreUpgrade/SKILL.md create mode 100644 LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts create mode 100755 LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts diff --git a/LifeOS/install/CLAUDE.template.md b/LifeOS/install/CLAUDE.template.md index 4d42f0b6a9..ed1ea09f6a 100644 --- a/LifeOS/install/CLAUDE.template.md +++ b/LifeOS/install/CLAUDE.template.md @@ -11,6 +11,7 @@ # @LIFEOS/USER/DIGITAL_ASSISTANT/DA_IDENTITY.md # @LIFEOS/USER/PROJECTS.md # @LIFEOS/USER/CONFIG/OPERATIONAL_RULES.md +# @LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md ## Constitutional layer diff --git a/LifeOS/install/skills/CoreUpgrade/SKILL.md b/LifeOS/install/skills/CoreUpgrade/SKILL.md new file mode 100644 index 0000000000..900da35dbe --- /dev/null +++ b/LifeOS/install/skills/CoreUpgrade/SKILL.md @@ -0,0 +1,80 @@ +--- +name: CoreUpgrade +version: 1.0.0 +description: "Scoped major-version upgrade for an existing LifeOS install β€” replaces only the SYSTEM entries the new payload ships (LIFEOS/, payload-shipped skills/commands/agents) while preserving USER, MEMORY, private skills/_*, custom commands/agents/skills, .env, settings, and harness state. Optional --split-claude-md extracts your CLAUDE.md customizations into an imported USER/CUSTOMIZATIONS/GLOBAL.md so the base CLAUDE.md can be pulled fresh every upgrade. Scoped backup + rollback, dry-run by default, --apply gated. USE WHEN upgrade lifeos, major version upgrade, move lifeos install to a new version, scoped core replacement, replace lifeos core, split claude.md customizations. NOT FOR fresh install or onboarding (use the LifeOS skill), system-improvement recommendations (use Upgrade), or external-content intake (use Migrate)." +disable-model-invocation: true +--- + +# CoreUpgrade β€” scoped major-version upgrade + +## 🚨 MANDATORY: Voice Notification + +```bash +curl -s -X POST http://localhost:31337/notify \ + -H "Content-Type: application/json" \ + -d '{"message": "Planning the core upgrade. Dry-run first β€” nothing changes without apply."}' \ + > /dev/null 2>&1 & +``` + +## What It Does + +CoreUpgrade moves an existing LifeOS install to a new major version. It clears only the SYSTEM entries the new payload actually ships β€” `LIFEOS/` subdirs, payload-shipped public skills, commands, and agents β€” then deploys the payload into the cleared slots. Everything user-owned survives untouched: `LIFEOS/USER/`, `LIFEOS/MEMORY/`, `.env*`, `settings*.json`, private `skills/_*`, and any command/agent/skill the payload does not ship. + +## The Problem + +The LifeOS installer is deliberately additive β€” `DeployCore` copies only missing files and never overwrites a populated target. That is the right behavior for a fresh install, but it means there is no path to *replace* the core across a major version: deploying a new payload over an old install leaves every old SYSTEM file in place, producing a broken hybrid. Hand-deleting the right directories is worse β€” one wrong `rm -rf` and user data is gone. CoreUpgrade is the missing primitive: a scoped, reversible clear-and-replace with the preserve boundary encoded as tested code, not operator care. + +## How It Works + +1. **Plan** β€” the clear-list is enumerated from the LIVE install and each entry is cleared only if it passes the preserve check AND the payload ships a replacement. A wrong or malicious payload can never inject a path or clear a preserved zone; an empty payload clears nothing. +2. **Backup** β€” exactly the to-be-cleared entries are copied to a backup dir (outside the install, enforced), verified per-entry, BEFORE any delete. +3. **Clear + deploy** β€” every delete goes through a guarded remove (preserve-check + path-escape, case-insensitive). The payload then fills the cleared slots. +4. **Rollback** β€” on ANY error, deploy-created artifacts are removed and cleared entries are restored from the backup, with partial failures named explicitly rather than reported as success. + +Dry-run is the default; nothing mutates without the literal `--apply`. + +## Usage + +```bash +# Dry-run (DEFAULT β€” prints the exact plan, changes nothing): +bun skills/CoreUpgrade/Tools/LifeosUpgrade.ts --payload /install + +# Execute: +bun skills/CoreUpgrade/Tools/LifeosUpgrade.ts --payload /install --apply + +# Execute + split CLAUDE.md customizations into an imported overlay: +bun skills/CoreUpgrade/Tools/LifeosUpgrade.ts --payload /install --apply --split-claude-md +``` + +Flags: `--payload ` (required), `--apply`, `--config-root ` (default `~/.claude`), `--backup-dir `, `--split-claude-md`, `--help`. + +## The CLAUDE.md Split (`--split-claude-md`) + +By default `CLAUDE.md` is preserved, which means it drifts: after an upgrade it still describes the OLD version until reconciled by hand. The split ends that cycle so future base updates can be pulled fresh: + +- Your `CLAUDE.md` is diffed against the payload's `CLAUDE.template.md` β€” deterministic markdown block-diff, no model calls. +- **Pure additions** (blocks absent upstream) are auto-extracted to `LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md`, imported by the base via `@LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md`. +- **Modified base lines** are FLAGGED in the plan for manual placement β€” the tool never guesses which version you meant. +- The old `CLAUDE.md` is backed up and verified before replacement; an existing `GLOBAL.md` is never overwritten (re-checked at write time). +- The new base ships with all `@`-imports commented. **Run the payload's `ActivateImports.ts` immediately after apply** β€” identity/TELOS/GLOBAL context is dormant until then. + +Review the dry-run's split report (additions / flagged modifications / matched / noise) before applying: a diff cannot distinguish *your customization* from *stale old-version content the new base supersedes* β€” that judgment is yours, and the full original is always in the backup. + +## Safety Guarantees + +- **Dry-run by default; `--apply` is the only mutating path.** No backup verified on disk β†’ no delete happens. +- **Payload-aware clearing** β€” only entries the payload ships are ever cleared, so upstream deletions are honored and user content the payload doesn't know about survives. +- **Preserve denylist + path-escape guard on every delete**, case-insensitive (APFS), failing toward preservation. +- **Refuses dev trees** (won't mutate a LifeOS source checkout) and ambiguous targets (a bare dir with a stray `CLAUDE.md` is not an install). +- **Named-failure rollback** β€” anything that could not be auto-restored is listed with the backup path, never silently dropped. + +## Post-Upgrade Steps (manual, printed by the tool) + +1. `--split-claude-md` used β†’ run the payload's `ActivateImports.ts` (imports ship commented). +2. Run the payload's `InstallHooks.ts` to wire new-version hooks. +3. Reconcile `settings*.json` against the payload templates. +4. Re-apply any custom edits you had made inside replaced SYSTEM dirs (from the backup; better: move them to `skills/_*` first, where they're preserved automatically). + +## Verification + +`Tools/LifeosUpgrade.test.ts` β€” 20 tests / 111 assertions, runnable standalone with `bun test`. Covers: preserve-zone protection (incl. case variants), payload-aware clearing, empty/wrong-payload no-op, dev-tree refusal, symlink-cycle safety, backup scoping, rollback (restore + new-artifact removal + named failures), split extraction (additions vs flagged modifications vs noise), split no-clobber (incl. write-time re-check), and full split+rollback composition under a simulated deploy failure. diff --git a/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts new file mode 100644 index 0000000000..52bceb2c95 --- /dev/null +++ b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts @@ -0,0 +1,283 @@ +import { test, expect } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, symlinkSync, rmSync } from "node:fs"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { + isPreserved, computeClearList, planUpgrade, copyMissing, preflight, payloadHas, + parseArgs, safeRemove, scopedBackup, rollback, snapshotDeployTargets, extractCustomizations, + planClaudeSplit, applyClaudeSplit, undoClaudeSplit, +} from "./LifeosUpgrade"; + +const tmp = (p: string) => mkdtempSync(join(tmpdir(), p)); +function tree(root: string, dirs: string[], files: Record = {}) { + for (const d of dirs) mkdirSync(join(root, d), { recursive: true }); + for (const [f, c] of Object.entries(files)) { mkdirSync(join(root, f, ".."), { recursive: true }); writeFileSync(join(root, f), c); } +} + +test("isPreserved protects user + harness zones (case-insensitive)", () => { + for (const p of ["LIFEOS/USER", "LIFEOS/USER/CONFIG/x.md", "LIFEOS/MEMORY", "LIFEOS/ARBOL", + "LIFEOS/Memory", "LIFEOS/User", "lifeos/memory", // #5 APFS case variants + ".env", ".env.local", "CLAUDE.md", "settings.json", "settings.user.json", + "skills/_RECON", "skills/_UPGRADE", // #11 private-skill denylist depth + "sessions", "todos/x", "tasks", "plugins/y", "cache", "history.jsonl", "ide"]) { + expect(isPreserved(p)).toBe(true); + } +}); + +test("isPreserved does NOT over-preserve system code β€” and basename over-match is fixed (#10)", () => { + for (const p of ["LIFEOS/TOOLS", "LIFEOS/ALGORITHM/v.md", "LIFEOS/PULSE", "commands", "agents", + "skills/Crucible", + "skills/jobs", "skills/tasks", "skills/cache", "LIFEOS/jobs"]) { // #10 basename must NOT match + expect(isPreserved(p)).toBe(false); + } +}); + +test("computeClearList: payload-aware β€” replace only what payload ships (#1, #2)", () => { + const root = tmp("lu-c-"), pay = tmp("lu-cp-"); + tree(root, ["LIFEOS/TOOLS", "LIFEOS/USER", "LIFEOS/MEMORY", "skills/Research", "skills/Crucible", "skills/_RECON", "commands", "agents"], + { "commands/pu.md": "sys", "commands/mine.md": "user", "agents/Forge.md": "sys", "CLAUDE.md": "x", "settings.json": "x" }); + // payload ships: LIFEOS/TOOLS, skills/Research, commands/pu.md, agents/Forge.md (NOT Crucible, NOT mine.md) + tree(pay, ["LIFEOS/TOOLS", "skills/Research", "commands", "agents"], + { "commands/pu.md": "new", "agents/Forge.md": "new" }); + const clear = computeClearList(root, pay); + expect(clear).toContain("LIFEOS/TOOLS"); + expect(clear).toContain("skills/Research"); + expect(clear).toContain("commands/pu.md"); // shipped β†’ replaced + expect(clear).toContain("agents/Forge.md"); + // user content NOT shipped by payload β†’ preserved + expect(clear).not.toContain("commands/mine.md"); // #1 user command survives + expect(clear).not.toContain("skills/Crucible"); // #1 user public skill survives + expect(clear).not.toContain("skills/_RECON"); + expect(clear).not.toContain("LIFEOS/USER"); + expect(clear).not.toContain("LIFEOS/MEMORY"); + expect(clear).not.toContain("CLAUDE.md"); +}); + +test("computeClearList: empty/wrong payload clears NOTHING (#2)", () => { + const root = tmp("lu-e-"), pay = tmp("lu-ep-"); // payload has no LIFEOS/skills/commands/agents + tree(root, ["LIFEOS/TOOLS", "skills/Research", "commands"], { "CLAUDE.md": "x" }); + tree(pay, ["someWrongThing"], {}); + expect(computeClearList(root, pay)).toEqual([]); + expect(planUpgrade(root, pay).deployRoots).toEqual([]); // main aborts on this before any mutation +}); + +test("payloadHas: maps rel paths 1:1 onto payload layout β€” LIFEOS/ canonical (#G1)", () => { + const pay = tmp("lu-ph-"); + tree(pay, ["LIFEOS/TOOLS", "skills/Research", "commands"], { "commands/pu.md": "x" }); + expect(payloadHas(pay, "LIFEOS/TOOLS")).toBe(true); + expect(payloadHas(pay, "skills/Research")).toBe(true); + expect(payloadHas(pay, "commands/pu.md")).toBe(true); + expect(payloadHas(pay, "LIFEOS/NOPE")).toBe(false); + expect(payloadHas(pay, "skills/Missing")).toBe(false); +}); + +test("preflight: bare CLAUDE.md dir is rejected (#3); dev-tree refused", () => { + const notInstall = tmp("lu-n-"); writeFileSync(join(notInstall, "CLAUDE.md"), "x"); // no LIFEOS/ + const pay = tmp("lu-pp-"); mkdirSync(join(pay, "LIFEOS"), { recursive: true }); + expect(preflight(notInstall, pay).some((e) => e.includes("no LIFEOS/"))).toBe(true); + const dev = tmp("lu-d-"); tree(dev, ["LIFEOS", "skills/_LIFEOS"], { "settings.json": "{}" }); + expect(preflight(dev, pay).some((e) => e.includes("DEV-TREE"))).toBe(true); +}); + +test("parseArgs: rejects empty/missing values and swallowed flags (#4, #9)", () => { + expect(() => parseArgs(["--payload"])).toThrow(); // missing value + expect(() => parseArgs(["--config-root", ""])).toThrow(); // explicit empty β†’ no silent prod default (#4) + expect(() => parseArgs(["--backup-dir", "--apply"])).toThrow(); // #9 value-flag must not swallow --apply + expect(() => parseArgs(["--bogus"])).toThrow(); // unknown arg + expect(parseArgs(["--payload", "/x", "--apply"])).toEqual({ apply: true, payload: "/x" }); +}); + +test("copyMissing: does not follow symlinks β€” no cycle stack-overflow (#7)", () => { + const base = tmp("lu-sl-"); const src = join(base, "src"), dst = join(base, "dst"); + mkdirSync(src, { recursive: true }); writeFileSync(join(src, "real.txt"), "R"); + symlinkSync(src, join(src, "loop")); // self-referential dir symlink (would infinite-recurse if followed) + const { copied } = copyMissing(src, dst); // must terminate + expect(existsSync(join(dst, "real.txt"))).toBe(true); + expect(copied).toBeGreaterThan(0); +}); + +test("safeRemove refuses a preserved path (guard 1) and path escape (guard 2)", () => { + const root = tmp("lu-sr-"); + expect(() => safeRemove(root, "LIFEOS/USER")).toThrow(/REFUSING/); + expect(() => safeRemove(root, "skills/_RECON")).toThrow(/REFUSING/); + expect(() => safeRemove(root, "../evil")).toThrow(/escapes/); +}); + +test("scopedBackup backs up ONLY the clear-list, per-entry verified (#8, #13)", () => { + const root = tmp("lu-b-"); const bak = join(root, "..", "bak-" + Date.now()); + tree(root, ["LIFEOS/TOOLS", "LIFEOS/USER", "plugins"], { "LIFEOS/TOOLS/a.ts": "A", "LIFEOS/USER/big.md": "U", "plugins/x": "P" }); + scopedBackup(root, bak, ["LIFEOS/TOOLS"]); + expect(readFileSync(join(bak, "LIFEOS/TOOLS/a.ts"), "utf8")).toBe("A"); // cleared entry backed up + expect(existsSync(join(bak, "LIFEOS/USER"))).toBe(false); // preserved zone NOT copied (scoped, not 3.1GB) + expect(existsSync(join(bak, "plugins"))).toBe(false); + rmSync(bak, { recursive: true, force: true }); +}); + +test("rollback: restores cleared entries AND removes NEW deploy artifacts (#6, Crucible HIGH)", () => { + const root = tmp("lu-rb-"); const bak = join(root, "..", "bak-rb-" + Date.now()); + tree(root, ["LIFEOS/TOOLS", "skills/Research"], { "LIFEOS/TOOLS/orig.ts": "ORIG", "skills/Research/s.md": "R" }); + const clear = ["LIFEOS/TOOLS", "skills/Research"]; + const snap = snapshotDeployTargets(root, [{ src: "LIFEOS", dest: "LIFEOS" }, { src: "skills", dest: "skills" }]); + scopedBackup(root, bak, clear); + // simulate clear + partial deploy + safeRemove(root, "LIFEOS/TOOLS"); mkdirSync(join(root, "LIFEOS/TOOLS"), { recursive: true }); writeFileSync(join(root, "LIFEOS/TOOLS/new.ts"), "NEW"); + safeRemove(root, "skills/Research"); + mkdirSync(join(root, "skills/NewSkill"), { recursive: true }); writeFileSync(join(root, "skills/NewSkill/n.md"), "NEWSKILL"); // brand-new deploy artifact + const failed = rollback(root, bak, clear, snap); + expect(failed).toEqual([]); + expect(readFileSync(join(root, "LIFEOS/TOOLS/orig.ts"), "utf8")).toBe("ORIG"); // cleared entry restored + expect(existsSync(join(root, "LIFEOS/TOOLS/new.ts"))).toBe(false); // intra-entry remnant gone (restored from backup) + expect(readFileSync(join(root, "skills/Research/s.md"), "utf8")).toBe("R"); // cleared entry restored + expect(existsSync(join(root, "skills/NewSkill"))).toBe(false); // NEW top-level artifact removed (the HIGH) + rmSync(bak, { recursive: true, force: true }); +}); + +test("rollback: reports entries it could NOT restore instead of false success (#security-MEDIUM)", () => { + const root = tmp("lu-rf-"); const bak = join(root, "..", "bak-rf-" + Date.now()); + tree(root, ["LIFEOS/TOOLS"], { "LIFEOS/TOOLS/x.ts": "X" }); + mkdirSync(bak, { recursive: true }); // empty backup β€” nothing captured + const failed = rollback(root, bak, ["LIFEOS/TOOLS"], {}); + expect(failed).toContain("LIFEOS/TOOLS"); // surfaced, not silently "complete" + rmSync(bak, { recursive: true, force: true }); +}); + +test("preflight rejects a --backup-dir nested inside config-root (#security-MEDIUM)", () => { + const root = tmp("lu-bd-"); tree(root, ["LIFEOS"], { "settings.json": "{}" }); + const pay = tmp("lu-bdp-"); mkdirSync(join(pay, "LIFEOS"), { recursive: true }); + expect(preflight(root, pay, join(root, "skills/nested-bak")).some((e) => e.includes("OUTSIDE config-root"))).toBe(true); + expect(preflight(root, pay, join(root, "..", "outside-bak")).some((e) => e.includes("OUTSIDE config-root"))).toBe(false); +}); + +test("extractCustomizations: additions auto-extracted, modifications flagged, noise excluded (#G-split)", () => { + const base = [ + "# LifeOS 7.1.1 β€” LifeOS", + "", + "# @LIFEOS/USER/PROJECTS.md", + "", + "## LifeOS System", + "", + "- **Memory** β€” `Memory/MemorySystem.md`", + '- **Browser automation** β€” `Skill("Interceptor")` (real Chrome, mandatory)', + ].join("\n"); + const user = [ + "# LifeOS 6.0.5 β€” LifeOS", // version header β†’ noise + "", + "@LIFEOS/USER/PROJECTS.md", // activated import β†’ noise (comment-state ignored) + "", + "## LifeOS System", // heading identical β†’ matched + "", + "- **Memory** β€” `Memory/MemorySystem.md`", // identical β†’ matched + '- **Browser automation** β€” `Skill("Browser")` + Interceptor + Chrome DevTools MCP',// same key, diff body β†’ MODIFICATION + "- **CodeGraph** β€” structural code intel via `mcp__codegraph__*`", // absent upstream β†’ ADDITION + ].join("\n"); + + const { global, report } = extractCustomizations(user, base); + expect(report.additions.some((a) => a.includes("CodeGraph"))).toBe(true); // addition captured + expect(global).toContain("CodeGraph"); + expect(report.modifications.some((m) => m.includes("Chrome DevTools MCP"))).toBe(true); // modification flagged… + expect(global).not.toContain("Chrome DevTools MCP"); // …NOT auto-extracted + expect(global).not.toContain("6.0.5"); // version header excluded + expect(global).not.toContain("@LIFEOS/USER/PROJECTS.md"); // import excluded + expect(report.noise.some((n) => n.includes("6.0.5"))).toBe(true); + expect(global).not.toContain("MemorySystem"); // identical system block dropped + expect(report.matched).toBeGreaterThanOrEqual(2); // "## LifeOS System" + Memory bullet +}); + +test("extractCustomizations: no customizations β†’ empty global (benign)", () => { + const base = "## LifeOS System\n\n- **Memory** β€” `x`"; + const { global, report } = extractCustomizations(base, base); + expect(global).toBe(""); + expect(report.additions).toEqual([]); + expect(report.modifications).toEqual([]); +}); + +test("planClaudeSplit: preconditions + classifies against payload template (#split)", () => { + const root = tmp("lu-sp-"), pay = tmp("lu-spp-"); + tree(root, ["LIFEOS/USER/CUSTOMIZATIONS"], { "CLAUDE.md": "## LifeOS System\n\n- **Memory** β€” `x`\n- **CodeGraph** β€” mine" }); + writeFileSync(join(pay, "CLAUDE.template.md"), "## LifeOS System\n\n- **Memory** β€” `x`"); + const p = planClaudeSplit(root, pay); + expect(p.willSplit).toBe(true); + expect(p.report.additions.some((a) => a.includes("CodeGraph"))).toBe(true); + expect(p.global).toContain("CodeGraph"); + expect(p.globalExists).toBe(false); + expect(p.injectImport).toBe(true); // template has no GLOBAL import line + expect(planClaudeSplit(root, tmp("lu-spx-")).willSplit).toBe(false); // payload ships no CLAUDE.template.md +}); + +test("applyClaudeSplit + undo: backs up CLAUDE.md, writes GLOBAL.md + base, undo restores (#split)", () => { + const root = tmp("lu-sa-"), pay = tmp("lu-sap-"); const bak = join(root, "..", "bak-sa-" + Date.now()); + tree(root, ["LIFEOS/USER/CUSTOMIZATIONS"], { "CLAUDE.md": "## LifeOS System\n\n- **CodeGraph** β€” mine" }); + writeFileSync(join(pay, "CLAUDE.template.md"), "## LifeOS System\n\n- **Memory** β€” `x`"); + mkdirSync(bak, { recursive: true }); + const undo = applyClaudeSplit(root, pay, bak, planClaudeSplit(root, pay)); + const newClaude = readFileSync(join(root, "CLAUDE.md"), "utf8"); + expect(newClaude).toContain("Memory"); // base deployed + expect(newClaude).not.toContain("CodeGraph"); // customization out of CLAUDE.md + expect(newClaude).toContain("@LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"); // import injected (commented) + expect(readFileSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"), "utf8")).toContain("CodeGraph"); + expect(readFileSync(undo.claudeBackup, "utf8")).toContain("CodeGraph"); // old CLAUDE.md backed up + const failed = undoClaudeSplit(root, undo); + expect(failed).toEqual([]); + expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).toContain("CodeGraph"); // restored + expect(existsSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"))).toBe(false); // created GLOBAL removed + rmSync(bak, { recursive: true, force: true }); +}); + +test("applyClaudeSplit: never overwrites an existing GLOBAL.md (#split-safety)", () => { + const root = tmp("lu-sg-"), pay = tmp("lu-sgp-"); const bak = join(root, "..", "bak-sg-" + Date.now()); + tree(root, ["LIFEOS/USER/CUSTOMIZATIONS"], { + "CLAUDE.md": "## LifeOS System\n\n- **CodeGraph** β€” mine", + "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md": "MY CURATED CUSTOMIZATIONS", + }); + writeFileSync(join(pay, "CLAUDE.template.md"), "## LifeOS System\n\n- **Memory** β€” `x`"); + mkdirSync(bak, { recursive: true }); + const plan = planClaudeSplit(root, pay); + expect(plan.globalExists).toBe(true); + const undo = applyClaudeSplit(root, pay, bak, plan); + expect(readFileSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"), "utf8")).toBe("MY CURATED CUSTOMIZATIONS"); // untouched + expect(undo.globalCreated).toBeUndefined(); // did not create/overwrite it + rmSync(bak, { recursive: true, force: true }); +}); + +test("applyClaudeSplit: re-checks GLOBAL.md at WRITE time β€” no planβ†’apply TOCTOU clobber (#split-safety-L3)", () => { + const root = tmp("lu-st-"), pay = tmp("lu-stp-"); const bak = join(root, "..", "bak-st-" + Date.now()); + tree(root, ["LIFEOS/USER/CUSTOMIZATIONS"], { "CLAUDE.md": "## LifeOS System\n\n- **CodeGraph** β€” mine" }); + writeFileSync(join(pay, "CLAUDE.template.md"), "## LifeOS System\n\n- **Memory** β€” `x`"); + const plan = planClaudeSplit(root, pay); + expect(plan.globalExists).toBe(false); // plan captured BEFORE the file appears + writeFileSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"), "APPEARED AFTER PLAN"); // created between plan and apply + const undo = applyClaudeSplit(root, pay, bak, plan); + expect(readFileSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"), "utf8")).toBe("APPEARED AFTER PLAN"); // NOT clobbered + expect(undo.globalCreated).toBeUndefined(); + rmSync(bak, { recursive: true, force: true }); +}); + +test("split + rollback compose: deploy failure restores CLAUDE.md, removes GLOBAL, restores SYSTEM (#split-rollback)", () => { + const root = tmp("lu-srb-"), pay = tmp("lu-srbp-"); const bak = join(root, "..", "bak-srb-" + Date.now()); + tree(root, ["LIFEOS/TOOLS", "LIFEOS/USER/CUSTOMIZATIONS"], { + "CLAUDE.md": "## LifeOS System\n\n- **CodeGraph** β€” mine", + "LIFEOS/TOOLS/orig.ts": "ORIG", + }); + writeFileSync(join(pay, "CLAUDE.template.md"), "## LifeOS System\n\n- **Memory** β€” `x`"); + // NB: do NOT pre-create bak β€” scopedBackup refuses an existing backup dir (safety) and creates it itself. + + // mirror main()'s order: scoped backup β†’ split β†’ snapshot β†’ (clear + partial deploy) β†’ FAIL β†’ undo split β†’ rollback + const clear = ["LIFEOS/TOOLS"]; + scopedBackup(root, bak, clear); + const splitUndo = applyClaudeSplit(root, pay, bak, planClaudeSplit(root, pay)); + const snap = snapshotDeployTargets(root, [{ src: "LIFEOS", dest: "LIFEOS" }]); + expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).not.toContain("CodeGraph"); // split took effect + expect(existsSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"))).toBe(true); + + safeRemove(root, "LIFEOS/TOOLS"); mkdirSync(join(root, "LIFEOS/TOOLS"), { recursive: true }); writeFileSync(join(root, "LIFEOS/TOOLS/new.ts"), "NEW"); + const sf = undoClaudeSplit(root, splitUndo); // main()'s catch order: split undo first… + const failed = rollback(root, bak, clear, snap); // …then SYSTEM rollback + + expect(sf).toEqual([]); + expect(failed).toEqual([]); + expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).toContain("CodeGraph"); // CLAUDE.md fully restored + expect(existsSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"))).toBe(false); // split-created GLOBAL removed + expect(readFileSync(join(root, "LIFEOS/TOOLS/orig.ts"), "utf8")).toBe("ORIG"); // SYSTEM restored + expect(existsSync(join(root, "LIFEOS/TOOLS/new.ts"))).toBe(false); // deploy remnant gone + rmSync(bak, { recursive: true, force: true }); +}); diff --git a/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts new file mode 100755 index 0000000000..b435866407 --- /dev/null +++ b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts @@ -0,0 +1,517 @@ +#!/usr/bin/env bun +/** + * LifeosUpgrade β€” scoped major-version upgrade for a LifeOS install. + * + * The LifeOS installer is additive (DeployCore = copyMissing, never overwrites + * a populated target), so it cannot replace the core across a major version. + * This REPLACES only the LifeOS SYSTEM entries THE NEW PAYLOAD SHIPS, then + * copies the payload in β€” preserving everything user-owned and all harness state. + * + * SAFE BY CONSTRUCTION: + * - dry-run is the default; --apply is required to mutate. + * - "replace only what the payload ships": clear-list paths are enumerated ONLY + * from the live config-root (never from payload content) and each is cleared + * only if isPreserved() passes AND the payload ships a replacement β€” so a + * wrong/malicious payload can never inject a path or clear a preserved zone. + * - scoped backup of exactly the to-be-cleared entries, per-entry verified, + * BEFORE any delete. + * - on ANY error during clear/deploy: rollback removes deploy-created artifacts + * AND restores the cleared entries from backup (both through the guarded + * delete path), then reports the ACTUAL rollback outcome (partial failures named). + * - all deletes go through safeRemove (isPreserved + path-escape, case-insensitive). + * + * Known limitations (documented, low-risk): safeRemove's path-escape guard is + * lexical (does not resolve a symlinked config-root ancestor); "backup precedes + * delete" is enforced by main()'s call order, not an invariant inside safeRemove. + * + * Usage: + * bun LifeosUpgrade.ts --payload # dry-run + * bun LifeosUpgrade.ts --payload --apply # execute + * flags: --config-root (default ~/.claude), --backup-dir , + * --split-claude-md (extract CLAUDE.md customizations β†’ USER/CUSTOMIZATIONS/GLOBAL.md, + * then replace CLAUDE.md with the payload base), --help + */ +import { existsSync, readdirSync, lstatSync, readlinkSync, symlinkSync, rmSync, mkdirSync, copyFileSync, cpSync, readFileSync, writeFileSync } from "node:fs"; +import { join, sep, resolve, dirname } from "node:path"; +import { homedir } from "node:os"; + +const ensureParentDir = (p: string) => mkdirSync(dirname(p), { recursive: true }); +const errCode = (e: unknown): string | undefined => (e as { code?: string })?.code; + +// ── Zone policy (the load-bearing correctness surface) ────────────────────── + +/** Never touched. Prefix-matched (case-insensitive) against a config-root-relative path. */ +export const PRESERVE: string[] = [ + "LIFEOS/USER", "LIFEOS/MEMORY", "LIFEOS/ARBOL", "LIFEOS/Backups", + ".env", + "CLAUDE.md", // payload ships CLAUDE.template.md, not a drop-in; reconcile, never headless-clear + "settings.json", "settings.user.json", "settings.system.json", + // harness-owned runtime state (top-level dirs/files only) + "sessions", "todos", "tasks", "teams", "plugins", "Plugins", + "cache", "shell-snapshots", "session-env", "paste-cache", "file-history", + "history.jsonl", "ide", ".git", "downloads", "jobs", "daemon", +]; + +/** + * True when a config-root-relative path must never be cleared. Case-insensitive + * (macOS APFS; fail toward PRESERVING). Private skills (skills/_*) and .env* always preserved; + * compound entries match exact-or-under; bare entries match at TOP LEVEL only. + */ +export function isPreserved(rel: string): boolean { + const norm = rel.split(sep).join("/").replace(/^\.?\//, ""); + const lc = norm.toLowerCase(); + const first = lc.split("/")[0]; + const baseName = lc.split("/").pop() || ""; + if (lc.startsWith("skills/_")) return true; + if (baseName === ".env" || baseName.startsWith(".env.")) return true; + return PRESERVE.some((p) => { + const pl = p.toLowerCase(); + if (lc === pl || lc.startsWith(pl + "/")) return true; + if (pl.includes("/")) return false; + return first === pl; + }); +} + +const isPrivateSkill = (name: string) => name.startsWith("_"); + +/** Does the payload ship a replacement for this config-relative entry? Rel paths mirror the payload layout 1:1 (runtime dir is canonically `LIFEOS/`). */ +export function payloadHas(payloadRoot: string, rel: string): boolean { + return existsSync(join(payloadRoot, rel)); +} + +/** + * SYSTEM entries to clear = per-entry, enumerated from the LIVE config-root, and ONLY + * where the payload ships a replacement. Every returned path passes isPreserved. + */ +export function computeClearList(configRoot: string, payloadRoot: string): string[] { + const out: string[] = []; + const consider = (rel: string) => { + if (isPreserved(rel)) return; + if (!existsSync(join(configRoot, rel))) return; + if (!payloadHas(payloadRoot, rel)) return; + out.push(rel); + }; + const lifeos = join(configRoot, "LIFEOS"); + if (existsSync(lifeos)) for (const e of readdirSync(lifeos)) consider(`LIFEOS/${e}`); + for (const zone of ["commands", "agents"]) { + const z = join(configRoot, zone); + if (existsSync(z)) for (const e of readdirSync(z)) consider(`${zone}/${e}`); + } + const skills = join(configRoot, "skills"); + if (existsSync(skills)) for (const e of readdirSync(skills)) if (!isPrivateSkill(e)) consider(`skills/${e}`); + return out.filter((r) => !isPreserved(r)); // belt-and-suspenders vs a future consider() that forgets the check +} + +// ── copyMissing (mirrors v7 DeployCore: recursive, existsSync-guarded, never overwrites) ── +// lstat (no symlink follow β†’ no cycle). Only ENOENT means "nothing to copy"; real errors propagate β†’ rollback. +export function copyMissing(src: string, dst: string): { copied: number } { + let copied = 0; + let st; + try { st = lstatSync(src); } + catch (e) { if (errCode(e) === "ENOENT") return { copied }; throw e; } + if (st.isSymbolicLink()) { + if (!existsSync(dst)) { ensureParentDir(dst); symlinkSync(readlinkSync(src), dst); copied++; } + return { copied }; + } + if (st.isDirectory()) { + if (!existsSync(dst)) mkdirSync(dst, { recursive: true }); + for (const e of readdirSync(src)) copied += copyMissing(join(src, e), join(dst, e)).copied; + } else if (!existsSync(dst)) { + ensureParentDir(dst); + copyFileSync(src, dst); copied++; + } + return { copied }; +} + +// ── Plan ──────────────────────────────────────────────────────────────────── +export interface DeployRoot { src: string; dest: string; } // payload dir name β†’ canonical config-root dest +export interface Plan { + configRoot: string; payloadRoot: string; + clear: string[]; + deployRoots: DeployRoot[]; // one per payload root that ships (LIFEOS/skills/commands/agents) + warnings: string[]; +} + +export function planUpgrade(configRoot: string, payloadRoot: string): Plan { + const clear = computeClearList(configRoot, payloadRoot); + const deployRoots: DeployRoot[] = []; + for (const cand of ["LIFEOS", "skills", "commands", "agents"]) { + if (!existsSync(join(payloadRoot, cand))) continue; + deployRoots.push({ src: cand, dest: cand }); // runtime dir is canonically LIFEOS/ (payload verified: 0 mixed-case refs) + } + const warnings: string[] = []; + if (clear.some((r) => r.startsWith("LIFEOS/") || r.startsWith("skills/"))) + warnings.push("SYSTEM dirs (LIFEOS/*, public skills) are replaced wholesale β€” custom code placed INSIDE a shipped dir (e.g. a tool in LIFEOS/TOOLS) is backed up but NOT merged; the payload version wins. Move custom code into a preserved private skills/_* skill before upgrading, or re-apply it from the backup after."); + warnings.push("PRESERVED, never replaced: CLAUDE.md + settings*.json (they hold your @-imports and hook registrations). Reconcile them by hand against the payload's CLAUDE.template.md + settings templates after deploy."); + warnings.push("Config reconciliation is a MANUAL post-step (by design β€” it depends on YOUR customizations): (1) run the payload's InstallHooks.ts to wire new hooks; (2) fold new CLAUDE.md/settings structure in by hand; (3) re-apply any edits to replaced SYSTEM files from the backup dir."); + return { configRoot, payloadRoot, clear, deployRoots, warnings }; +} + +// ── Preflight ──────────────────────────────────────────────────────────────── +export function preflight(configRoot: string, payloadRoot: string, backupDir?: string): string[] { + const errs: string[] = []; + if (!existsSync(configRoot)) errs.push(`config-root does not exist: ${configRoot}`); + else if (!existsSync(join(configRoot, "LIFEOS"))) + errs.push(`config-root has no LIFEOS/ dir β€” not a LifeOS install (a stray CLAUDE.md is not enough): ${configRoot}`); + else if (!existsSync(join(configRoot, "settings.json")) && !existsSync(join(configRoot, "CLAUDE.md"))) + errs.push(`config-root has LIFEOS/ but no settings.json/CLAUDE.md β€” refusing an ambiguous target: ${configRoot}`); + if (existsSync(join(configRoot, "skills", "_LIFEOS"))) + errs.push("DEV-TREE REFUSAL: skills/_LIFEOS present β€” refusing to mutate a LifeOS source repo."); + if (!payloadRoot || !existsSync(payloadRoot)) errs.push(`--payload dir missing or not found: ${payloadRoot}`); + if (backupDir) { + const b = resolve(backupDir), c = resolve(configRoot); + if (b === c || b.startsWith(c + sep)) errs.push(`--backup-dir must be OUTSIDE config-root (would nest the backup inside the tree being cleared): ${backupDir}`); + } + return errs; +} + +// ── Apply + rollback ────────────────────────────────────────────────────────── +export function safeRemove(configRoot: string, rel: string) { + if (isPreserved(rel)) throw new Error(`REFUSING to delete preserved path: ${rel}`); // guard 1 + const abs = resolve(configRoot, rel); + if (!abs.startsWith(resolve(configRoot) + sep)) throw new Error(`path escapes config-root: ${rel}`); // guard 2 (lexical) + rmSync(abs, { recursive: true, force: true }); +} + +export function scopedBackup(configRoot: string, backupDir: string, clear: string[]) { + if (existsSync(backupDir)) throw new Error(`backup dir already exists: ${backupDir}`); + mkdirSync(backupDir, { recursive: true }); + for (const rel of clear) { + const dst = join(backupDir, rel); + ensureParentDir(dst); + cpSync(join(configRoot, rel), dst, { recursive: true, errorOnExist: false }); + if (!existsSync(dst)) throw new Error(`backup verification failed for ${rel} β€” aborting before any delete`); + } +} + +/** Snapshot the top-level child set of each deploy destination BEFORE deploy, so rollback can remove new artifacts. */ +export function snapshotDeployTargets(configRoot: string, deployRoots: DeployRoot[]): Record { + const snap: Record = {}; + for (const { dest } of deployRoots) { + const d = join(configRoot, dest); + snap[dest] = existsSync(d) ? readdirSync(d) : []; // [] also encodes "dest dir did not exist" + } + return snap; +} + +/** Rollback: remove deploy-created NEW top-level entries (guarded), then restore cleared entries from backup. Returns names of entries that could NOT be restored. */ +export function rollback(configRoot: string, backupDir: string, clear: string[], snapshot: Record): string[] { + const failed: string[] = []; + // 1) remove new artifacts deploy created (children not present pre-run, excluding preserved) + for (const [dest, before] of Object.entries(snapshot)) { + const d = join(configRoot, dest); + if (!existsSync(d)) continue; + const beforeSet = new Set(before); + for (const child of readdirSync(d)) { + const rel = `${dest}/${child}`; + if (beforeSet.has(child)) continue; // pre-existing (cleared ones are restored below) + if (isPreserved(rel)) continue; + try { safeRemove(configRoot, rel); } catch { failed.push(`(new)${rel}`); } + } + } + // 2) restore cleared entries from backup (through the guarded delete) + for (const rel of clear) { + const bak = join(backupDir, rel), live = join(configRoot, rel); + if (!existsSync(bak)) { failed.push(rel); continue; } + try { + if (existsSync(live)) safeRemove(configRoot, rel); // remove partial deploy remnants (guarded) + ensureParentDir(live); + cpSync(bak, live, { recursive: true, errorOnExist: false }); + if (!existsSync(live)) failed.push(rel); + } catch { failed.push(rel); } + } + return failed; +} + +// ── CLAUDE.md customization split (Approach A) ──────────────────────────────── +// Diffs the user's CLAUDE.md against the upstream base to auto-extract additive +// customizations into an imported GLOBAL.md, so the base can be pulled fresh on +// upgrade. SAFE: pure + deterministic (no LLM). Additions are auto-extracted; +// MODIFICATIONS (same block key, different body) are only FLAGGED, never guessed; +// the version header + @-import comment-state are excluded as noise. The full +// original CLAUDE.md is always in the scoped backup, so a mis-classification is +// cosmetic (surfaced in dry-run for review), never data loss. + +export interface ExtractReport { + additions: string[]; // present in user, absent upstream β†’ extracted to GLOBAL.md + modifications: string[]; // same key as an upstream block, different body β†’ FLAGGED for manual placement + noise: string[]; // version header + @-import lines β†’ excluded (base wins; imports re-activated separately) + matched: number; // identical to an upstream block β†’ dropped (the base ships it) +} +export interface ExtractResult { global: string; report: ExtractReport; } + +interface MdBlock { key: string; text: string; } + +// In a LifeOS CLAUDE.md a single-`#` line is the title or an explanatory comment β€” never a +// content section (real sections are `##`+). So H1 lines + @-import lines (any comment-state) are noise. +const H1_LINE = /^#\s+\S/; // "# LifeOS 6.0.5 …" | "# Identity @-imports …" +const IMPORT_LINE = /^\s*(?:#\s*|)?\s*$/; // @X | # @X | +const isNoiseLine = (l: string) => H1_LINE.test(l) || IMPORT_LINE.test(l); +const mdNorm = (s: string) => s.replace(/\s+/g, " ").trim().toLowerCase(); + +/** Stable identity of a block: heading text, a bullet's bold label / pre-dash lead, else normalized head. */ +function blockKey(firstLine: string): string { + if (/^#{1,6}\s/.test(firstLine)) return "h:" + mdNorm(firstLine.replace(/^#+\s*/, "")); + const body = firstLine.replace(/^\s*[-*]\s+/, ""); + const bold = body.match(/^\*\*(.+?)\*\*/); + if (bold) return "b:" + mdNorm(bold[1]); + return "b:" + mdNorm(body.split(/\s[β€”-]\s/)[0]).slice(0, 48); +} + +/** Split markdown into blocks: heading | top-level bullet (+ indented continuations) | paragraph. Noise lines dropped. */ +function segmentMd(md: string): MdBlock[] { + const lines = md.split("\n"); + const blocks: MdBlock[] = []; + const isHeading = (l: string) => /^#{1,6}\s/.test(l); + const isBullet = (l: string) => /^\s*[-*]\s/.test(l); + let i = 0; + while (i < lines.length) { + const line = lines[i]; + if (line.trim() === "" || isNoiseLine(line)) { i++; continue; } + if (isHeading(line)) { blocks.push({ key: blockKey(line), text: line.trimEnd() }); i++; continue; } + const buf = [line]; i++; + if (isBullet(line)) { + while (i < lines.length && /^\s+\S/.test(lines[i])) { buf.push(lines[i]); i++; } // absorb indented continuations + } else { + while (i < lines.length && lines[i].trim() !== "" && !isHeading(lines[i]) && !isBullet(lines[i]) && !isNoiseLine(lines[i])) { buf.push(lines[i]); i++; } + } + blocks.push({ key: blockKey(buf[0]), text: buf.join("\n").trimEnd() }); + } + return blocks; +} + +export function extractCustomizations(userClaudeMd: string, upstreamBase: string): ExtractResult { + const baseBlocks = segmentMd(upstreamBase); + const baseByContent = new Set(baseBlocks.map((b) => mdNorm(b.text))); + const baseKeys = new Set(baseBlocks.map((b) => b.key)); + const report: ExtractReport = { additions: [], modifications: [], noise: [], matched: 0 }; + const globalBlocks: string[] = []; + for (const b of segmentMd(userClaudeMd)) { + if (baseByContent.has(mdNorm(b.text))) { report.matched++; continue; } // identical to upstream β†’ drop + if (baseKeys.has(b.key)) { report.modifications.push(b.text); continue; } // same key, diff body β†’ FLAG (no extract) + report.additions.push(b.text); globalBlocks.push(b.text); // pure addition β†’ extract + } + report.noise = userClaudeMd.split("\n").filter((l) => l.trim() !== "" && isNoiseLine(l)).map((l) => l.trim()); + return { global: assembleGlobalMd(globalBlocks), report }; +} + +function assembleGlobalMd(blocks: string[]): string { + if (!blocks.length) return ""; + return [ + "# CLAUDE.md Customizations", + "", + "> Auto-extracted by CoreUpgrade from your previous CLAUDE.md during upgrade.", + "> Your additions to the LifeOS routing table β€” edit freely. Imported by CLAUDE.md.", + "", + ...blocks.flatMap((b) => [b, ""]), + ].join("\n").replace(/\n+$/, "\n"); +} + +// ── CLAUDE.md split integration (opt-in --split-claude-md) ──────────────────── +// SENSITIVE: the ONLY path that replaces CLAUDE.md (the #1 preserved file). Guards: +// opt-in flag; the old CLAUDE.md is backed up and verified before overwrite; an +// existing GLOBAL.md is NEVER overwritten; identity + GLOBAL imports are written +// COMMENTED for the post-step ActivateImports to activate; undo restores CLAUDE.md +// and removes only a GLOBAL.md this run created. Rollback-path change β†’ re-review. + +const GLOBAL_REL = "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"; +const GLOBAL_IMPORT = "@LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"; +const claudeTemplatePath = (payloadRoot: string) => join(payloadRoot, "CLAUDE.template.md"); + +export interface SplitPlan { + willSplit: boolean; + reason: string; + report: ExtractReport; + global: string; + globalExists: boolean; // GLOBAL.md already present β†’ we will NOT overwrite; additions reported for manual merge + injectImport: boolean; // base template lacks the GLOBAL import line β†’ we add it (commented) +} +export interface SplitUndo { claudeBackup: string; globalCreated?: string; } + +export function planClaudeSplit(configRoot: string, payloadRoot: string): SplitPlan { + const empty: ExtractReport = { additions: [], modifications: [], noise: [], matched: 0 }; + const userPath = join(configRoot, "CLAUDE.md"); + const basePath = claudeTemplatePath(payloadRoot); + if (!existsSync(userPath)) return { willSplit: false, reason: "no CLAUDE.md in config-root", report: empty, global: "", globalExists: false, injectImport: false }; + if (!existsSync(basePath)) return { willSplit: false, reason: "payload ships no CLAUDE.template.md", report: empty, global: "", globalExists: false, injectImport: false }; + const baseMd = readFileSync(basePath, "utf8"); + const { global, report } = extractCustomizations(readFileSync(userPath, "utf8"), baseMd); + return { + willSplit: true, reason: "", report, global, + globalExists: existsSync(join(configRoot, GLOBAL_REL)), + injectImport: !baseMd.includes(GLOBAL_IMPORT), + }; +} + +/** Insert a commented GLOBAL import after the last existing @-import line (or after line 1). */ +function injectGlobalImport(baseMd: string): string { + const lines = baseMd.split("\n"); + let lastImport = -1; + for (let i = 0; i < lines.length; i++) if (IMPORT_LINE.test(lines[i])) lastImport = i; + lines.splice(lastImport >= 0 ? lastImport + 1 : 1, 0, `# ${GLOBAL_IMPORT}`); + return lines.join("\n"); +} + +/** Mutating step: back up CLAUDE.md, write GLOBAL.md (only if absent), replace CLAUDE.md with the base. Atomic β€” cleans its own partial state on throw. */ +export function applyClaudeSplit(configRoot: string, payloadRoot: string, backupDir: string, plan: SplitPlan): SplitUndo { + const userPath = join(configRoot, "CLAUDE.md"); + const claudeBackup = join(backupDir, "CLAUDE.md.pre-split"); + ensureParentDir(claudeBackup); + copyFileSync(userPath, claudeBackup); + if (!existsSync(claudeBackup)) throw new Error("CLAUDE.md backup verification failed β€” aborting split before any write"); + const undo: SplitUndo = { claudeBackup }; + try { + const gp = join(configRoot, GLOBAL_REL); + if (plan.global && !existsSync(gp)) { // re-check at WRITE time (not stale plan.globalExists) β€” never clobber a GLOBAL.md + ensureParentDir(gp); + undo.globalCreated = gp; // record intent BEFORE the write so the catch can clean a partial write + writeFileSync(gp, plan.global); + } + let baseMd = readFileSync(claudeTemplatePath(payloadRoot), "utf8"); + if (plan.injectImport) baseMd = injectGlobalImport(baseMd); + writeFileSync(userPath, baseMd); // the one destructive op; backup precedes it + } catch (e) { + if (undo.globalCreated) { try { rmSync(undo.globalCreated, { force: true }); } catch { /* best-effort */ } } + try { copyFileSync(claudeBackup, userPath); } // restore CLAUDE.md from the verified backup + catch { throw new Error(`CLAUDE.md split failed AND auto-restore also failed β€” your intact original is at ${claudeBackup}; copy it back to ${userPath}. Cause: ${(e as Error).message}`); } + throw e; + } + return undo; +} + +/** Undo a split: restore CLAUDE.md from backup, remove only a GLOBAL.md this run created. Returns entries it could NOT restore. */ +export function undoClaudeSplit(configRoot: string, undo: SplitUndo): string[] { + const failed: string[] = []; + try { copyFileSync(undo.claudeBackup, join(configRoot, "CLAUDE.md")); } catch { failed.push("CLAUDE.md"); } + if (undo.globalCreated) { try { rmSync(undo.globalCreated, { force: true }); } catch { failed.push(GLOBAL_REL); } } + return failed; +} + +// ── CLI ─────────────────────────────────────────────────────────────────────── +function needVal(argv: string[], i: number, flag: string): string { + const v = argv[i]; + if (!v || v.startsWith("--")) throw new Error(`flag ${flag} requires a non-empty value`); + return v; +} +export const USAGE = `LifeosUpgrade β€” scoped major-version upgrade for a LifeOS install. + +Usage: + bun LifeosUpgrade.ts --payload dry-run (default): print the exact plan, change nothing + bun LifeosUpgrade.ts --payload --apply execute (scoped backup first, rollback on error) + +Flags: + --payload REQUIRED. The target version's install/ payload tree. + --apply Execute. Without it, dry-run only. + --config-root LifeOS install to upgrade (default: ~/.claude). + --backup-dir Backup location (default: -backup-; must be outside config-root). + --split-claude-md Extract CLAUDE.md customizations β†’ LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md, + then replace CLAUDE.md with the payload base (imports ship commented; + run ActivateImports.ts after). Additions auto-extracted; modified base + lines are FLAGGED for manual placement, never guessed. + --help Show this help. + +Preserved (never touched): LIFEOS/USER, LIFEOS/MEMORY, .env*, settings*.json, skills/_*, +custom commands/agents/skills the payload does not ship, harness state β€” and CLAUDE.md +unless --split-claude-md is passed (then it is backed up first).`; + +export function parseArgs(argv: string[]) { + const a: Record = { apply: false }; + for (let i = 0; i < argv.length; i++) { + const t = argv[i]; + if (t === "--help" || t === "-h") a.help = true; + else if (t === "--apply") a.apply = true; + else if (t === "--payload") a.payload = needVal(argv, ++i, "--payload"); + else if (t === "--config-root") a.configRoot = needVal(argv, ++i, "--config-root"); + else if (t === "--backup-dir") a.backupDir = needVal(argv, ++i, "--backup-dir"); + else if (t === "--split-claude-md") a.splitClaudeMd = true; + else throw new Error(`unknown argument: ${t}`); + } + return a; +} + +/** Provably-`never` exit (trailing throw is unreachable; narrows types past process.exit). */ +function die(msg: string): never { console.error(msg); process.exit(1); throw new Error(msg); } + +function main() { + let a; + try { a = parseArgs(process.argv.slice(2)); } + catch (e) { die(`ARG ERROR: ${(e as Error).message}\n\n${USAGE}`); } + if (a.help || process.argv.length <= 2) { console.log(USAGE); return; } + + const configRoot = resolve((a.configRoot as string) || join(homedir(), ".claude")); // default only when flag ABSENT + const payloadRoot = a.payload ? resolve(a.payload as string) : ""; + const apply = a.apply === true; + const splitClaudeMd = a.splitClaudeMd === true; + const backupDir = (a.backupDir as string) || `${configRoot}-backup-${Date.now()}`; + + const errs = preflight(configRoot, payloadRoot, backupDir); + if (errs.length) die("PREFLIGHT FAILED:\n" + errs.map((e) => " βœ— " + e).join("\n")); + + const plan = planUpgrade(configRoot, payloadRoot); + if (!plan.deployRoots.length) + die(`PREFLIGHT FAILED:\n βœ— payload has no deployable roots (LIFEOS/skills/commands/agents) at ${payloadRoot}\n β†’ point --payload at the version's install/ tree.`); + + console.log(`\n=== LifeosUpgrade ${apply ? "APPLY" : "DRY-RUN"} ===`); + console.log(`config-root: ${configRoot}\npayload: ${payloadRoot}\n`); + console.log(`CLEAR (${plan.clear.length} payload-backed SYSTEM entries β†’ removed, then re-filled):`); + plan.clear.forEach((r) => console.log(" βˆ’ " + r)); + console.log(`\nDEPLOY payload roots β†’ dest: ${plan.deployRoots.map((d) => `${d.src}β†’${d.dest}`).join(", ")}`); + console.log(`\nPRESERVED (never touched): USER, MEMORY, ARBOL, .env*, CLAUDE.md, settings*.json, private skills/_*, harness dirs, and any entry the payload does NOT ship.`); + console.log(`\nWARNINGS:`); plan.warnings.forEach((w) => console.log(" ⚠ " + w)); + + let splitPlan: SplitPlan | undefined; + if (splitClaudeMd) { + splitPlan = planClaudeSplit(configRoot, payloadRoot); + console.log(`\n── CLAUDE.md SPLIT (--split-claude-md) ──`); + if (!splitPlan.willSplit) console.log(` ⚠ split skipped: ${splitPlan.reason}`); + else { + const r = splitPlan.report; + console.log(` additions β†’ GLOBAL.md: ${r.additions.length} | modifications FLAGGED for manual placement: ${r.modifications.length} | matched-dropped: ${r.matched} | noise: ${r.noise.length}`); + if (splitPlan.globalExists) console.log(` ⚠ USER/CUSTOMIZATIONS/GLOBAL.md exists β€” will NOT overwrite; new additions are for manual merge.`); + r.modifications.forEach((m) => console.log(` ~ place by hand: ${m.split("\n")[0].slice(0, 100)}`)); + console.log(` β†’ after apply, run the payload's ActivateImports.ts to activate identity + GLOBAL imports.`); + } + } + + if (!apply) { console.log(`\nDRY-RUN β€” nothing changed. Re-run with --apply to execute.`); return; } + + console.log(`\n[1/3] Scoped backup of ${plan.clear.length} entries β†’ ${backupDir}`); + try { scopedBackup(configRoot, backupDir, plan.clear); } + catch (e) { die(`βœ— BACKUP FAILED (no deletes performed): ${(e as Error).message}`); } + console.log(` backup verified (per-entry).`); + + let splitUndo: SplitUndo | undefined; + if (splitClaudeMd && splitPlan?.willSplit) { + console.log(`[split] extracting CLAUDE.md customizations β†’ GLOBAL.md; replacing CLAUDE.md with the base…`); + try { splitUndo = applyClaudeSplit(configRoot, payloadRoot, backupDir, splitPlan); } + catch (e) { die(`βœ— CLAUDE.md SPLIT FAILED β€” SYSTEM zones untouched. ${(e as Error).message}\n If it was not auto-restored, your original is at ${backupDir}/CLAUDE.md.pre-split.`); } + console.log(` CLAUDE.md backed up + replaced${splitUndo?.globalCreated ? "; GLOBAL.md written" : " (GLOBAL.md left as-is)"}.`); + } + + let snapshot: Record = {}; + try { + snapshot = snapshotDeployTargets(configRoot, plan.deployRoots); // inside try so a post-split failure here still triggers undoClaudeSplit + console.log(`[2/3] Clearing ${plan.clear.length} SYSTEM entries…`); + for (const rel of plan.clear) { safeRemove(configRoot, rel); console.log(" βˆ’ " + rel); } + console.log(`[3/3] Deploying payload…`); + let copied = 0; + for (const { src, dest } of plan.deployRoots) copied += copyMissing(join(payloadRoot, src), join(configRoot, dest)).copied; + console.log(` copied ${copied} files.`); + } catch (e) { + console.error(`\nβœ— ERROR during clear/deploy: ${(e as Error).message}\n β†’ ROLLING BACK from ${backupDir}…`); + if (splitUndo) { const sf = undoClaudeSplit(configRoot, splitUndo); console.error(sf.length ? ` ⚠ split-undo could NOT restore: ${sf.join(", ")} (restore from ${backupDir})` : ` split undone β€” CLAUDE.md restored.`); } + const failed = rollback(configRoot, backupDir, plan.clear, snapshot); + if (failed.length) die(` ⚠ PARTIAL ROLLBACK β€” these entries could NOT be auto-restored; restore MANUALLY from the backup:\n` + + failed.map((f) => ` β€’ ${f}`).join("\n") + `\n Backup: ${backupDir}`); + console.error(` rollback complete β€” SYSTEM zones restored to pre-run state. Backup kept at ${backupDir}.`); + process.exit(1); + } + + console.log(`\nDONE. Backup: ${backupDir}`); + if (splitClaudeMd && splitPlan?.willSplit) + console.log(`⚠ NEXT (REQUIRED): the new CLAUDE.md ships with ALL @-imports COMMENTED β€” your identity/TELOS/GLOBAL context is DORMANT until you run the payload's ActivateImports.ts. Run it FIRST, then reconcile settings + run InstallHooks.ts.`); + else + console.log(`NEXT (manual, per warnings): run payload InstallHooks.ts; reconcile CLAUDE.md + settings from payload templates; re-apply any custom SYSTEM-file edits from the backup.`); +} + +if (import.meta.main) main(); From 95adde6227bf12dd4f39465c96ef1b1d7fa1576d Mon Sep 17 00:00:00 2001 From: Matt <174058705+asdf8675309@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:20:39 -0400 Subject: [PATCH 2/3] Crucible review fixes: symlink safety, CLAUDE_CONFIG_DIR, main() integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - copyMissing now SKIPS payload symlinks (matches upstream DeployCore, which never deploys them) β€” closes a symlink-target-injection surface a tampered payload could exploit, and makes the 'mirrors DeployCore' claim true. - Backup/rollback verification is lstat-based (lexists): a dangling symlink in a cleared zone no longer spuriously aborts a successful backup or mis-reports a clean rollback as PARTIAL (existsSync follows links). - Default config-root honors CLAUDE_CONFIG_DIR (PR #696 convention, matches ActivateImports.ts) before falling back to ~/.claude. - Rollback/split-undo failures now log their cause instead of a bare path. - Comments: deliberate inline dev-tree predicate (skill self-containment); lexical backup-dir guard limitation noted at the check site. - Tests 20β†’27 (111β†’138 assertions): two subprocess integration tests of main() (dry-run inert; apply proves backup-precedes-delete + preserve boundary), forced-throw split atomicity (before/after GLOBAL write), dangling-symlink backup/rollback round-trip, payload-symlink skip, preflight missing-payload/ambiguous-target, pre-existing-backup-dir refusal, tracked temp-dir cleanup. Co-authored-by: Claude --- LifeOS/install/skills/CoreUpgrade/SKILL.md | 2 +- .../CoreUpgrade/Tools/LifeosUpgrade.test.ts | 99 ++++++++++++++++++- .../skills/CoreUpgrade/Tools/LifeosUpgrade.ts | 55 +++++++---- 3 files changed, 130 insertions(+), 26 deletions(-) diff --git a/LifeOS/install/skills/CoreUpgrade/SKILL.md b/LifeOS/install/skills/CoreUpgrade/SKILL.md index 900da35dbe..9897a77fe5 100644 --- a/LifeOS/install/skills/CoreUpgrade/SKILL.md +++ b/LifeOS/install/skills/CoreUpgrade/SKILL.md @@ -77,4 +77,4 @@ Review the dry-run's split report (additions / flagged modifications / matched / ## Verification -`Tools/LifeosUpgrade.test.ts` β€” 20 tests / 111 assertions, runnable standalone with `bun test`. Covers: preserve-zone protection (incl. case variants), payload-aware clearing, empty/wrong-payload no-op, dev-tree refusal, symlink-cycle safety, backup scoping, rollback (restore + new-artifact removal + named failures), split extraction (additions vs flagged modifications vs noise), split no-clobber (incl. write-time re-check), and full split+rollback composition under a simulated deploy failure. +`Tools/LifeosUpgrade.test.ts` β€” 27 tests / 138 assertions, runnable standalone with `bun test` (the tool requires Bun; it relies on Bun's fs semantics). Covers: preserve-zone protection (incl. case variants), payload-aware clearing, empty/wrong-payload no-op, dev-tree + ambiguous-target + missing-payload refusal, symlink safety (payload symlinks never deployed β€” matching the installer β€” and backup/rollback verification is lstat-based so symlink-bearing zones round-trip correctly), backup scoping + pre-existing-backup-dir refusal, rollback (restore + new-artifact removal + named failures with causes), split extraction (additions vs flagged modifications vs noise), split no-clobber (incl. write-time re-check), split failure atomicity (forced throws before and after the GLOBAL.md write), full split+rollback composition under a simulated deploy failure, and two subprocess integration tests of `main()` itself (dry-run mutates nothing; apply proves backup-precedes-delete, SYSTEM replaced, USER/CLAUDE.md preserved). diff --git a/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts index 52bceb2c95..062f10316b 100644 --- a/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts +++ b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.test.ts @@ -1,5 +1,5 @@ -import { test, expect } from "bun:test"; -import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, symlinkSync, rmSync } from "node:fs"; +import { test, expect, afterAll } from "bun:test"; +import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync, symlinkSync, rmSync, lstatSync, readdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; import { @@ -8,7 +8,12 @@ import { planClaudeSplit, applyClaudeSplit, undoClaudeSplit, } from "./LifeosUpgrade"; -const tmp = (p: string) => mkdtempSync(join(tmpdir(), p)); +// TR6: every tmp root is tracked and removed after the suite β€” no orphaned temp trees. +const CREATED: string[] = []; +const tmp = (p: string) => { const d = mkdtempSync(join(tmpdir(), p)); CREATED.push(d); return d; }; +const trackedPath = (name: string) => { const p = join(tmpdir(), name); CREATED.push(p); return p; }; +afterAll(() => { for (const d of CREATED) rmSync(d, { recursive: true, force: true }); }); +const lexistsT = (p: string): boolean => { try { lstatSync(p); return true; } catch { return false; } }; function tree(root: string, dirs: string[], files: Record = {}) { for (const d of dirs) mkdirSync(join(root, d), { recursive: true }); for (const [f, c] of Object.entries(files)) { mkdirSync(join(root, f, ".."), { recursive: true }); writeFileSync(join(root, f), c); } @@ -79,6 +84,21 @@ test("preflight: bare CLAUDE.md dir is rejected (#3); dev-tree refused", () => { expect(preflight(dev, pay).some((e) => e.includes("DEV-TREE"))).toBe(true); }); +test("preflight: missing payload + ambiguous target rejected (#TR3)", () => { + const root = tmp("lu-pf-"); tree(root, ["LIFEOS"], { "settings.json": "{}" }); + expect(preflight(root, "").some((e) => e.includes("--payload"))).toBe(true); // payload missing + expect(preflight(root, join(root, "nope")).some((e) => e.includes("--payload"))).toBe(true); // payload nonexistent + const ambiguous = tmp("lu-amb-"); mkdirSync(join(ambiguous, "LIFEOS"), { recursive: true }); // LIFEOS/ but no settings.json/CLAUDE.md + const pay = tmp("lu-pfp-"); mkdirSync(join(pay, "LIFEOS"), { recursive: true }); + expect(preflight(ambiguous, pay).some((e) => e.includes("ambiguous"))).toBe(true); +}); + +test("scopedBackup refuses a pre-existing backup dir (#TR8)", () => { + const root = tmp("lu-sb-"); tree(root, ["LIFEOS/TOOLS"], { "LIFEOS/TOOLS/a.ts": "A" }); + const bak = tmp("lu-sb-bak-"); // exists already + expect(() => scopedBackup(root, bak, ["LIFEOS/TOOLS"])).toThrow(/already exists/); +}); + test("parseArgs: rejects empty/missing values and swallowed flags (#4, #9)", () => { expect(() => parseArgs(["--payload"])).toThrow(); // missing value expect(() => parseArgs(["--config-root", ""])).toThrow(); // explicit empty β†’ no silent prod default (#4) @@ -87,13 +107,31 @@ test("parseArgs: rejects empty/missing values and swallowed flags (#4, #9)", () expect(parseArgs(["--payload", "/x", "--apply"])).toEqual({ apply: true, payload: "/x" }); }); -test("copyMissing: does not follow symlinks β€” no cycle stack-overflow (#7)", () => { +test("copyMissing: never follows OR deploys symlinks (#7, #S1 β€” matches upstream DeployCore)", () => { const base = tmp("lu-sl-"); const src = join(base, "src"), dst = join(base, "dst"); mkdirSync(src, { recursive: true }); writeFileSync(join(src, "real.txt"), "R"); symlinkSync(src, join(src, "loop")); // self-referential dir symlink (would infinite-recurse if followed) + symlinkSync("/tmp/attacker-target-nope", join(src, "evil")); // payload symlink to an arbitrary target const { copied } = copyMissing(src, dst); // must terminate expect(existsSync(join(dst, "real.txt"))).toBe(true); expect(copied).toBeGreaterThan(0); + expect(readdirSync(dst)).not.toContain("loop"); // S1: payload symlinks are SKIPPED, never recreated + expect(readdirSync(dst)).not.toContain("evil"); +}); + +test("backup+rollback are symlink-aware: dangling symlink round-trips without spurious failure (#P1/#P2)", () => { + const root = tmp("lu-syl-"); const bak = trackedPath("bak-syl-" + Date.now()); + tree(root, ["LIFEOS/TOOLS"], { "LIFEOS/TOOLS/real.ts": "R" }); + symlinkSync("/nonexistent-target-xyz", join(root, "LIFEOS/TOOLS/dead-link")); // dangling symlink in the cleared zone + const clear = ["LIFEOS/TOOLS"]; + const snap = snapshotDeployTargets(root, [{ src: "LIFEOS", dest: "LIFEOS" }]); + scopedBackup(root, bak, clear); // P1: must NOT spuriously abort + expect(lexistsT(join(bak, "LIFEOS/TOOLS/dead-link"))).toBe(true); // backup holds the symlink itself + safeRemove(root, "LIFEOS/TOOLS"); + const failed = rollback(root, bak, clear, snap); // P2: must NOT report PARTIAL + expect(failed).toEqual([]); + expect(readFileSync(join(root, "LIFEOS/TOOLS/real.ts"), "utf8")).toBe("R"); + expect(lexistsT(join(root, "LIFEOS/TOOLS/dead-link"))).toBe(true); // symlink restored as a symlink }); test("safeRemove refuses a preserved path (guard 1) and path escape (guard 2)", () => { @@ -252,6 +290,59 @@ test("applyClaudeSplit: re-checks GLOBAL.md at WRITE time β€” no planβ†’apply TO rmSync(bak, { recursive: true, force: true }); }); +test("applyClaudeSplit: throw BEFORE CLAUDE.md write self-cleans β€” CLAUDE.md untouched (#TR2a)", () => { + const root = tmp("lu-t2a-"), pay = tmp("lu-t2ap-"); const bak = trackedPath("bak-t2a-" + Date.now()); + tree(root, ["LIFEOS/USER"], { "CLAUDE.md": "## LifeOS System\n\n- **CodeGraph** β€” mine" }); + writeFileSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS"), "A FILE, NOT A DIR"); // makes ensureParentDir(GLOBAL) throw + writeFileSync(join(pay, "CLAUDE.template.md"), "## LifeOS System\n\n- **Memory** β€” `x`"); + mkdirSync(bak, { recursive: true }); + const plan = planClaudeSplit(root, pay); + expect(plan.willSplit).toBe(true); + expect(() => applyClaudeSplit(root, pay, bak, plan)).toThrow(); // forced failure fires the catch + expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).toContain("CodeGraph"); // CLAUDE.md untouched + expect(readFileSync(join(bak, "CLAUDE.md.pre-split"), "utf8")).toContain("CodeGraph"); // backup was written first +}); + +test("applyClaudeSplit: throw AFTER GLOBAL write removes the partial GLOBAL.md (#TR2b)", () => { + const root = tmp("lu-t2b-"), pay = tmp("lu-t2bp-"); const bak = trackedPath("bak-t2b-" + Date.now()); + tree(root, ["LIFEOS/USER/CUSTOMIZATIONS"], { "CLAUDE.md": "## LifeOS System\n\n- **CodeGraph** β€” mine" }); + writeFileSync(join(pay, "CLAUDE.template.md"), "## LifeOS System\n\n- **Memory** β€” `x`"); + mkdirSync(bak, { recursive: true }); + const plan = planClaudeSplit(root, pay); + rmSync(join(pay, "CLAUDE.template.md")); // vanish the template between plan and apply + expect(() => applyClaudeSplit(root, pay, bak, plan)).toThrow(); // readFileSync(template) throws AFTER GLOBAL write + expect(existsSync(join(root, "LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md"))).toBe(false); // partial GLOBAL cleaned up + expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).toContain("CodeGraph"); // CLAUDE.md untouched +}); + +const TOOL_PATH = new URL("./LifeosUpgrade.ts", import.meta.url).pathname; + +test("main() dry-run via subprocess: exit 0, renders plan, mutates NOTHING (#TR1)", () => { + const root = tmp("lu-m1-"), pay = tmp("lu-m1p-"); + tree(root, ["LIFEOS/TOOLS", "LIFEOS/USER"], { "LIFEOS/TOOLS/orig.ts": "ORIG", "CLAUDE.md": "x", "settings.json": "{}" }); + tree(pay, ["LIFEOS/TOOLS"], { "LIFEOS/TOOLS/new.ts": "NEW" }); + const r = Bun.spawnSync(["bun", TOOL_PATH, "--payload", pay, "--config-root", root]); + expect(r.exitCode).toBe(0); + expect(r.stdout.toString()).toContain("DRY-RUN"); + expect(readFileSync(join(root, "LIFEOS/TOOLS/orig.ts"), "utf8")).toBe("ORIG"); // nothing changed + expect(existsSync(join(root, "LIFEOS/TOOLS/new.ts"))).toBe(false); +}); + +test("main() apply via subprocess: backup precedes delete; SYSTEM replaced, USER preserved (#TR1)", () => { + const root = tmp("lu-m2-"), pay = tmp("lu-m2p-"); const bak = trackedPath("bak-m2-" + Date.now()); + tree(root, ["LIFEOS/TOOLS", "LIFEOS/USER"], { + "LIFEOS/TOOLS/orig.ts": "ORIG", "LIFEOS/USER/keep.md": "USER", "CLAUDE.md": "MY CLAUDE", "settings.json": "{}", + }); + tree(pay, ["LIFEOS/TOOLS"], { "LIFEOS/TOOLS/new.ts": "NEW" }); + const r = Bun.spawnSync(["bun", TOOL_PATH, "--payload", pay, "--config-root", root, "--apply", "--backup-dir", bak]); + expect(r.exitCode).toBe(0); + expect(readFileSync(join(bak, "LIFEOS/TOOLS/orig.ts"), "utf8")).toBe("ORIG"); // backup holds the ORIGINAL β†’ backup preceded delete + expect(readFileSync(join(root, "LIFEOS/TOOLS/new.ts"), "utf8")).toBe("NEW"); // payload deployed + expect(existsSync(join(root, "LIFEOS/TOOLS/orig.ts"))).toBe(false); // upstream deletion honored + expect(readFileSync(join(root, "LIFEOS/USER/keep.md"), "utf8")).toBe("USER"); // USER preserved + expect(readFileSync(join(root, "CLAUDE.md"), "utf8")).toBe("MY CLAUDE"); // CLAUDE.md preserved (no --split-claude-md) +}); + test("split + rollback compose: deploy failure restores CLAUDE.md, removes GLOBAL, restores SYSTEM (#split-rollback)", () => { const root = tmp("lu-srb-"), pay = tmp("lu-srbp-"); const bak = join(root, "..", "bak-srb-" + Date.now()); tree(root, ["LIFEOS/TOOLS", "LIFEOS/USER/CUSTOMIZATIONS"], { diff --git a/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts index b435866407..1d1345917d 100755 --- a/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts +++ b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts @@ -27,16 +27,18 @@ * Usage: * bun LifeosUpgrade.ts --payload # dry-run * bun LifeosUpgrade.ts --payload --apply # execute - * flags: --config-root (default ~/.claude), --backup-dir , + * flags: --config-root (default $CLAUDE_CONFIG_DIR or ~/.claude), --backup-dir , * --split-claude-md (extract CLAUDE.md customizations β†’ USER/CUSTOMIZATIONS/GLOBAL.md, * then replace CLAUDE.md with the payload base), --help */ -import { existsSync, readdirSync, lstatSync, readlinkSync, symlinkSync, rmSync, mkdirSync, copyFileSync, cpSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readdirSync, lstatSync, rmSync, mkdirSync, copyFileSync, cpSync, readFileSync, writeFileSync } from "node:fs"; import { join, sep, resolve, dirname } from "node:path"; import { homedir } from "node:os"; const ensureParentDir = (p: string) => mkdirSync(dirname(p), { recursive: true }); const errCode = (e: unknown): string | undefined => (e as { code?: string })?.code; +/** lstat-based existence β€” sees a symlink ITSELF (existsSync follows links, so a dangling symlink reads as absent and breaks backup/rollback verification). */ +const lexists = (p: string): boolean => { try { lstatSync(p); return true; } catch { return false; } }; // ── Zone policy (the load-bearing correctness surface) ────────────────────── @@ -102,21 +104,22 @@ export function computeClearList(configRoot: string, payloadRoot: string): strin return out.filter((r) => !isPreserved(r)); // belt-and-suspenders vs a future consider() that forgets the check } -// ── copyMissing (mirrors v7 DeployCore: recursive, existsSync-guarded, never overwrites) ── -// lstat (no symlink follow β†’ no cycle). Only ENOENT means "nothing to copy"; real errors propagate β†’ rollback. +// ── copyMissing (mirrors upstream DeployCore: recursive, never overwrites) ── +// Payload SYMLINKS ARE SKIPPED, exactly like upstream DeployCore (its isFile/isDirectory +// branches never match a symlink) β€” deploying a payload-authored symlink would let a +// tampered payload plant a link to an arbitrary target (symlink-target injection). +// dst guards use lexists so a dangling symlink at dst is treated as occupied, not absent. +// Only ENOENT on src means "nothing to copy"; real errors propagate β†’ rollback. export function copyMissing(src: string, dst: string): { copied: number } { let copied = 0; let st; try { st = lstatSync(src); } catch (e) { if (errCode(e) === "ENOENT") return { copied }; throw e; } - if (st.isSymbolicLink()) { - if (!existsSync(dst)) { ensureParentDir(dst); symlinkSync(readlinkSync(src), dst); copied++; } - return { copied }; - } + if (st.isSymbolicLink()) return { copied }; // never deploy payload symlinks (matches upstream) if (st.isDirectory()) { - if (!existsSync(dst)) mkdirSync(dst, { recursive: true }); + if (!lexists(dst)) mkdirSync(dst, { recursive: true }); for (const e of readdirSync(src)) copied += copyMissing(join(src, e), join(dst, e)).copied; - } else if (!existsSync(dst)) { + } else if (!lexists(dst)) { ensureParentDir(dst); copyFileSync(src, dst); copied++; } @@ -155,10 +158,14 @@ export function preflight(configRoot: string, payloadRoot: string, backupDir?: s errs.push(`config-root has no LIFEOS/ dir β€” not a LifeOS install (a stray CLAUDE.md is not enough): ${configRoot}`); else if (!existsSync(join(configRoot, "settings.json")) && !existsSync(join(configRoot, "CLAUDE.md"))) errs.push(`config-root has LIFEOS/ but no settings.json/CLAUDE.md β€” refusing an ambiguous target: ${configRoot}`); + // Same predicate as InstallEngine.detectDevTree() β€” deliberately inlined, not imported: + // this tool REPLACES the very tree that helper ships in, and skills are self-contained + // by repo convention (zero cross-skill imports exist anywhere in the payload). if (existsSync(join(configRoot, "skills", "_LIFEOS"))) errs.push("DEV-TREE REFUSAL: skills/_LIFEOS present β€” refusing to mutate a LifeOS source repo."); if (!payloadRoot || !existsSync(payloadRoot)) errs.push(`--payload dir missing or not found: ${payloadRoot}`); if (backupDir) { + // Lexical (non-realpath) comparison β€” same documented limitation as safeRemove's escape guard. const b = resolve(backupDir), c = resolve(configRoot); if (b === c || b.startsWith(c + sep)) errs.push(`--backup-dir must be OUTSIDE config-root (would nest the backup inside the tree being cleared): ${backupDir}`); } @@ -180,7 +187,7 @@ export function scopedBackup(configRoot: string, backupDir: string, clear: strin const dst = join(backupDir, rel); ensureParentDir(dst); cpSync(join(configRoot, rel), dst, { recursive: true, errorOnExist: false }); - if (!existsSync(dst)) throw new Error(`backup verification failed for ${rel} β€” aborting before any delete`); + if (!lexists(dst)) throw new Error(`backup verification failed for ${rel} β€” aborting before any delete`); } } @@ -206,19 +213,20 @@ export function rollback(configRoot: string, backupDir: string, clear: string[], const rel = `${dest}/${child}`; if (beforeSet.has(child)) continue; // pre-existing (cleared ones are restored below) if (isPreserved(rel)) continue; - try { safeRemove(configRoot, rel); } catch { failed.push(`(new)${rel}`); } + try { safeRemove(configRoot, rel); } + catch (e) { console.error(` rollback: could not remove ${rel}: ${(e as Error)?.message ?? String(e)}`); failed.push(`(new)${rel}`); } } } - // 2) restore cleared entries from backup (through the guarded delete) + // 2) restore cleared entries from backup (through the guarded delete; lexists so symlink-bearing backups verify correctly) for (const rel of clear) { const bak = join(backupDir, rel), live = join(configRoot, rel); - if (!existsSync(bak)) { failed.push(rel); continue; } + if (!lexists(bak)) { failed.push(rel); continue; } try { - if (existsSync(live)) safeRemove(configRoot, rel); // remove partial deploy remnants (guarded) + if (lexists(live)) safeRemove(configRoot, rel); // remove partial deploy remnants (guarded) ensureParentDir(live); cpSync(bak, live, { recursive: true, errorOnExist: false }); - if (!existsSync(live)) failed.push(rel); - } catch { failed.push(rel); } + if (!lexists(live)) failed.push(rel); + } catch (e) { console.error(` rollback: could not restore ${rel}: ${(e as Error)?.message ?? String(e)}`); failed.push(rel); } } return failed; } @@ -382,8 +390,12 @@ export function applyClaudeSplit(configRoot: string, payloadRoot: string, backup /** Undo a split: restore CLAUDE.md from backup, remove only a GLOBAL.md this run created. Returns entries it could NOT restore. */ export function undoClaudeSplit(configRoot: string, undo: SplitUndo): string[] { const failed: string[] = []; - try { copyFileSync(undo.claudeBackup, join(configRoot, "CLAUDE.md")); } catch { failed.push("CLAUDE.md"); } - if (undo.globalCreated) { try { rmSync(undo.globalCreated, { force: true }); } catch { failed.push(GLOBAL_REL); } } + try { copyFileSync(undo.claudeBackup, join(configRoot, "CLAUDE.md")); } + catch (e) { console.error(` split-undo: could not restore CLAUDE.md: ${(e as Error)?.message ?? String(e)}`); failed.push("CLAUDE.md"); } + if (undo.globalCreated) { + try { rmSync(undo.globalCreated, { force: true }); } + catch (e) { console.error(` split-undo: could not remove ${GLOBAL_REL}: ${(e as Error)?.message ?? String(e)}`); failed.push(GLOBAL_REL); } + } return failed; } @@ -402,7 +414,7 @@ Usage: Flags: --payload REQUIRED. The target version's install/ payload tree. --apply Execute. Without it, dry-run only. - --config-root LifeOS install to upgrade (default: ~/.claude). + --config-root LifeOS install to upgrade (default: $CLAUDE_CONFIG_DIR, else ~/.claude). --backup-dir Backup location (default: -backup-; must be outside config-root). --split-claude-md Extract CLAUDE.md customizations β†’ LIFEOS/USER/CUSTOMIZATIONS/GLOBAL.md, then replace CLAUDE.md with the payload base (imports ship commented; @@ -438,7 +450,8 @@ function main() { catch (e) { die(`ARG ERROR: ${(e as Error).message}\n\n${USAGE}`); } if (a.help || process.argv.length <= 2) { console.log(USAGE); return; } - const configRoot = resolve((a.configRoot as string) || join(homedir(), ".claude")); // default only when flag ABSENT + // Default resolution honors CLAUDE_CONFIG_DIR (upstream PR #696 convention; matches ActivateImports.ts). + const configRoot = resolve((a.configRoot as string) || process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude")); const payloadRoot = a.payload ? resolve(a.payload as string) : ""; const apply = a.apply === true; const splitClaudeMd = a.splitClaudeMd === true; From b79d4ffd5d99174f00ca7192bf6b45b423305613 Mon Sep 17 00:00:00 2001 From: Matt <174058705+asdf8675309@users.noreply.github.com> Date: Wed, 22 Jul 2026 20:43:31 -0400 Subject: [PATCH 3/3] CoreUpgrade: document deps-install + frontend-build post-upgrade step Surfaced by running the tool on a real v6->v7 upgrade: the tool copies source but not node_modules or built frontend output, so a deployed component with a new dependency or a build step fails until the user runs its bun install (+ build). Concrete case hit: LIFEOS/PULSE needs bun install, and its Observability dashboard needs bun install && bun run build, or the dashboard 503s. - Add it as SKILL.md Post-Upgrade Step 2 (before InstallHooks, since tools may need deps too). - Add a runtime warning to planUpgrade() so every dry-run/apply prints it. Docs + one warning string; no logic change. 27 tests / 138 assertions green. Co-authored-by: Claude --- LifeOS/install/skills/CoreUpgrade/SKILL.md | 7 ++++--- LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/LifeOS/install/skills/CoreUpgrade/SKILL.md b/LifeOS/install/skills/CoreUpgrade/SKILL.md index 9897a77fe5..940ed3e159 100644 --- a/LifeOS/install/skills/CoreUpgrade/SKILL.md +++ b/LifeOS/install/skills/CoreUpgrade/SKILL.md @@ -71,9 +71,10 @@ Review the dry-run's split report (additions / flagged modifications / matched / ## Post-Upgrade Steps (manual, printed by the tool) 1. `--split-claude-md` used β†’ run the payload's `ActivateImports.ts` (imports ship commented). -2. Run the payload's `InstallHooks.ts` to wire new-version hooks. -3. Reconcile `settings*.json` against the payload templates. -4. Re-apply any custom edits you had made inside replaced SYSTEM dirs (from the backup; better: move them to `skills/_*` first, where they're preserved automatically). +2. **Install deps + build any deployed component that needs it.** The tool copies source, not `node_modules` or built frontend output β€” so a deployed dir with a new dependency or a build step will fail until you run its `bun install` (and build). Known case: `LIFEOS/PULSE` needs `bun install`, and its `Observability/` dashboard needs `bun install && bun run build`, or the dashboard 503s. Check every deployed component that ships a `package.json` or a build output dir. +3. Run the payload's `InstallHooks.ts` to wire new-version hooks. +4. Reconcile `settings*.json` against the payload templates. +5. Re-apply any custom edits you had made inside replaced SYSTEM dirs (from the backup; better: move them to `skills/_*` first, where they're preserved automatically). ## Verification diff --git a/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts index 1d1345917d..e6ed7f08f9 100755 --- a/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts +++ b/LifeOS/install/skills/CoreUpgrade/Tools/LifeosUpgrade.ts @@ -147,6 +147,7 @@ export function planUpgrade(configRoot: string, payloadRoot: string): Plan { warnings.push("SYSTEM dirs (LIFEOS/*, public skills) are replaced wholesale β€” custom code placed INSIDE a shipped dir (e.g. a tool in LIFEOS/TOOLS) is backed up but NOT merged; the payload version wins. Move custom code into a preserved private skills/_* skill before upgrading, or re-apply it from the backup after."); warnings.push("PRESERVED, never replaced: CLAUDE.md + settings*.json (they hold your @-imports and hook registrations). Reconcile them by hand against the payload's CLAUDE.template.md + settings templates after deploy."); warnings.push("Config reconciliation is a MANUAL post-step (by design β€” it depends on YOUR customizations): (1) run the payload's InstallHooks.ts to wire new hooks; (2) fold new CLAUDE.md/settings structure in by hand; (3) re-apply any edits to replaced SYSTEM files from the backup dir."); + warnings.push("Deployed components with their own dependencies or a build step (e.g. LIFEOS/PULSE) need `bun install` + their build re-run AFTER deploy β€” this tool copies source, not node_modules or built output. A component with a new dependency or an unbuilt frontend will fail (e.g. HTTP 503) until then."); return { configRoot, payloadRoot, clear, deployRoots, warnings }; }