diff --git a/LifeOS/Tools/InstallEngine.ts b/LifeOS/Tools/InstallEngine.ts index e336412cb1..95768456f6 100644 --- a/LifeOS/Tools/InstallEngine.ts +++ b/LifeOS/Tools/InstallEngine.ts @@ -326,7 +326,7 @@ export function scanSettingsHooks(settingsPath: string): SettingsHookScan { // follows the proven logic from the legacy engine actions.ts. // ════════════════════════════════════════════════════════════════════ -import { cpSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; // Extended 2026-07-25 (Forge finding, v7.15.0 re-audit). The set stopped at .ts, @@ -432,7 +432,9 @@ export function substituteTree(rootDir: string, vars: TemplateVars): { scanned: } if (after !== before) { const tmp = filePath + ".lifeos.tmp"; + const mode = lstatSync(filePath).mode; writeFileSync(tmp, after); + chmodSync(tmp, mode); renameSync(tmp, filePath); modified++; } diff --git a/LifeOS/install/skills/LifeOS/Tools/InstallEngine.ts b/LifeOS/install/skills/LifeOS/Tools/InstallEngine.ts index e336412cb1..95768456f6 100644 --- a/LifeOS/install/skills/LifeOS/Tools/InstallEngine.ts +++ b/LifeOS/install/skills/LifeOS/Tools/InstallEngine.ts @@ -326,7 +326,7 @@ export function scanSettingsHooks(settingsPath: string): SettingsHookScan { // follows the proven logic from the legacy engine actions.ts. // ════════════════════════════════════════════════════════════════════ -import { cpSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; +import { chmodSync, cpSync, lstatSync, mkdirSync, readdirSync, readlinkSync, renameSync, symlinkSync, writeFileSync } from "node:fs"; import { dirname } from "node:path"; // Extended 2026-07-25 (Forge finding, v7.15.0 re-audit). The set stopped at .ts, @@ -432,7 +432,9 @@ export function substituteTree(rootDir: string, vars: TemplateVars): { scanned: } if (after !== before) { const tmp = filePath + ".lifeos.tmp"; + const mode = lstatSync(filePath).mode; writeFileSync(tmp, after); + chmodSync(tmp, mode); renameSync(tmp, filePath); modified++; } diff --git a/LifeOS/test/skills/LifeOS/Tools/InstallEngine.test.ts b/LifeOS/test/skills/LifeOS/Tools/InstallEngine.test.ts new file mode 100644 index 0000000000..c6fbbc9546 --- /dev/null +++ b/LifeOS/test/skills/LifeOS/Tools/InstallEngine.test.ts @@ -0,0 +1,27 @@ +import { expect, test } from "bun:test"; +import { chmodSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { substituteTree } from "../../../../Tools/InstallEngine"; + +test("substituteTree preserves executable file modes", () => { + if (process.platform === "win32") return; + + const root = mkdtempSync(join(tmpdir(), "lifeos-substitute-")); + const hook = join(root, "Example.hook.ts"); + + try { + writeFileSync(hook, "#!/usr/bin/env bun\nHello, {{PRINCIPAL_NAME}}!\n"); + chmodSync(hook, 0o755); + const modeBefore = statSync(hook).mode & 0o777; + + const result = substituteTree(root, { "{{PRINCIPAL_NAME}}": "Ada" }); + + expect(result).toEqual({ scanned: 1, modified: 1, applied: 1 }); + expect(readFileSync(hook, "utf8")).toContain("Hello, Ada!"); + expect(statSync(hook).mode & 0o777).toBe(modeBefore); + } finally { + rmSync(root, { recursive: true, force: true }); + } +});