diff --git a/docs/modules/language-tutorial/pages/02_filling_out_a_template.adoc b/docs/modules/language-tutorial/pages/02_filling_out_a_template.adoc index 8e1440535..ccc4aeb89 100644 --- a/docs/modules/language-tutorial/pages/02_filling_out_a_template.adoc +++ b/docs/modules/language-tutorial/pages/02_filling_out_a_template.adoc @@ -360,7 +360,7 @@ at pkl.base#Module.output.text (https://github.com/apple/pkl/blob/e4d8c882d/stdl <6> What Pkl evaluated to discover the error. When Pkl prints source locations, it also prints clickable links for easy access. -For local files, it generates a link for your development environment (https://pkl-lang.org/main/current/pkl-cli/index.html#settings-file[configurable in `+~/.pkl/settings.pkl+`]). +For local files, it generates a link for your development environment (https://pkl-lang.org/main/current/pkl-cli/index.html#settings-file[configurable in `+~/.config/pkl/settings.pkl+`]). For packages imported from elsewhere, if available, Pkl produces `https://` links to their repository. Pkl complains about a _type constraint_. diff --git a/docs/modules/pkl-cli/pages/index.adoc b/docs/modules/pkl-cli/pages/index.adoc index 6161614bc..3a19e20dc 100644 --- a/docs/modules/pkl-cli/pages/index.adoc +++ b/docs/modules/pkl-cli/pages/index.adoc @@ -1385,14 +1385,14 @@ it works as follows: The Pkl settings file allows to customize the CLI experience. A settings file is a Pkl module amending the `pkl.settings` standard library module. -Its default location is `~/.pkl/settings.pkl`. +Its default location is `~/.config/pkl/settings.pkl` on Unix or `%APPDATA%/pkl/settings.pkl` on Windows, falling back to the legacy `~/.pkl/settings.pkl` if that exists. To use a different settings file, set the `--settings` command line option, for example `--settings mysettings.pkl`. To enforce default settings, use `--settings pkl:settings`. The settings file is also honored by (and configurable through) the Gradle plugin and `CliEvaluator` API. Here is a typical settings file: -.~/.pkl/settings.pkl +.~/.config/pkl/settings.pkl [source%parsed,{pkl}] ---- amends "pkl:settings" // <1> @@ -1414,10 +1414,10 @@ When making TLS requests, Pkl comes with its own set of {uri-certificates}[CA ce These certificates can be overridden via either of the two options: - Set them directly via the CLI option `--ca-certificates `. -- Add them to a directory at path `~/.pkl/cacerts/`. +- Add them to a directory at path `~/.config/pkl/cacerts/` on Unix or `%APPDATA%/pkl/cacerts/` on Windows (or the legacy `~/.pkl/cacerts/`). Both these options will *replace* the default CA certificates bundled with Pkl. + -The CLI option takes precedence over the certificates in `~/.pkl/cacerts/`. + +The CLI option takes precedence over the certificates in the cacerts directory. + Certificates need to be X.509 certificates in PEM format. [[http-proxy]] diff --git a/docs/modules/pkl-cli/partials/cli-common-options.adoc b/docs/modules/pkl-cli/partials/cli-common-options.adoc index 13d94bcd0..b42f2ca8d 100644 --- a/docs/modules/pkl-cli/partials/cli-common-options.adoc +++ b/docs/modules/pkl-cli/partials/cli-common-options.adoc @@ -36,7 +36,7 @@ Possible values: .--cache-dir [%collapsible] ==== -Default: `~/.pkl/cache` + +Default: `~/.cache/pkl` on Unix, `%LOCALAPPDATA%/pkl/cache` on Windows (or the legacy `~/.pkl/cache` if it already exists) + Example: `/path/to/module/cache/` + The cache directory for storing packages. ==== @@ -97,7 +97,7 @@ Any symlinks are resolved before this check is performed. Default: (none) + Example: `mySettings.pkl` + File path of the Pkl settings file to use. -If not set, `~/.pkl/settings.pkl` or defaults specified in the `pkl.settings` standard library module are used. +If not set, `~/.config/pkl/settings.pkl` on Unix or `%APPDATA%/pkl/settings.pkl` on Windows (or the legacy `~/.pkl/settings.pkl`), or defaults specified in the `pkl.settings` standard library module are used. ==== .-t, --timeout diff --git a/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc b/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc index 10fad87cb..cb0042d45 100644 --- a/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc +++ b/docs/modules/pkl-gradle/partials/gradle-common-properties.adoc @@ -64,7 +64,7 @@ Default: `null` + Example 1: `moduleCacheDir = layout.buildDirectory.dir("pkl-module-cache")` + Example 2: `moduleCacheDir.fileValue file("/absolute/path/to/cache")` + The cache directory for storing packages. -If `null`, defaults to `~/.pkl/cache`. +If `null`, defaults to `~/.cache/pkl` on Unix or `%LOCALAPPDATA%/pkl/cache` on Windows (or the legacy `~/.pkl/cache` if it already exists). ==== .color: Property diff --git a/docs/modules/pkl-gradle/partials/gradle-modules-properties.adoc b/docs/modules/pkl-gradle/partials/gradle-modules-properties.adoc index 4f145793d..94b7e11e5 100644 --- a/docs/modules/pkl-gradle/partials/gradle-modules-properties.adoc +++ b/docs/modules/pkl-gradle/partials/gradle-modules-properties.adoc @@ -69,7 +69,7 @@ Example: `settingsModule = layout.projectDirectory.file("mySettings.pkl")` + The Pkl settings module to use. This property accepts the same input types as the `sourceModules` property. -If `null`, `~/.pkl/settings.pkl` or defaults specified in the `pkl.settings` standard library module are used. +If `null`, `~/.config/pkl/settings.pkl` on Unix or `%APPDATA%/pkl/settings.pkl` on Windows (or the legacy `~/.pkl/settings.pkl`), or defaults specified in the `pkl.settings` standard library module are used. ==== include::../partials/gradle-common-properties.adoc[] diff --git a/docs/modules/release-notes/pages/0.32.adoc b/docs/modules/release-notes/pages/0.32.adoc index f26873229..6cf209e65 100644 --- a/docs/modules/release-notes/pages/0.32.adoc +++ b/docs/modules/release-notes/pages/0.32.adoc @@ -37,6 +37,41 @@ Ready when you need them. * New property: link:{uri-stdlib-projectModule}#resolvedEvaluatorSettings[`Project.resolvedEvaluatorSettings`] +=== CLI Changes + +==== Default file locations + +For new setups, the CLI no longer stores anything under `~/.pkl` (https://github.com/apple/pkl/pull/1674[#1674]). It uses XDG-style locations on Unix and Known Folder locations on Windows: + +[cols="1,2,2,2",options="header"] +|=== +| Concern | Unix (Linux/macOS) | Windows | Legacy fallback + +| Package cache +| `~/.cache/pkl` +| `%LOCALAPPDATA%/pkl/cache` +| `~/.pkl/cache` + +| Settings file +| `~/.config/pkl/settings.pkl` +| `%APPDATA%/pkl/settings.pkl` +| `~/.pkl/settings.pkl` + +| CA certificates +| `~/.config/pkl/cacerts` +| `%APPDATA%/pkl/cacerts` +| `~/.pkl/cacerts` + +| REPL history +| `~/.local/state/pkl/repl-history` +| `%LOCALAPPDATA%/pkl/repl-history` +| `~/.pkl/repl-history` +|=== + +No Pkl-specific environment variables (such as `XDG_CACHE_HOME`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, or `PKL_HOME`) are read. On Windows, the standard `APPDATA` and `LOCALAPPDATA` paths are honoured because that is how Windows itself exposes the Known Folders – they are not Pkl-specific knobs. If `APPDATA` or `LOCALAPPDATA` is unset, the Unix layout is used as a fallback so a misconfigured environment doesn't crash. + +Existing setups keep working without migration: each legacy `~/.pkl` location is still used when it already exists. In particular, a pre-existing `~/.pkl/cache` keeps being used as long as the new location does not exist, so packages aren't re-downloaded. If you have manually created an empty `~/.cache/pkl` (or the Windows equivalent), remove it before upgrading, or move your `~/.pkl/cache` contents over. + == Breaking Changes [small]#💔# Things to watch out for when upgrading. @@ -54,6 +89,7 @@ The following APIs have been removed without replacement. The following APIs have been deprecated for removal. * `org.pkl.config.java.mapper.NonNull` (https://github.com/apple/pkl/pull/1607[#1607]). +* `org.pkl.core.settings.PklSettings.loadFromPklHomeDir()` – renamed to `PklSettings.loadFromDefaultLocation()`, which now prefers `~/.config/pkl/settings.pkl` over the legacy `~/.pkl/settings.pkl` (https://github.com/apple/pkl/pull/1674[#1674]). ==== Changes diff --git a/docs/modules/release-notes/pages/changelog.adoc b/docs/modules/release-notes/pages/changelog.adoc index 2254e9c03..82c587e74 100644 --- a/docs/modules/release-notes/pages/changelog.adoc +++ b/docs/modules/release-notes/pages/changelog.adoc @@ -4,6 +4,11 @@ include::ROOT:partial$component-attributes.adoc[] [[release-0.32.0]] == 0.32.0 (UNRELEASED) +=== Changes + +* Default the CLI's package cache, settings file, CA certificates directory, and REPL history to XDG-style locations instead of `~/.pkl`: `~/.cache/pkl`, `~/.config/pkl/settings.pkl`, `~/.config/pkl/cacerts`, and `~/.local/state/pkl/repl-history` (pr:https://github.com/apple/pkl/pull/1674[]). +The legacy `~/.pkl` locations are still used when they already exist, so existing setups keep working without migration. + [[release-0.31.1]] == 0.31.1 (2026-03-26) diff --git a/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt b/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt index c68ccc8d5..b837a028a 100644 --- a/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt +++ b/pkl-cli/src/main/kotlin/org/pkl/cli/repl/Repl.kt @@ -63,7 +63,7 @@ internal class Repl(workingDir: Path, private val server: ReplServer, private va } completer(AggregateCompleter(CommandCompleter, FileCompleter(workingDir))) option(Option.DISABLE_EVENT_EXPANSION, true) - variable(LineReader.HISTORY_FILE, (IoUtils.getPklHomeDir().resolve("repl-history"))) + variable(LineReader.HISTORY_FILE, IoUtils.getDefaultReplHistoryFile()) } .build() diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt index 0a7274feb..a1a3c18be 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliBaseOptions.kt @@ -77,8 +77,9 @@ data class CliBaseOptions( /** * The Pkl settings file to use. A settings file is a Pkl module amending the `pkl.settings` - * standard library module. If `null`, `~/.pkl/settings.pkl` (if present) or the defaults - * specified in the `pkl:settings` standard library module are used. + * standard library module. If `null`, `~/.config/pkl/settings.pkl` (falling back to the legacy + * `~/.pkl/settings.pkl`), or the defaults specified in the `pkl:settings` standard library + * module, are used. */ private val settings: URI? = null, @@ -130,8 +131,9 @@ data class CliBaseOptions( * The given files must contain [X.509](https://en.wikipedia.org/wiki/X.509) certificates in PEM * format. * - * If [caCertificates] is the empty list, the certificate files in `~/.pkl/cacerts/` are used. If - * `~/.pkl/cacerts/` does not exist or is empty, Pkl's built-in CA certificates are used. + * If [caCertificates] is the empty list, the certificate files in `~/.config/pkl/cacerts/` (or + * the legacy `~/.pkl/cacerts/`) are used. If that directory does not exist or is empty, Pkl's + * built-in CA certificates are used. */ val caCertificates: List = listOf(), diff --git a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt index c34611d90..ae61ab751 100644 --- a/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt +++ b/pkl-commons-cli/src/main/kotlin/org/pkl/commons/cli/CliCommand.kt @@ -69,7 +69,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { if (cliOptions.normalizedSettingsModule != null) { PklSettings.load(ModuleSource.uri(cliOptions.normalizedSettingsModule)) } else { - PklSettings.loadFromPklHomeDir() + PklSettings.loadFromDefaultLocation() } } catch (e: PklException) { // do not use `errorRenderer` because it depends on `settings` @@ -215,7 +215,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) { } private fun HttpClient.Builder.addDefaultCliCertificates() { - val caCertsDir = IoUtils.getPklHomeDir().resolve("cacerts") + val caCertsDir = IoUtils.getDefaultCaCertsDir() var certsAdded = false if (Files.isDirectory(caCertsDir)) { Files.list(caCertsDir) diff --git a/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java b/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java index 590ecb580..dd9d0b0d5 100644 --- a/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java +++ b/pkl-core/src/main/java/org/pkl/core/runtime/ModuleCache.java @@ -111,7 +111,7 @@ public synchronized VmTyped getOrLoad( case "semver": return SemVerModule.getModule(); case "settings": - // always needed if ~/.pkl/settings.pkl is present + // always needed if ~/.config/pkl/settings.pkl is present return SettingsModule.getModule(); case "test": return TestModule.getModule(); diff --git a/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java b/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java index 8b54a1a55..3a315b61f 100644 --- a/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java +++ b/pkl-core/src/main/java/org/pkl/core/settings/PklSettings.java @@ -42,16 +42,31 @@ public record PklSettings(Editor editor, PklEvaluatorSettings.@Nullable Http htt List.of(Pattern.compile("env:"), Pattern.compile("file:")); /** - * Loads the user settings file ({@literal ~/.pkl/settings.pkl}). If this file does not exist, - * returns default settings defined by module {@literal pkl.settings}. + * Loads the user settings file. Prefers {@literal ~/.config/pkl/settings.pkl}, falling back to + * the legacy {@literal ~/.pkl/settings.pkl}. If neither file exists, returns default settings + * defined by module {@literal pkl.settings}. */ + public static PklSettings loadFromDefaultLocation() throws VmEvalException { + return loadFromSettingsFile(IoUtils.getDefaultSettingsFile()); + } + + /** + * Loads the user settings file. + * + * @deprecated As of 0.32.0, renamed to {@link #loadFromDefaultLocation()}, which now prefers + * {@literal ~/.config/pkl/settings.pkl} over the legacy {@literal ~/.pkl/settings.pkl}. + */ + @Deprecated(since = "0.32.0", forRemoval = true) public static PklSettings loadFromPklHomeDir() throws VmEvalException { - return loadFromPklHomeDir(IoUtils.getPklHomeDir()); + return loadFromDefaultLocation(); } /** For testing only. */ - static PklSettings loadFromPklHomeDir(Path pklHomeDir) throws VmEvalException { - var path = pklHomeDir.resolve("settings.pkl"); + static PklSettings loadFromSettingsDir(Path settingsDir) throws VmEvalException { + return loadFromSettingsFile(settingsDir.resolve("settings.pkl")); + } + + private static PklSettings loadFromSettingsFile(Path path) throws VmEvalException { return Files.exists(path) ? load(ModuleSource.path(path)) : new PklSettings(Editor.SYSTEM, null); diff --git a/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java b/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java index c784a0780..22752d12a 100644 --- a/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java +++ b/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java @@ -32,6 +32,7 @@ import java.nio.file.SimpleFileVisitor; import java.nio.file.attribute.BasicFileAttributes; import java.util.*; +import java.util.function.Function; import java.util.regex.Pattern; import java.util.zip.ZipEntry; import java.util.zip.ZipOutputStream; @@ -234,7 +235,160 @@ public static Path getPklHomeDir() { // not stored to avoid build-time initialization by native-image public static Path getDefaultModuleCacheDir() { - return getPklHomeDir().resolve("cache"); + return getDefaultModuleCacheDir(getUserHomeDir(), isWindows(), System::getenv); + } + + // Package-private; the older `(homeDir)` overload retains its Unix-only behaviour so existing + // tests don't have to thread the OS / env-var arguments through. + static Path getDefaultModuleCacheDir(Path homeDir) { + return getDefaultModuleCacheDir(homeDir, false, name -> null); + } + + static Path getDefaultModuleCacheDir( + Path homeDir, boolean isWindows, Function envLookup) { + // Prefer the new XDG-style / Known Folder location, but keep using a pre-existing legacy + // `~/.pkl/cache` so that already-populated caches aren't orphaned (which would force a + // re-download). + return preferNewLocation( + getPklCacheDir(homeDir, isWindows, envLookup), homeDir.resolve(".pkl").resolve("cache")); + } + + // not stored to avoid build-time initialization by native-image + public static Path getDefaultSettingsFile() { + return getDefaultSettingsFile(getUserHomeDir(), isWindows(), System::getenv); + } + + static Path getDefaultSettingsFile(Path homeDir) { + return getDefaultSettingsFile(homeDir, false, name -> null); + } + + static Path getDefaultSettingsFile( + Path homeDir, boolean isWindows, Function envLookup) { + // Prefer the new config-dir location, falling back to legacy `~/.pkl/settings.pkl`. + return preferNewLocation( + getPklConfigDir(homeDir, isWindows, envLookup).resolve("settings.pkl"), + homeDir.resolve(".pkl").resolve("settings.pkl")); + } + + // not stored to avoid build-time initialization by native-image + public static Path getDefaultCaCertsDir() { + return getDefaultCaCertsDir(getUserHomeDir(), isWindows(), System::getenv); + } + + static Path getDefaultCaCertsDir(Path homeDir) { + return getDefaultCaCertsDir(homeDir, false, name -> null); + } + + static Path getDefaultCaCertsDir( + Path homeDir, boolean isWindows, Function envLookup) { + // Prefer the new config-dir location, falling back to legacy `~/.pkl/cacerts`. A directory with + // no certificate files counts as absent, so an empty config-dir `cacerts` doesn't shadow a + // populated legacy `~/.pkl/cacerts`. + var preferred = getPklConfigDir(homeDir, isWindows, envLookup).resolve("cacerts"); + if (containsRegularFile(preferred)) { + return preferred; + } + var legacy = homeDir.resolve(".pkl").resolve("cacerts"); + return containsRegularFile(legacy) ? legacy : preferred; + } + + // not stored to avoid build-time initialization by native-image + public static Path getDefaultReplHistoryFile() { + return getDefaultReplHistoryFile(getUserHomeDir(), isWindows(), System::getenv); + } + + static Path getDefaultReplHistoryFile(Path homeDir) { + return getDefaultReplHistoryFile(homeDir, false, name -> null); + } + + static Path getDefaultReplHistoryFile( + Path homeDir, boolean isWindows, Function envLookup) { + // REPL history is state; prefer the new state-dir location, falling back to legacy + // `~/.pkl/repl-history`. + return preferNewLocation( + getPklStateDir(homeDir, isWindows, envLookup).resolve("repl-history"), + homeDir.resolve(".pkl").resolve("repl-history")); + } + + // not stored to avoid build-time initialization by native-image + private static Path getUserHomeDir() { + return Path.of(System.getProperty("user.home")); + } + + /** + * Returns Pkl's user-scoped config directory. On Unix this is {@code ~/.config/pkl} (under {@code + * XDG_CONFIG_HOME}); on Windows this is {@code %APPDATA%/pkl} (under the roaming application data + * Known Folder). If {@code %APPDATA%} is unset or empty, falls through to the Unix layout so a + * misconfigured environment doesn't crash. + */ + private static Path getPklConfigDir( + Path homeDir, boolean isWindows, Function envLookup) { + if (isWindows) { + var appData = envLookup.apply("APPDATA"); + if (appData != null && !appData.isEmpty()) { + return Path.of(appData).resolve("pkl"); + } + } + return homeDir.resolve(".config").resolve("pkl"); + } + + /** + * Returns Pkl's user-scoped cache directory. On Unix this is {@code ~/.cache/pkl} (under {@code + * XDG_CACHE_HOME}); on Windows this is {@code %LOCALAPPDATA%/pkl/cache} (under the machine-local + * application data Known Folder, with an explicit {@code cache} category segment matching the + * standard Windows app layout). If {@code %LOCALAPPDATA%} is unset or empty, falls through to the + * Unix layout. + */ + private static Path getPklCacheDir( + Path homeDir, boolean isWindows, Function envLookup) { + if (isWindows) { + var localAppData = envLookup.apply("LOCALAPPDATA"); + if (localAppData != null && !localAppData.isEmpty()) { + return Path.of(localAppData).resolve("pkl").resolve("cache"); + } + } + return homeDir.resolve(".cache").resolve("pkl"); + } + + /** + * Returns Pkl's user-scoped state directory. On Unix this is {@code ~/.local/state/pkl} (under + * {@code XDG_STATE_HOME}); on Windows this is {@code %LOCALAPPDATA%/pkl}. If {@code + * %LOCALAPPDATA%} is unset or empty, falls through to the Unix layout. + */ + private static Path getPklStateDir( + Path homeDir, boolean isWindows, Function envLookup) { + if (isWindows) { + var localAppData = envLookup.apply("LOCALAPPDATA"); + if (localAppData != null && !localAppData.isEmpty()) { + return Path.of(localAppData).resolve("pkl"); + } + } + return homeDir.resolve(".local").resolve("state").resolve("pkl"); + } + + /** + * Returns {@code newLocation}, unless it does not exist and {@code legacyLocation} does, in which + * case {@code legacyLocation} is returned. New setups therefore use the OS-appropriate location + * (XDG-style on Unix, Known Folders on Windows) while existing setups keep working without + * migration. Package-private to allow direct testing. + */ + static Path preferNewLocation(Path newLocation, Path legacyLocation) { + if (!Files.exists(newLocation) && Files.exists(legacyLocation)) { + return legacyLocation; + } + return newLocation; + } + + /** Whether {@code dir} is a directory containing at least one regular file. */ + private static boolean containsRegularFile(Path dir) { + if (!Files.isDirectory(dir)) { + return false; + } + try (var entries = Files.list(dir)) { + return entries.anyMatch(Files::isRegularFile); + } catch (IOException e) { + return false; + } } // not stored to avoid build-time initialization by native-image diff --git a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt index a4094ac45..ce71e2746 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/settings/PklSettingsTest.kt @@ -42,7 +42,7 @@ class PklSettingsTest { .trimIndent() ) - val settings = PklSettings.loadFromPklHomeDir(tempDir) + val settings = PklSettings.loadFromSettingsDir(tempDir) assertThat(settings).isEqualTo(PklSettings(Editor.SUBLIME, null)) } @@ -80,7 +80,7 @@ class PklSettingsTest { .trimIndent() ) - val settings = PklSettings.loadFromPklHomeDir(tempDir) + val settings = PklSettings.loadFromSettingsDir(tempDir) val expectedHttp = PklEvaluatorSettings.Http( PklEvaluatorSettings.Proxy( @@ -113,7 +113,7 @@ class PklSettingsTest { .trimIndent() ) - val settings = PklSettings.loadFromPklHomeDir(tempDir) + val settings = PklSettings.loadFromSettingsDir(tempDir) val expectedHttp = PklEvaluatorSettings.Http( PklEvaluatorSettings.Proxy(URI("http://localhost:8080"), listOf()), @@ -169,7 +169,7 @@ class PklSettingsTest { @Test fun `invalid settings file`(@TempDir tempDir: Path) { val settingsFile = tempDir.resolve("settings.pkl").apply { writeString("foo = 1") } - assertThatCode { PklSettings.loadFromPklHomeDir(tempDir) } + assertThatCode { PklSettings.loadFromSettingsDir(tempDir) } .hasMessageContaining( "Expected `output.value` of module `${settingsFile.toUri()}` to be of type `pkl.settings`, but got type `settings`." ) diff --git a/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt b/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt index 8a7076c71..3dd317555 100644 --- a/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt +++ b/pkl-core/src/test/kotlin/org/pkl/core/util/IoUtilsTest.kt @@ -1,5 +1,5 @@ /* - * Copyright © 2024-2025 Apple Inc. and the Pkl project authors. All rights reserved. + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,8 @@ import java.io.FileNotFoundException import java.net.URI import java.net.URISyntaxException import java.nio.file.Path +import java.util.function.Function +import kotlin.io.path.createDirectories import kotlin.io.path.createFile import kotlin.io.path.createParentDirectories import org.assertj.core.api.Assertions.assertThat @@ -409,4 +411,193 @@ class IoUtilsTest { assertThat(IoUtils.encodePath("3a)")).isEqualTo("3a)") assertThat(IoUtils.encodePath("foo/bar/baz")).isEqualTo("foo/bar/baz") } + + @Test + fun `preferNewLocation() prefers the XDG location when it exists`(@TempDir tempDir: Path) { + val xdg = tempDir.resolve("xdg").createDirectories() + val legacy = tempDir.resolve("legacy").createDirectories() + assertThat(IoUtils.preferNewLocation(xdg, legacy)).isEqualTo(xdg) + } + + @Test + fun `preferNewLocation() falls back to legacy when only the legacy location exists`( + @TempDir tempDir: Path + ) { + val xdg = tempDir.resolve("xdg") + val legacy = tempDir.resolve("legacy").createDirectories() + assertThat(IoUtils.preferNewLocation(xdg, legacy)).isEqualTo(legacy) + } + + @Test + fun `preferNewLocation() prefers the XDG location when neither exists`(@TempDir tempDir: Path) { + val xdg = tempDir.resolve("xdg") + val legacy = tempDir.resolve("legacy") + assertThat(IoUtils.preferNewLocation(xdg, legacy)).isEqualTo(xdg) + } + + @Test + fun `preferNewLocation() prefers the XDG location when only it exists`(@TempDir tempDir: Path) { + val xdg = tempDir.resolve("xdg").createDirectories() + val legacy = tempDir.resolve("legacy") + assertThat(IoUtils.preferNewLocation(xdg, legacy)).isEqualTo(xdg) + } + + @Test + fun `getDefaultModuleCacheDir() prefers XDG and falls back to legacy`(@TempDir home: Path) { + assertThat(IoUtils.getDefaultModuleCacheDir(home)) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + home.resolve(".pkl").resolve("cache").createDirectories() + assertThat(IoUtils.getDefaultModuleCacheDir(home)) + .isEqualTo(home.resolve(".pkl").resolve("cache")) + } + + @Test + fun `getDefaultSettingsFile() prefers XDG and falls back to legacy`(@TempDir home: Path) { + assertThat(IoUtils.getDefaultSettingsFile(home)) + .isEqualTo(home.resolve(".config").resolve("pkl").resolve("settings.pkl")) + home.resolve(".pkl").resolve("settings.pkl").createParentDirectories().createFile() + assertThat(IoUtils.getDefaultSettingsFile(home)) + .isEqualTo(home.resolve(".pkl").resolve("settings.pkl")) + } + + @Test + fun `getDefaultReplHistoryFile() prefers XDG state dir and falls back to legacy`( + @TempDir home: Path + ) { + assertThat(IoUtils.getDefaultReplHistoryFile(home)) + .isEqualTo(home.resolve(".local").resolve("state").resolve("pkl").resolve("repl-history")) + home.resolve(".pkl").resolve("repl-history").createParentDirectories().createFile() + assertThat(IoUtils.getDefaultReplHistoryFile(home)) + .isEqualTo(home.resolve(".pkl").resolve("repl-history")) + } + + @Test + fun `getDefaultCaCertsDir() prefers XDG when nothing exists`(@TempDir home: Path) { + assertThat(IoUtils.getDefaultCaCertsDir(home)) + .isEqualTo(home.resolve(".config").resolve("pkl").resolve("cacerts")) + } + + @Test + fun `getDefaultCaCertsDir() does not let an empty XDG dir shadow a populated legacy dir`( + @TempDir home: Path + ) { + val legacy = home.resolve(".pkl").resolve("cacerts").createDirectories() + legacy.resolve("ca.pem").createFile() + // an empty XDG cacerts dir must not shadow the populated legacy dir + val xdg = home.resolve(".config").resolve("pkl").resolve("cacerts").createDirectories() + assertThat(IoUtils.getDefaultCaCertsDir(home)).isEqualTo(legacy) + // once the XDG dir holds a cert, it wins + xdg.resolve("ca.pem").createFile() + assertThat(IoUtils.getDefaultCaCertsDir(home)).isEqualTo(xdg) + } + + // ---- Windows Known Folder behaviour --------------------------------------- + // + // The `(homeDir, isWindows, envLookup)` overloads let us exercise the Windows code path on a Unix + // CI box. The env lookup is a `Function` so tests can inject `LOCALAPPDATA` / + // `APPDATA` without touching the JVM environment (which the JDK doesn't allow on Unix). + + private fun env(vararg entries: kotlin.Pair): Function { + val map: Map = entries.toMap() + return Function { name -> map[name] } + } + + @Test + fun `getDefaultModuleCacheDir() on Windows uses LOCALAPPDATA when set`(@TempDir home: Path) { + val localAppData = home.resolve("LocalAppData").createDirectories() + assertThat( + IoUtils.getDefaultModuleCacheDir(home, true, env("LOCALAPPDATA" to localAppData.toString())) + ) + .isEqualTo(localAppData.resolve("pkl").resolve("cache")) + } + + @Test + fun `getDefaultModuleCacheDir() on Windows falls back to Unix layout when LOCALAPPDATA is unset`( + @TempDir home: Path + ) { + assertThat(IoUtils.getDefaultModuleCacheDir(home, true, env())) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + } + + @Test + fun `getDefaultModuleCacheDir() on Windows treats empty LOCALAPPDATA like unset`( + @TempDir home: Path + ) { + assertThat(IoUtils.getDefaultModuleCacheDir(home, true, env("LOCALAPPDATA" to ""))) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + } + + @Test + fun `getDefaultModuleCacheDir() on Windows still falls back to legacy ~_pkl_cache`( + @TempDir home: Path + ) { + val localAppData = home.resolve("LocalAppData").createDirectories() + home.resolve(".pkl").resolve("cache").createDirectories() + assertThat( + IoUtils.getDefaultModuleCacheDir(home, true, env("LOCALAPPDATA" to localAppData.toString())) + ) + .isEqualTo(home.resolve(".pkl").resolve("cache")) + } + + @Test + fun `getDefaultSettingsFile() on Windows uses APPDATA when set`(@TempDir home: Path) { + val appData = home.resolve("AppData").createDirectories() + assertThat(IoUtils.getDefaultSettingsFile(home, true, env("APPDATA" to appData.toString()))) + .isEqualTo(appData.resolve("pkl").resolve("settings.pkl")) + } + + @Test + fun `getDefaultSettingsFile() on Windows falls back to Unix layout when APPDATA is unset`( + @TempDir home: Path + ) { + assertThat(IoUtils.getDefaultSettingsFile(home, true, env())) + .isEqualTo(home.resolve(".config").resolve("pkl").resolve("settings.pkl")) + } + + @Test + fun `getDefaultSettingsFile() on Windows treats empty APPDATA like unset`(@TempDir home: Path) { + assertThat(IoUtils.getDefaultSettingsFile(home, true, env("APPDATA" to ""))) + .isEqualTo(home.resolve(".config").resolve("pkl").resolve("settings.pkl")) + } + + @Test + fun `getDefaultCaCertsDir() on Windows uses APPDATA when set`(@TempDir home: Path) { + val appData = home.resolve("AppData").createDirectories() + assertThat(IoUtils.getDefaultCaCertsDir(home, true, env("APPDATA" to appData.toString()))) + .isEqualTo(appData.resolve("pkl").resolve("cacerts")) + } + + @Test + fun `getDefaultCaCertsDir() on Windows preserves the empty-dir shadow rule`(@TempDir home: Path) { + val appData = home.resolve("AppData").createDirectories() + val legacy = home.resolve(".pkl").resolve("cacerts").createDirectories() + legacy.resolve("ca.pem").createFile() + val preferred = appData.resolve("pkl").resolve("cacerts").createDirectories() + assertThat(IoUtils.getDefaultCaCertsDir(home, true, env("APPDATA" to appData.toString()))) + .isEqualTo(legacy) + preferred.resolve("ca.pem").createFile() + assertThat(IoUtils.getDefaultCaCertsDir(home, true, env("APPDATA" to appData.toString()))) + .isEqualTo(preferred) + } + + @Test + fun `getDefaultReplHistoryFile() on Windows uses LOCALAPPDATA when set`(@TempDir home: Path) { + val localAppData = home.resolve("LocalAppData").createDirectories() + assertThat( + IoUtils.getDefaultReplHistoryFile( + home, + true, + env("LOCALAPPDATA" to localAppData.toString()), + ) + ) + .isEqualTo(localAppData.resolve("pkl").resolve("repl-history")) + } + + @Test + fun `getDefaultReplHistoryFile() on Windows falls back to Unix layout when LOCALAPPDATA is unset`( + @TempDir home: Path + ) { + assertThat(IoUtils.getDefaultReplHistoryFile(home, true, env())) + .isEqualTo(home.resolve(".local").resolve("state").resolve("pkl").resolve("repl-history")) + } } diff --git a/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java b/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java index 0f534dd00..609173048 100644 --- a/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java +++ b/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java @@ -16,11 +16,14 @@ package org.pkl.executor; import java.net.URI; +import java.nio.file.Files; import java.nio.file.Path; import java.time.Duration; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.Objects; +import java.util.function.Function; import org.jspecify.annotations.Nullable; import org.pkl.executor.spi.v1.ExecutorSpiOptions; import org.pkl.executor.spi.v1.ExecutorSpiOptions2; @@ -67,7 +70,37 @@ public final class ExecutorOptions { /** Returns the module cache dir that the CLI uses by default. */ public static Path defaultModuleCacheDir() { - return Path.of(System.getProperty("user.home"), ".pkl", "cache"); + return defaultModuleCacheDir( + Path.of(System.getProperty("user.home")), isWindowsOs(), System::getenv); + } + + // Package-private; injectable so tests can exercise the Windows code path on a Unix CI box. + static Path defaultModuleCacheDir( + Path home, boolean isWindows, Function envLookup) { + // Keep in sync with org.pkl.core.util.IoUtils.getDefaultModuleCacheDir (pkl-executor cannot + // depend on pkl-core). On Unix prefer the XDG-style `~/.cache/pkl`; on Windows prefer + // `%LOCALAPPDATA%/pkl/cache`. Keep using a pre-existing legacy `~/.pkl/cache` so that + // already-populated caches aren't orphaned. + Path preferred = null; + if (isWindows) { + var localAppData = envLookup.apply("LOCALAPPDATA"); + if (localAppData != null && !localAppData.isEmpty()) { + preferred = Path.of(localAppData, "pkl", "cache"); + } + } + if (preferred == null) { + preferred = home.resolve(".cache").resolve("pkl"); + } + var legacyLocation = home.resolve(".pkl").resolve("cache"); + if (!Files.exists(preferred) && Files.exists(legacyLocation)) { + return legacyLocation; + } + return preferred; + } + + private static boolean isWindowsOs() { + var osName = System.getProperty("os.name"); + return osName != null && osName.toLowerCase(Locale.ROOT).contains("windows"); } public static Builder builder() { diff --git a/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt b/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt new file mode 100644 index 000000000..ecc777cca --- /dev/null +++ b/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt @@ -0,0 +1,99 @@ +/* + * Copyright © 2024-2026 Apple Inc. and the Pkl project authors. All rights reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.pkl.executor + +import java.nio.file.Path +import java.util.function.Function +import kotlin.io.path.createDirectories +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.pkl.core.util.IoUtils + +class ExecutorOptionsTest { + // `ExecutorOptions.defaultModuleCacheDir()` inlines the XDG/legacy fallback because pkl-executor + // cannot depend on pkl-core. This guards against drift from `IoUtils.getDefaultModuleCacheDir()`. + @Test + fun `defaultModuleCacheDir stays in sync with pkl-core across home states`(@TempDir home: Path) { + val original = System.getProperty("user.home") + try { + System.setProperty("user.home", home.toString()) + + // fresh home -> XDG location + assertThat(ExecutorOptions.defaultModuleCacheDir()) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + assertThat(ExecutorOptions.defaultModuleCacheDir()) + .isEqualTo(IoUtils.getDefaultModuleCacheDir()) + + // pre-existing legacy cache -> legacy location + home.resolve(".pkl").resolve("cache").createDirectories() + assertThat(ExecutorOptions.defaultModuleCacheDir()) + .isEqualTo(home.resolve(".pkl").resolve("cache")) + assertThat(ExecutorOptions.defaultModuleCacheDir()) + .isEqualTo(IoUtils.getDefaultModuleCacheDir()) + } finally { + System.setProperty("user.home", original) + } + } + + private fun env(vararg entries: Pair): Function { + val map = entries.toMap() + return Function { name -> map[name] } + } + + @Test + fun `defaultModuleCacheDir on Windows uses LOCALAPPDATA when set`(@TempDir home: Path) { + val localAppData = home.resolve("LocalAppData").createDirectories() + assertThat( + ExecutorOptions.defaultModuleCacheDir( + home, + true, + env("LOCALAPPDATA" to localAppData.toString()), + ) + ) + .isEqualTo(localAppData.resolve("pkl").resolve("cache")) + } + + @Test + fun `defaultModuleCacheDir on Windows falls back to Unix layout when LOCALAPPDATA is unset`( + @TempDir home: Path + ) { + assertThat(ExecutorOptions.defaultModuleCacheDir(home, true, env())) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + } + + @Test + fun `defaultModuleCacheDir on Windows treats empty LOCALAPPDATA like unset`(@TempDir home: Path) { + assertThat(ExecutorOptions.defaultModuleCacheDir(home, true, env("LOCALAPPDATA" to ""))) + .isEqualTo(home.resolve(".cache").resolve("pkl")) + } + + @Test + fun `defaultModuleCacheDir on Windows still falls back to legacy ~_pkl_cache`( + @TempDir home: Path + ) { + val localAppData = home.resolve("LocalAppData").createDirectories() + home.resolve(".pkl").resolve("cache").createDirectories() + assertThat( + ExecutorOptions.defaultModuleCacheDir( + home, + true, + env("LOCALAPPDATA" to localAppData.toString()), + ) + ) + .isEqualTo(home.resolve(".pkl").resolve("cache")) + } +} diff --git a/stdlib/settings.pkl b/stdlib/settings.pkl index 553f3e2b5..448320e22 100644 --- a/stdlib/settings.pkl +++ b/stdlib/settings.pkl @@ -18,7 +18,7 @@ /// /// Every settings file must amend this module. /// Unless CLI commands and build tool plugins are explicitly configured with a settings file, -/// they will use `~/.pkl/settings.pkl` or the defaults specified in this module. +/// they will use `~/.config/pkl/settings.pkl` (falling back to the legacy `~/.pkl/settings.pkl`) or the defaults specified in this module. @ModuleInfo { minPklVersion = "0.32.0" } module pkl.settings