From da94887db8f4a9c382704354caf3a93b02fa99d9 Mon Sep 17 00:00:00 2001 From: Florin Ungur Date: Tue, 9 Jun 2026 22:12:00 +0100 Subject: [PATCH 1/7] Default to XDG-style cache and settings locations The CLI placed its package cache in ~/.pkl/cache and read its settings file from ~/.pkl/settings.pkl. Default these to the XDG-style locations ~/.cache/pkl and ~/.config/pkl/settings.pkl instead, so a default setup no longer writes to $HOME/.pkl. These are fixed defaults; no environment variables (XDG_CACHE_HOME, XDG_CONFIG_HOME) are read, consistent with Pkl not configuring itself from the environment. Existing setups keep working without migration: a pre-existing ~/.pkl/cache is still used so packages aren't re-downloaded, and ~/.pkl/settings.pkl is still read when ~/.config/pkl/settings.pkl is absent. --- docs/modules/pkl-cli/pages/index.adoc | 4 +-- .../pkl-cli/partials/cli-common-options.adoc | 4 +-- .../partials/gradle-common-properties.adoc | 2 +- .../partials/gradle-modules-properties.adoc | 2 +- docs/modules/release-notes/pages/0.32.adoc | 9 +++++++ .../release-notes/pages/changelog.adoc | 5 ++++ .../org/pkl/commons/cli/CliBaseOptions.kt | 5 ++-- .../org/pkl/core/settings/PklSettings.java | 12 ++++++--- .../main/java/org/pkl/core/util/IoUtils.java | 27 ++++++++++++++++++- .../kotlin/org/pkl/core/util/IoUtilsTest.kt | 26 +++++++++++++++++- .../org/pkl/executor/ExecutorOptions.java | 12 ++++++++- 11 files changed, 93 insertions(+), 15 deletions(-) diff --git a/docs/modules/pkl-cli/pages/index.adoc b/docs/modules/pkl-cli/pages/index.adoc index 6161614bc..6b1900891 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`, 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> diff --git a/docs/modules/pkl-cli/partials/cli-common-options.adoc b/docs/modules/pkl-cli/partials/cli-common-options.adoc index 13d94bcd0..44b7ccb7f 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` (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` (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..043f99b30 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` (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..34554f8a6 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` (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..0d02d3d30 100644 --- a/docs/modules/release-notes/pages/0.32.adoc +++ b/docs/modules/release-notes/pages/0.32.adoc @@ -37,6 +37,15 @@ Ready when you need them. * New property: link:{uri-stdlib-projectModule}#resolvedEvaluatorSettings[`Project.resolvedEvaluatorSettings`] +=== CLI Changes + +==== Default cache and settings locations + +The CLI now stores its package cache in `~/.cache/pkl` and reads its settings file from `~/.config/pkl/settings.pkl` by default, instead of placing both under `~/.pkl`. +These are fixed defaults; no environment variables (such as `XDG_CACHE_HOME` or `XDG_CONFIG_HOME`) are read. + +Existing setups keep working without migration: a pre-existing `~/.pkl/cache` is still used (so packages aren't re-downloaded), and `~/.pkl/settings.pkl` is still read when `~/.config/pkl/settings.pkl` is absent. + == Breaking Changes [small]#💔# Things to watch out for when upgrading. diff --git a/docs/modules/release-notes/pages/changelog.adoc b/docs/modules/release-notes/pages/changelog.adoc index 2254e9c03..c56eefe4a 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 module cache and settings file to XDG-style locations (`~/.cache/pkl` and `~/.config/pkl/settings.pkl`) instead of `~/.pkl`. +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-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..296e34407 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` (or the legacy + * `~/.pkl/settings.pkl`) if present, or the defaults specified in the `pkl:settings` standard + * library module are used. */ private val settings: URI? = null, 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..084469fb6 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,20 @@ 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 loadFromPklHomeDir() throws VmEvalException { - return loadFromPklHomeDir(IoUtils.getPklHomeDir()); + return loadFromSettingsFile(IoUtils.getDefaultSettingsFile()); } /** For testing only. */ static PklSettings loadFromPklHomeDir(Path pklHomeDir) throws VmEvalException { - var path = pklHomeDir.resolve("settings.pkl"); + return loadFromSettingsFile(pklHomeDir.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..db95ddf47 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 @@ -234,7 +234,32 @@ public static Path getPklHomeDir() { // not stored to avoid build-time initialization by native-image public static Path getDefaultModuleCacheDir() { - return getPklHomeDir().resolve("cache"); + // Prefer the XDG-style `~/.cache/pkl`, but keep using a pre-existing legacy `~/.pkl/cache` so + // that already-populated caches aren't orphaned (which would force a re-download). + return preferXdgLocation( + Path.of(System.getProperty("user.home"), ".cache", "pkl"), + getPklHomeDir().resolve("cache")); + } + + // not stored to avoid build-time initialization by native-image + public static Path getDefaultSettingsFile() { + // Prefer the XDG-style `~/.config/pkl/settings.pkl`, falling back to legacy + // `~/.pkl/settings.pkl`. + return preferXdgLocation( + Path.of(System.getProperty("user.home"), ".config", "pkl", "settings.pkl"), + getPklHomeDir().resolve("settings.pkl")); + } + + /** + * Returns {@code xdgLocation}, unless it does not exist and the legacy {@code ~/.pkl} location + * does, in which case {@code legacyLocation} is returned. New setups therefore use the XDG-style + * location while existing {@code ~/.pkl} setups keep working without migration. + */ + static Path preferXdgLocation(Path xdgLocation, Path legacyLocation) { + if (!Files.exists(xdgLocation) && Files.exists(legacyLocation)) { + return legacyLocation; + } + return xdgLocation; } // not stored to avoid build-time initialization by native-image 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..697fdf0e6 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,7 @@ import java.io.FileNotFoundException import java.net.URI import java.net.URISyntaxException import java.nio.file.Path +import kotlin.io.path.createDirectories import kotlin.io.path.createFile import kotlin.io.path.createParentDirectories import org.assertj.core.api.Assertions.assertThat @@ -409,4 +410,27 @@ class IoUtilsTest { assertThat(IoUtils.encodePath("3a)")).isEqualTo("3a)") assertThat(IoUtils.encodePath("foo/bar/baz")).isEqualTo("foo/bar/baz") } + + @Test + fun `preferXdgLocation() prefers the XDG location when it exists`(@TempDir tempDir: Path) { + val xdg = tempDir.resolve("xdg").createDirectories() + val legacy = tempDir.resolve("legacy").createDirectories() + assertThat(IoUtils.preferXdgLocation(xdg, legacy)).isEqualTo(xdg) + } + + @Test + fun `preferXdgLocation() 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.preferXdgLocation(xdg, legacy)).isEqualTo(legacy) + } + + @Test + fun `preferXdgLocation() prefers the XDG location when neither exists`(@TempDir tempDir: Path) { + val xdg = tempDir.resolve("xdg") + val legacy = tempDir.resolve("legacy") + assertThat(IoUtils.preferXdgLocation(xdg, legacy)).isEqualTo(xdg) + } } 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..5578934f9 100644 --- a/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java +++ b/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java @@ -16,6 +16,7 @@ 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; @@ -67,7 +68,16 @@ 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"); + // Keep in sync with org.pkl.core.util.IoUtils.getDefaultModuleCacheDir (pkl-executor cannot + // depend on pkl-core). Prefer the XDG-style `~/.cache/pkl`, but keep using a pre-existing + // legacy `~/.pkl/cache` so that already-populated caches aren't orphaned. + var home = System.getProperty("user.home"); + var xdgLocation = Path.of(home, ".cache", "pkl"); + var legacyLocation = Path.of(home, ".pkl", "cache"); + if (!Files.exists(xdgLocation) && Files.exists(legacyLocation)) { + return legacyLocation; + } + return xdgLocation; } public static Builder builder() { From 4aa4ec5976d0c8b6e8c7cb7d326602e716134a93 Mon Sep 17 00:00:00 2001 From: Florin Ungur Date: Tue, 9 Jun 2026 22:12:01 +0100 Subject: [PATCH 2/7] Default CA certs and REPL history to XDG-style locations Extend the XDG-style defaults to the two remaining ~/.pkl users: the CA certificates directory now defaults to ~/.config/pkl/cacerts, and the REPL history to ~/.local/state/pkl/repl-history (XDG state home, where history files belong). Both fall back to their legacy ~/.pkl locations when those exist, so a default setup writes nothing under ~/.pkl. The one-time JLine2 ~/.pkl/repl-history.bin cleanup is left pointing at the legacy path, since that file only ever existed there. --- docs/modules/pkl-cli/pages/index.adoc | 4 ++-- docs/modules/release-notes/pages/0.32.adoc | 14 ++++++++++---- docs/modules/release-notes/pages/changelog.adoc | 2 +- .../src/main/kotlin/org/pkl/cli/repl/Repl.kt | 2 +- .../org/pkl/commons/cli/CliBaseOptions.kt | 5 +++-- .../kotlin/org/pkl/commons/cli/CliCommand.kt | 2 +- .../main/java/org/pkl/core/util/IoUtils.java | 17 +++++++++++++++++ 7 files changed, 35 insertions(+), 11 deletions(-) diff --git a/docs/modules/pkl-cli/pages/index.adoc b/docs/modules/pkl-cli/pages/index.adoc index 6b1900891..d1c94004a 100644 --- a/docs/modules/pkl-cli/pages/index.adoc +++ b/docs/modules/pkl-cli/pages/index.adoc @@ -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/` (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 `~/.config/pkl/cacerts/`. + Certificates need to be X.509 certificates in PEM format. [[http-proxy]] diff --git a/docs/modules/release-notes/pages/0.32.adoc b/docs/modules/release-notes/pages/0.32.adoc index 0d02d3d30..f9eab77c3 100644 --- a/docs/modules/release-notes/pages/0.32.adoc +++ b/docs/modules/release-notes/pages/0.32.adoc @@ -39,12 +39,18 @@ Ready when you need them. === CLI Changes -==== Default cache and settings locations +==== Default file locations -The CLI now stores its package cache in `~/.cache/pkl` and reads its settings file from `~/.config/pkl/settings.pkl` by default, instead of placing both under `~/.pkl`. -These are fixed defaults; no environment variables (such as `XDG_CACHE_HOME` or `XDG_CONFIG_HOME`) are read. +By default, the CLI no longer stores anything under `~/.pkl`. It now uses XDG-style locations: -Existing setups keep working without migration: a pre-existing `~/.pkl/cache` is still used (so packages aren't re-downloaded), and `~/.pkl/settings.pkl` is still read when `~/.config/pkl/settings.pkl` is absent. +* Package cache: `~/.cache/pkl` (was `~/.pkl/cache`) +* Settings file: `~/.config/pkl/settings.pkl` (was `~/.pkl/settings.pkl`) +* CA certificates: `~/.config/pkl/cacerts` (was `~/.pkl/cacerts`) +* REPL history: `~/.local/state/pkl/repl-history` (was `~/.pkl/repl-history`) + +These are fixed defaults; no environment variables (such as `XDG_CACHE_HOME`, `XDG_CONFIG_HOME`, or `XDG_STATE_HOME`) are read. + +Existing setups keep working without migration: each legacy `~/.pkl` location is still used when it already exists. In particular, a pre-existing `~/.pkl/cache` is still used, so packages aren't re-downloaded. == Breaking Changes [small]#💔# diff --git a/docs/modules/release-notes/pages/changelog.adoc b/docs/modules/release-notes/pages/changelog.adoc index c56eefe4a..cb389cd05 100644 --- a/docs/modules/release-notes/pages/changelog.adoc +++ b/docs/modules/release-notes/pages/changelog.adoc @@ -6,7 +6,7 @@ include::ROOT:partial$component-attributes.adoc[] === Changes -* Default the module cache and settings file to XDG-style locations (`~/.cache/pkl` and `~/.config/pkl/settings.pkl`) instead of `~/.pkl`. +* 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`. The legacy `~/.pkl` locations are still used when they already exist, so existing setups keep working without migration. [[release-0.31.1]] 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 296e34407..ca05e97fa 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 @@ -131,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..22991108f 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 @@ -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/util/IoUtils.java b/pkl-core/src/main/java/org/pkl/core/util/IoUtils.java index db95ddf47..c167c1a3b 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 @@ -250,6 +250,23 @@ public static Path getDefaultSettingsFile() { getPklHomeDir().resolve("settings.pkl")); } + // not stored to avoid build-time initialization by native-image + public static Path getDefaultCaCertsDir() { + // Prefer the XDG-style `~/.config/pkl/cacerts`, falling back to legacy `~/.pkl/cacerts`. + return preferXdgLocation( + Path.of(System.getProperty("user.home"), ".config", "pkl", "cacerts"), + getPklHomeDir().resolve("cacerts")); + } + + // not stored to avoid build-time initialization by native-image + public static Path getDefaultReplHistoryFile() { + // REPL history is state, so prefer the XDG state dir `~/.local/state/pkl/repl-history`, falling + // back to legacy `~/.pkl/repl-history`. + return preferXdgLocation( + Path.of(System.getProperty("user.home"), ".local", "state", "pkl", "repl-history"), + getPklHomeDir().resolve("repl-history")); + } + /** * Returns {@code xdgLocation}, unless it does not exist and the legacy {@code ~/.pkl} location * does, in which case {@code legacyLocation} is returned. New setups therefore use the XDG-style From 2791c00c50702f02bc5a10e4b9edbdfb066bbdfa Mon Sep 17 00:00:00 2001 From: Florin Ungur Date: Tue, 9 Jun 2026 22:12:01 +0100 Subject: [PATCH 3/7] Rename PklSettings.loadFromPklHomeDir; fix doc references Rename the public PklSettings.loadFromPklHomeDir() to loadFromDefaultLocation() (its behavior now prefers ~/.config/pkl), with a deprecated-for-removal shim delegating from the old name, and record the deprecation in the release notes. Also fix a stale ~/.pkl/settings.pkl reference in the language tutorial, and note in the release notes that Windows uses the XDG-style paths literally rather than mapping to Known Folder locations. --- .../pages/02_filling_out_a_template.adoc | 2 +- docs/modules/release-notes/pages/0.32.adoc | 2 ++ .../main/kotlin/org/pkl/commons/cli/CliCommand.kt | 2 +- .../java/org/pkl/core/settings/PklSettings.java | 13 ++++++++++++- 4 files changed, 16 insertions(+), 3 deletions(-) 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/release-notes/pages/0.32.adoc b/docs/modules/release-notes/pages/0.32.adoc index f9eab77c3..76372a996 100644 --- a/docs/modules/release-notes/pages/0.32.adoc +++ b/docs/modules/release-notes/pages/0.32.adoc @@ -49,6 +49,7 @@ By default, the CLI no longer stores anything under `~/.pkl`. It now uses XDG-st * REPL history: `~/.local/state/pkl/repl-history` (was `~/.pkl/repl-history`) These are fixed defaults; no environment variables (such as `XDG_CACHE_HOME`, `XDG_CONFIG_HOME`, or `XDG_STATE_HOME`) are read. +On Windows, these paths are used literally under the user home directory (`~/.cache/pkl`, etc.); they are not mapped to Known Folder locations. Existing setups keep working without migration: each legacy `~/.pkl` location is still used when it already exists. In particular, a pre-existing `~/.pkl/cache` is still used, so packages aren't re-downloaded. @@ -69,6 +70,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`. ==== Changes 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 22991108f..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` 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 084469fb6..f22eb8bc7 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 @@ -46,10 +46,21 @@ public record PklSettings(Editor editor, PklEvaluatorSettings.@Nullable Http htt * the legacy {@literal ~/.pkl/settings.pkl}. If neither file exists, returns default settings * defined by module {@literal pkl.settings}. */ - public static PklSettings loadFromPklHomeDir() throws VmEvalException { + public static PklSettings loadFromDefaultLocation() throws VmEvalException { return loadFromSettingsFile(IoUtils.getDefaultSettingsFile()); } + /** + * Loads the user settings file. + * + * @deprecated Renamed to {@link #loadFromDefaultLocation()}, which now prefers {@literal + * ~/.config/pkl/settings.pkl} over the legacy {@literal ~/.pkl/settings.pkl}. + */ + @Deprecated(forRemoval = true) + public static PklSettings loadFromPklHomeDir() throws VmEvalException { + return loadFromDefaultLocation(); + } + /** For testing only. */ static PklSettings loadFromPklHomeDir(Path pklHomeDir) throws VmEvalException { return loadFromSettingsFile(pklHomeDir.resolve("settings.pkl")); From 3721124d7f5055ace290ce90f454f9446b931234 Mon Sep 17 00:00:00 2001 From: Florin Ungur Date: Tue, 9 Jun 2026 22:12:01 +0100 Subject: [PATCH 4/7] Harden XDG default-location tests and cacerts fallback Add path-value tests for the four default-location helpers (via new package-private home-dir overloads) and a pkl-executor test pinning ExecutorOptions.defaultModuleCacheDir() to IoUtils, closing the gap where a wrong path segment would have passed CI. Fix the CA-certs fallback so an empty ~/.config/pkl/cacerts no longer shadows a populated legacy ~/.pkl/cacerts (a directory with no cert files now counts as absent). Update the remaining stale ~/.pkl references in stdlib settings.pkl and ModuleCache, scope the "nothing under ~/.pkl" release note to new setups and qualify the no-re-download claim, add a deprecation `since`, and rename the test-only settings loader. --- docs/modules/release-notes/pages/0.32.adoc | 4 +- .../org/pkl/commons/cli/CliBaseOptions.kt | 6 +- .../org/pkl/core/runtime/ModuleCache.java | 2 +- .../org/pkl/core/settings/PklSettings.java | 10 +-- .../main/java/org/pkl/core/util/IoUtils.java | 63 +++++++++++++++---- .../org/pkl/core/settings/PklSettingsTest.kt | 8 +-- .../kotlin/org/pkl/core/util/IoUtilsTest.kt | 56 +++++++++++++++++ .../org/pkl/executor/ExecutorOptionsTest.kt | 50 +++++++++++++++ stdlib/settings.pkl | 2 +- 9 files changed, 172 insertions(+), 29 deletions(-) create mode 100644 pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt diff --git a/docs/modules/release-notes/pages/0.32.adoc b/docs/modules/release-notes/pages/0.32.adoc index 76372a996..86506c78a 100644 --- a/docs/modules/release-notes/pages/0.32.adoc +++ b/docs/modules/release-notes/pages/0.32.adoc @@ -41,7 +41,7 @@ Ready when you need them. ==== Default file locations -By default, the CLI no longer stores anything under `~/.pkl`. It now uses XDG-style locations: +For new setups, the CLI no longer stores anything under `~/.pkl`. It uses XDG-style locations: * Package cache: `~/.cache/pkl` (was `~/.pkl/cache`) * Settings file: `~/.config/pkl/settings.pkl` (was `~/.pkl/settings.pkl`) @@ -51,7 +51,7 @@ By default, the CLI no longer stores anything under `~/.pkl`. It now uses XDG-st These are fixed defaults; no environment variables (such as `XDG_CACHE_HOME`, `XDG_CONFIG_HOME`, or `XDG_STATE_HOME`) are read. On Windows, these paths are used literally under the user home directory (`~/.cache/pkl`, etc.); they are not mapped to Known Folder locations. -Existing setups keep working without migration: each legacy `~/.pkl` location is still used when it already exists. In particular, a pre-existing `~/.pkl/cache` is still used, so packages aren't re-downloaded. +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 `~/.cache/pkl` does not exist, so packages aren't re-downloaded. == Breaking Changes [small]#💔# 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 ca05e97fa..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,9 +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`, `~/.config/pkl/settings.pkl` (or the legacy - * `~/.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, 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 f22eb8bc7..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 @@ -53,17 +53,17 @@ public static PklSettings loadFromDefaultLocation() throws VmEvalException { /** * Loads the user settings file. * - * @deprecated Renamed to {@link #loadFromDefaultLocation()}, which now prefers {@literal - * ~/.config/pkl/settings.pkl} over the legacy {@literal ~/.pkl/settings.pkl}. + * @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(forRemoval = true) + @Deprecated(since = "0.32.0", forRemoval = true) public static PklSettings loadFromPklHomeDir() throws VmEvalException { return loadFromDefaultLocation(); } /** For testing only. */ - static PklSettings loadFromPklHomeDir(Path pklHomeDir) throws VmEvalException { - return loadFromSettingsFile(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 { 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 c167c1a3b..69747fce0 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 @@ -234,43 +234,68 @@ public static Path getPklHomeDir() { // not stored to avoid build-time initialization by native-image public static Path getDefaultModuleCacheDir() { + return getDefaultModuleCacheDir(getUserHomeDir()); + } + + static Path getDefaultModuleCacheDir(Path homeDir) { // Prefer the XDG-style `~/.cache/pkl`, but keep using a pre-existing legacy `~/.pkl/cache` so // that already-populated caches aren't orphaned (which would force a re-download). return preferXdgLocation( - Path.of(System.getProperty("user.home"), ".cache", "pkl"), - getPklHomeDir().resolve("cache")); + homeDir.resolve(".cache").resolve("pkl"), homeDir.resolve(".pkl").resolve("cache")); } // not stored to avoid build-time initialization by native-image public static Path getDefaultSettingsFile() { + return getDefaultSettingsFile(getUserHomeDir()); + } + + static Path getDefaultSettingsFile(Path homeDir) { // Prefer the XDG-style `~/.config/pkl/settings.pkl`, falling back to legacy // `~/.pkl/settings.pkl`. return preferXdgLocation( - Path.of(System.getProperty("user.home"), ".config", "pkl", "settings.pkl"), - getPklHomeDir().resolve("settings.pkl")); + homeDir.resolve(".config").resolve("pkl").resolve("settings.pkl"), + homeDir.resolve(".pkl").resolve("settings.pkl")); } // not stored to avoid build-time initialization by native-image public static Path getDefaultCaCertsDir() { - // Prefer the XDG-style `~/.config/pkl/cacerts`, falling back to legacy `~/.pkl/cacerts`. - return preferXdgLocation( - Path.of(System.getProperty("user.home"), ".config", "pkl", "cacerts"), - getPklHomeDir().resolve("cacerts")); + return getDefaultCaCertsDir(getUserHomeDir()); + } + + static Path getDefaultCaCertsDir(Path homeDir) { + // Prefer the XDG-style `~/.config/pkl/cacerts`, falling back to legacy `~/.pkl/cacerts`. A + // directory with no certificate files counts as absent, so an empty `~/.config/pkl/cacerts` + // doesn't shadow a populated legacy `~/.pkl/cacerts`. + var xdg = homeDir.resolve(".config").resolve("pkl").resolve("cacerts"); + if (containsRegularFile(xdg)) { + return xdg; + } + var legacy = homeDir.resolve(".pkl").resolve("cacerts"); + return containsRegularFile(legacy) ? legacy : xdg; } // not stored to avoid build-time initialization by native-image public static Path getDefaultReplHistoryFile() { + return getDefaultReplHistoryFile(getUserHomeDir()); + } + + static Path getDefaultReplHistoryFile(Path homeDir) { // REPL history is state, so prefer the XDG state dir `~/.local/state/pkl/repl-history`, falling // back to legacy `~/.pkl/repl-history`. return preferXdgLocation( - Path.of(System.getProperty("user.home"), ".local", "state", "pkl", "repl-history"), - getPklHomeDir().resolve("repl-history")); + homeDir.resolve(".local").resolve("state").resolve("pkl").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 {@code xdgLocation}, unless it does not exist and the legacy {@code ~/.pkl} location - * does, in which case {@code legacyLocation} is returned. New setups therefore use the XDG-style - * location while existing {@code ~/.pkl} setups keep working without migration. + * Returns {@code xdgLocation}, unless it does not exist and {@code legacyLocation} does, in which + * case {@code legacyLocation} is returned. New setups therefore use the XDG-style location while + * existing setups keep working without migration. Package-private to allow direct testing. */ static Path preferXdgLocation(Path xdgLocation, Path legacyLocation) { if (!Files.exists(xdgLocation) && Files.exists(legacyLocation)) { @@ -279,6 +304,18 @@ static Path preferXdgLocation(Path xdgLocation, Path legacyLocation) { return xdgLocation; } + /** 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 @SuppressWarnings("SystemGetProperty") public static String getLineSeparator() { 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 697fdf0e6..5cfd348db 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 @@ -433,4 +433,60 @@ class IoUtilsTest { val legacy = tempDir.resolve("legacy") assertThat(IoUtils.preferXdgLocation(xdg, legacy)).isEqualTo(xdg) } + + @Test + fun `preferXdgLocation() prefers the XDG location when only it exists`(@TempDir tempDir: Path) { + val xdg = tempDir.resolve("xdg").createDirectories() + val legacy = tempDir.resolve("legacy") + assertThat(IoUtils.preferXdgLocation(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) + } } 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..d815bea47 --- /dev/null +++ b/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt @@ -0,0 +1,50 @@ +/* + * 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 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) + } + } +} 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 From 12f887edadf1faf6b39b61fc0aae99baff960b97 Mon Sep 17 00:00:00 2001 From: Florin Ungur Date: Tue, 9 Jun 2026 22:14:54 +0100 Subject: [PATCH 5/7] Add changelog PR link --- docs/modules/release-notes/pages/0.32.adoc | 4 ++-- docs/modules/release-notes/pages/changelog.adoc | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/modules/release-notes/pages/0.32.adoc b/docs/modules/release-notes/pages/0.32.adoc index 86506c78a..e20ff9e84 100644 --- a/docs/modules/release-notes/pages/0.32.adoc +++ b/docs/modules/release-notes/pages/0.32.adoc @@ -41,7 +41,7 @@ Ready when you need them. ==== Default file locations -For new setups, the CLI no longer stores anything under `~/.pkl`. It uses XDG-style 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: * Package cache: `~/.cache/pkl` (was `~/.pkl/cache`) * Settings file: `~/.config/pkl/settings.pkl` (was `~/.pkl/settings.pkl`) @@ -70,7 +70,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`. +* `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 cb389cd05..82c587e74 100644 --- a/docs/modules/release-notes/pages/changelog.adoc +++ b/docs/modules/release-notes/pages/changelog.adoc @@ -6,7 +6,7 @@ include::ROOT:partial$component-attributes.adoc[] === 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`. +* 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]] From 493741028d07232c574f1db5e3749a498604a173 Mon Sep 17 00:00:00 2001 From: Florin Ungur Date: Sat, 13 Jun 2026 17:58:24 +0100 Subject: [PATCH 6/7] feat(core): map default file locations to Windows Known Folders Apply %APPDATA% (config) and %LOCALAPPDATA% (cache/state) on Windows for the four default file locations, instead of using the XDG-style literal paths under the user home. Adds env-injectable overloads so tests can exercise the Windows code path on a Unix CI box. Rename preferXdgLocation -> preferNewLocation; the helper is no longer XDG-specific now that Windows uses Known Folders. --- docs/modules/release-notes/pages/0.32.adoc | 35 +++-- .../main/java/org/pkl/core/util/IoUtils.java | 133 ++++++++++++++---- .../kotlin/org/pkl/core/util/IoUtilsTest.kt | 127 +++++++++++++++-- .../org/pkl/executor/ExecutorOptions.java | 37 ++++- .../org/pkl/executor/ExecutorOptionsTest.kt | 49 +++++++ 5 files changed, 329 insertions(+), 52 deletions(-) diff --git a/docs/modules/release-notes/pages/0.32.adoc b/docs/modules/release-notes/pages/0.32.adoc index e20ff9e84..6cf209e65 100644 --- a/docs/modules/release-notes/pages/0.32.adoc +++ b/docs/modules/release-notes/pages/0.32.adoc @@ -41,17 +41,36 @@ Ready when you need them. ==== 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: +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: -* Package cache: `~/.cache/pkl` (was `~/.pkl/cache`) -* Settings file: `~/.config/pkl/settings.pkl` (was `~/.pkl/settings.pkl`) -* CA certificates: `~/.config/pkl/cacerts` (was `~/.pkl/cacerts`) -* REPL history: `~/.local/state/pkl/repl-history` (was `~/.pkl/repl-history`) +[cols="1,2,2,2",options="header"] +|=== +| Concern | Unix (Linux/macOS) | Windows | Legacy fallback -These are fixed defaults; no environment variables (such as `XDG_CACHE_HOME`, `XDG_CONFIG_HOME`, or `XDG_STATE_HOME`) are read. -On Windows, these paths are used literally under the user home directory (`~/.cache/pkl`, etc.); they are not mapped to Known Folder locations. +| Package cache +| `~/.cache/pkl` +| `%LOCALAPPDATA%/pkl/cache` +| `~/.pkl/cache` -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 `~/.cache/pkl` does not exist, so packages aren't re-downloaded. +| 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]#💔# 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 69747fce0..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,56 +235,78 @@ public static Path getPklHomeDir() { // not stored to avoid build-time initialization by native-image public static Path getDefaultModuleCacheDir() { - return getDefaultModuleCacheDir(getUserHomeDir()); + 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) { - // Prefer the XDG-style `~/.cache/pkl`, but keep using a pre-existing legacy `~/.pkl/cache` so - // that already-populated caches aren't orphaned (which would force a re-download). - return preferXdgLocation( - homeDir.resolve(".cache").resolve("pkl"), homeDir.resolve(".pkl").resolve("cache")); + 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()); + return getDefaultSettingsFile(getUserHomeDir(), isWindows(), System::getenv); } static Path getDefaultSettingsFile(Path homeDir) { - // Prefer the XDG-style `~/.config/pkl/settings.pkl`, falling back to legacy - // `~/.pkl/settings.pkl`. - return preferXdgLocation( - homeDir.resolve(".config").resolve("pkl").resolve("settings.pkl"), + 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()); + return getDefaultCaCertsDir(getUserHomeDir(), isWindows(), System::getenv); } static Path getDefaultCaCertsDir(Path homeDir) { - // Prefer the XDG-style `~/.config/pkl/cacerts`, falling back to legacy `~/.pkl/cacerts`. A - // directory with no certificate files counts as absent, so an empty `~/.config/pkl/cacerts` - // doesn't shadow a populated legacy `~/.pkl/cacerts`. - var xdg = homeDir.resolve(".config").resolve("pkl").resolve("cacerts"); - if (containsRegularFile(xdg)) { - return xdg; + 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 : xdg; + return containsRegularFile(legacy) ? legacy : preferred; } // not stored to avoid build-time initialization by native-image public static Path getDefaultReplHistoryFile() { - return getDefaultReplHistoryFile(getUserHomeDir()); + return getDefaultReplHistoryFile(getUserHomeDir(), isWindows(), System::getenv); } static Path getDefaultReplHistoryFile(Path homeDir) { - // REPL history is state, so prefer the XDG state dir `~/.local/state/pkl/repl-history`, falling - // back to legacy `~/.pkl/repl-history`. - return preferXdgLocation( - homeDir.resolve(".local").resolve("state").resolve("pkl").resolve("repl-history"), + 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")); } @@ -293,15 +316,67 @@ private static Path getUserHomeDir() { } /** - * Returns {@code xdgLocation}, unless it does not exist and {@code legacyLocation} does, in which - * case {@code legacyLocation} is returned. New setups therefore use the XDG-style location while - * existing setups keep working without migration. Package-private to allow direct testing. + * 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 preferXdgLocation(Path xdgLocation, Path legacyLocation) { - if (!Files.exists(xdgLocation) && Files.exists(legacyLocation)) { + static Path preferNewLocation(Path newLocation, Path legacyLocation) { + if (!Files.exists(newLocation) && Files.exists(legacyLocation)) { return legacyLocation; } - return xdgLocation; + return newLocation; } /** Whether {@code dir} is a directory containing at least one regular file. */ 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 5cfd348db..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 @@ -19,6 +19,7 @@ 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 @@ -412,33 +413,33 @@ class IoUtilsTest { } @Test - fun `preferXdgLocation() prefers the XDG location when it exists`(@TempDir tempDir: Path) { + 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.preferXdgLocation(xdg, legacy)).isEqualTo(xdg) + assertThat(IoUtils.preferNewLocation(xdg, legacy)).isEqualTo(xdg) } @Test - fun `preferXdgLocation() falls back to legacy when only the legacy location exists`( + 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.preferXdgLocation(xdg, legacy)).isEqualTo(legacy) + assertThat(IoUtils.preferNewLocation(xdg, legacy)).isEqualTo(legacy) } @Test - fun `preferXdgLocation() prefers the XDG location when neither exists`(@TempDir tempDir: Path) { + fun `preferNewLocation() prefers the XDG location when neither exists`(@TempDir tempDir: Path) { val xdg = tempDir.resolve("xdg") val legacy = tempDir.resolve("legacy") - assertThat(IoUtils.preferXdgLocation(xdg, legacy)).isEqualTo(xdg) + assertThat(IoUtils.preferNewLocation(xdg, legacy)).isEqualTo(xdg) } @Test - fun `preferXdgLocation() prefers the XDG location when only it exists`(@TempDir tempDir: Path) { + 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.preferXdgLocation(xdg, legacy)).isEqualTo(xdg) + assertThat(IoUtils.preferNewLocation(xdg, legacy)).isEqualTo(xdg) } @Test @@ -489,4 +490,114 @@ class IoUtilsTest { 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 5578934f9..609173048 100644 --- a/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java +++ b/pkl-executor/src/main/java/org/pkl/executor/ExecutorOptions.java @@ -20,8 +20,10 @@ 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; @@ -68,16 +70,37 @@ public final class ExecutorOptions { /** Returns the module cache dir that the CLI uses by default. */ public static Path defaultModuleCacheDir() { + 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). Prefer the XDG-style `~/.cache/pkl`, but keep using a pre-existing - // legacy `~/.pkl/cache` so that already-populated caches aren't orphaned. - var home = System.getProperty("user.home"); - var xdgLocation = Path.of(home, ".cache", "pkl"); - var legacyLocation = Path.of(home, ".pkl", "cache"); - if (!Files.exists(xdgLocation) && Files.exists(legacyLocation)) { + // 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 xdgLocation; + 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 index d815bea47..ecc777cca 100644 --- a/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt +++ b/pkl-executor/src/test/kotlin/org/pkl/executor/ExecutorOptionsTest.kt @@ -16,6 +16,7 @@ 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 @@ -47,4 +48,52 @@ class ExecutorOptionsTest { 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")) + } } From 47f4e6456e5cc1bbc9fe2f42820316215702b4db Mon Sep 17 00:00:00 2001 From: Florin Ungur Date: Sat, 13 Jun 2026 17:58:31 +0100 Subject: [PATCH 7/7] docs: note per-OS default file locations in CLI and Gradle docs Add the Windows %APPDATA% / %LOCALAPPDATA% paths alongside the existing Unix XDG paths in --cache-dir, --settings, ca-certificates, settings module, and gradle moduleCacheDir documentation. --- docs/modules/pkl-cli/pages/index.adoc | 6 +++--- docs/modules/pkl-cli/partials/cli-common-options.adoc | 4 ++-- .../pkl-gradle/partials/gradle-common-properties.adoc | 2 +- .../pkl-gradle/partials/gradle-modules-properties.adoc | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/modules/pkl-cli/pages/index.adoc b/docs/modules/pkl-cli/pages/index.adoc index d1c94004a..3a19e20dc 100644 --- a/docs/modules/pkl-cli/pages/index.adoc +++ b/docs/modules/pkl-cli/pages/index.adoc @@ -1385,7 +1385,7 @@ 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 `~/.config/pkl/settings.pkl`, falling back to the legacy `~/.pkl/settings.pkl` if that exists. +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. @@ -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 `~/.config/pkl/cacerts/` (or the legacy `~/.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 `~/.config/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 44b7ccb7f..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: `~/.cache/pkl` (or the legacy `~/.pkl/cache` if it already exists) + +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, `~/.config/pkl/settings.pkl` (or the legacy `~/.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 043f99b30..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 `~/.cache/pkl` (or the legacy `~/.pkl/cache` if it already exists). +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 34554f8a6..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`, `~/.config/pkl/settings.pkl` (or the legacy `~/.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[]