diff --git a/CHANGELOG.md b/CHANGELOG.md index 031028e..eb5dd23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [1.0.5] - 2026-06-12 + +### Fixed + +- First packaged app launch sets User `SHELLFORGE_USER_DATA` to `%APPDATA%/ShellForge/shellforge-data/` when not already defined, aligning CLI with the desktop app. +- CLI user-data resolution falls back to the AppData path for packaged installs when the runtime install dir has no config, even before `config.json` exists. + ## [1.0.3] - 2026-06-12 ### Fixed diff --git a/package.json b/package.json index 892e786..5916fd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "shellforge", - "version": "1.0.3", + "version": "1.0.5", "description": "ShellForge - custom Windows PowerShell automation and commands", "license": "ISC", "author": "João Luiz de Castro", diff --git a/ui/package-lock.json b/ui/package-lock.json index f9d66c8..ed2f82c 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1,12 +1,12 @@ { "name": "shellforge-ui", - "version": "1.0.3", + "version": "1.0.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "shellforge-ui", - "version": "1.0.3", + "version": "1.0.5", "dependencies": { "dagre": "^0.8.5", "js-pretty-icons": "^0.3.0", diff --git a/ui/package.json b/ui/package.json index 226d49b..058b397 100644 --- a/ui/package.json +++ b/ui/package.json @@ -1,6 +1,6 @@ { "name": "shellforge-ui", - "version": "1.0.3", + "version": "1.0.5", "description": "ShellForge desktop manager for Windows PowerShell automation", "author": "João Luiz de Castro", "private": true, diff --git a/ui/src/main/services/ensureShellforgeUserDataEnv.test.ts b/ui/src/main/services/ensureShellforgeUserDataEnv.test.ts new file mode 100644 index 0000000..c5d992c --- /dev/null +++ b/ui/src/main/services/ensureShellforgeUserDataEnv.test.ts @@ -0,0 +1,95 @@ +import { execFileSync } from "node:child_process"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:child_process", () => ({ + execFileSync: vi.fn(), +})); + +import { + ensureShellforgeUserDataEnvVar, + readWindowsUserEnvironmentVariable, + SHELLFORGE_USER_DATA_ENV_VAR, + setWindowsUserEnvironmentVariable, +} from "./ensureShellforgeUserDataEnv"; + +describe("ensureShellforgeUserDataEnv", () => { + const execFileSyncMock = vi.mocked(execFileSync); + const originalPlatform = process.platform; + const userDataRepoRoot = "C:\\Users\\test\\AppData\\Roaming\\ShellForge\\shellforge-data"; + + afterEach(() => { + vi.clearAllMocks(); + Object.defineProperty(process, "platform", { value: originalPlatform }); + delete process.env[SHELLFORGE_USER_DATA_ENV_VAR]; + }); + + it("reads a User environment variable via PowerShell", () => { + execFileSyncMock.mockReturnValue(`${userDataRepoRoot}\r\n`); + + expect(readWindowsUserEnvironmentVariable(SHELLFORGE_USER_DATA_ENV_VAR)).toBe(userDataRepoRoot); + expect(execFileSyncMock).toHaveBeenCalledWith( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + "[Environment]::GetEnvironmentVariable('SHELLFORGE_USER_DATA','User')", + ], + { encoding: "utf8", windowsHide: true }, + ); + }); + + it("returns null when the User environment variable is unset", () => { + execFileSyncMock.mockReturnValue("\r\n"); + + expect(readWindowsUserEnvironmentVariable(SHELLFORGE_USER_DATA_ENV_VAR)).toBeNull(); + }); + + it("sets a User environment variable via PowerShell", () => { + execFileSyncMock.mockReturnValue(""); + + setWindowsUserEnvironmentVariable(SHELLFORGE_USER_DATA_ENV_VAR, userDataRepoRoot); + + expect(execFileSyncMock).toHaveBeenCalledWith( + "powershell.exe", + [ + "-NoProfile", + "-NonInteractive", + "-Command", + `[Environment]::SetEnvironmentVariable('SHELLFORGE_USER_DATA','${userDataRepoRoot}','User')`, + ], + { encoding: "utf8", windowsHide: true }, + ); + }); + + it("creates SHELLFORGE_USER_DATA when missing and mirrors it into the current process", () => { + Object.defineProperty(process, "platform", { value: "win32" }); + execFileSyncMock + .mockReturnValueOnce("\r\n") + .mockReturnValueOnce(""); + + ensureShellforgeUserDataEnvVar(userDataRepoRoot); + + expect(process.env[SHELLFORGE_USER_DATA_ENV_VAR]).toBe(userDataRepoRoot); + expect(execFileSyncMock).toHaveBeenCalledTimes(2); + }); + + it("does not overwrite an existing User environment variable", () => { + Object.defineProperty(process, "platform", { value: "win32" }); + execFileSyncMock.mockReturnValue(`${userDataRepoRoot}\r\n`); + + ensureShellforgeUserDataEnvVar("C:\\other\\path\\shellforge-data"); + + expect(execFileSyncMock).toHaveBeenCalledTimes(1); + expect(process.env[SHELLFORGE_USER_DATA_ENV_VAR]).toBeUndefined(); + }); + + it("no-ops on non-Windows platforms", () => { + Object.defineProperty(process, "platform", { value: "darwin" }); + + ensureShellforgeUserDataEnvVar(userDataRepoRoot); + + expect(execFileSyncMock).not.toHaveBeenCalled(); + expect(process.env[SHELLFORGE_USER_DATA_ENV_VAR]).toBeUndefined(); + }); +}); diff --git a/ui/src/main/services/ensureShellforgeUserDataEnv.ts b/ui/src/main/services/ensureShellforgeUserDataEnv.ts new file mode 100644 index 0000000..39a7d7e --- /dev/null +++ b/ui/src/main/services/ensureShellforgeUserDataEnv.ts @@ -0,0 +1,51 @@ +import { execFileSync } from "node:child_process"; + +export const SHELLFORGE_USER_DATA_ENV_VAR = "SHELLFORGE_USER_DATA"; + +function escapePowerShellSingleQuotedString(value: string): string { + return value.replace(/'/g, "''"); +} + +export function runPowerShellCommand(script: string): string { + return execFileSync( + "powershell.exe", + ["-NoProfile", "-NonInteractive", "-Command", script], + { encoding: "utf8", windowsHide: true }, + ).trim(); +} + +export function readWindowsUserEnvironmentVariable(variableName: string): string | null { + const escapedVariableName = escapePowerShellSingleQuotedString(variableName); + const output = runPowerShellCommand( + `[Environment]::GetEnvironmentVariable('${escapedVariableName}','User')`, + ); + + return output.length > 0 ? output : null; +} + +export function setWindowsUserEnvironmentVariable(variableName: string, value: string): void { + const escapedVariableName = escapePowerShellSingleQuotedString(variableName); + const escapedValue = escapePowerShellSingleQuotedString(value); + runPowerShellCommand( + `[Environment]::SetEnvironmentVariable('${escapedVariableName}','${escapedValue}','User')`, + ); +} + +export function ensureShellforgeUserDataEnvVar(userDataRepoRoot: string): void { + if (process.platform !== "win32") { + return; + } + + const trimmedUserDataRepoRoot = userDataRepoRoot.trim(); + if (trimmedUserDataRepoRoot.length === 0) { + throw new Error("User data repo root cannot be empty when ensuring SHELLFORGE_USER_DATA."); + } + + const existingValue = readWindowsUserEnvironmentVariable(SHELLFORGE_USER_DATA_ENV_VAR); + if (existingValue !== null && existingValue.trim().length > 0) { + return; + } + + setWindowsUserEnvironmentVariable(SHELLFORGE_USER_DATA_ENV_VAR, trimmedUserDataRepoRoot); + process.env[SHELLFORGE_USER_DATA_ENV_VAR] = trimmedUserDataRepoRoot; +} diff --git a/ui/src/main/services/packagedDataBootstrap.test.ts b/ui/src/main/services/packagedDataBootstrap.test.ts index 8f52eaa..6cb2d78 100644 --- a/ui/src/main/services/packagedDataBootstrap.test.ts +++ b/ui/src/main/services/packagedDataBootstrap.test.ts @@ -12,11 +12,16 @@ vi.mock("electron", () => ({ app: appMock, })); +vi.mock("./ensureShellforgeUserDataEnv", () => ({ + ensureShellforgeUserDataEnvVar: vi.fn(), +})); + import { ensurePackagedUserData, getPackagedRuntimePath, getUserDataRepoRoot, } from "./packagedDataBootstrap"; +import { ensureShellforgeUserDataEnvVar } from "./ensureShellforgeUserDataEnv"; import { SHELLFORGE_RUNTIME_VERSION_FILE } from "./shellforgeRuntimeLayout"; function createRuntimeBundle(runtimeRoot: string, version: string): void { @@ -71,6 +76,7 @@ describe("packagedDataBootstrap", () => { expect(fs.existsSync(path.join(userDataRoot, "nodejs"))).toBe(false); expect(getUserDataRepoRoot()).toBe(userDataRoot); expect(getPackagedRuntimePath()).toBe(bundledRuntimeRoot); + expect(ensureShellforgeUserDataEnvVar).toHaveBeenCalledWith(userDataRoot); }); it("preserves config.json and updates runtime version marker on version change", () => { diff --git a/ui/src/main/services/packagedDataBootstrap.ts b/ui/src/main/services/packagedDataBootstrap.ts index a4c8f90..d7adccd 100644 --- a/ui/src/main/services/packagedDataBootstrap.ts +++ b/ui/src/main/services/packagedDataBootstrap.ts @@ -1,6 +1,7 @@ import { app } from "electron"; import fs from "node:fs"; import path from "node:path"; +import { ensureShellforgeUserDataEnvVar } from "./ensureShellforgeUserDataEnv"; import { PACKAGED_RUNTIME_RESOURCE_DIR, SHELLFORGE_RUNTIME_VERSION_FILE, @@ -81,4 +82,6 @@ export function ensurePackagedUserData(): void { if (bundledVersion && bundledVersion !== installedVersion) { writeInstalledRuntimeVersion(userDataRepoRoot, bundledVersion); } + + ensureShellforgeUserDataEnvVar(userDataRepoRoot); } diff --git a/utils/getConfig.js b/utils/getConfig.js index e50478b..433ae27 100644 --- a/utils/getConfig.js +++ b/utils/getConfig.js @@ -26,10 +26,7 @@ function resolveUserDataRootFromInputs({ } if (typeof appDataPath === "string" && appDataPath.trim().length > 0) { - const packagedUserDataRoot = getPackagedUserDataRoot(appDataPath.trim()); - if (hasConfigAtRoot(packagedUserDataRoot)) { - return packagedUserDataRoot; - } + return getPackagedUserDataRoot(appDataPath.trim()); } return resolvedRuntimeRoot; diff --git a/utils/tests/getConfig.test.js b/utils/tests/getConfig.test.js index 735298d..17ca752 100644 --- a/utils/tests/getConfig.test.js +++ b/utils/tests/getConfig.test.js @@ -92,6 +92,21 @@ test("resolveUserDataRootFromInputs uses runtime root when config exists there", ); }); +test("resolveUserDataRootFromInputs uses packaged AppData root when runtime config is missing", () => { + const runtimeRoot = createTempRoot("shellforge-runtime-empty-"); + const appDataRoot = createTempRoot("shellforge-appdata-config-"); + const packagedUserDataRoot = getPackagedUserDataRoot(appDataRoot); + + assert.equal( + resolveUserDataRootFromInputs({ + envUserDataRoot: "", + runtimeRoot, + appDataPath: appDataRoot, + }), + packagedUserDataRoot + ); +}); + test("resolveUserDataRootFromInputs uses packaged AppData root when only AppData config exists", () => { const runtimeRoot = createTempRoot("shellforge-runtime-empty-"); const appDataRoot = createTempRoot("shellforge-appdata-config-"); @@ -108,15 +123,14 @@ test("resolveUserDataRootFromInputs uses packaged AppData root when only AppData ); }); -test("resolveUserDataRootFromInputs defaults to runtime root when no config exists", () => { +test("resolveUserDataRootFromInputs defaults to runtime root when AppData is unavailable", () => { const runtimeRoot = createTempRoot("shellforge-runtime-default-"); - const appDataRoot = createTempRoot("shellforge-appdata-empty-"); assert.equal( resolveUserDataRootFromInputs({ envUserDataRoot: "", runtimeRoot, - appDataPath: appDataRoot, + appDataPath: "", }), runtimeRoot );