Use XDG base directories and Known Folders on Windows (#1809)

This changes logic that previously read/wrote from `~/.pkl` to use
XDG base directories (all OSes), and Known Folders locations on Windows.

For example, Pkl will look for `settings.pkl` in:

1. `$XDG_CONFIG_HOME/pkl/settings.pkl`
2. `%APPDATA/pkl/settings.pkl`
3. `~/.pkl/settings.pkl`
4. Path pkl/settings/pkl within `$XDG_CONFIG_DIRS`
5. `/etc/xdg/pkl/settings.pkl`

---------

Co-authored-by: Florin Ungur <florin@florinungur.com>
This commit is contained in:
Daniel Chao
2026-08-20 21:50:24 +00:00
committed by GitHub
co-authored by Florin Ungur
parent 4dd37219c0
commit 6551a59f9e
24 changed files with 799 additions and 40 deletions
@@ -210,6 +210,11 @@ abstract class NativeImageBuild : DefaultTask() {
add("--initialize-at-build-time=")
// needed for messagepack-java (see https://github.com/msgpack/msgpack-java/issues/600)
add("--initialize-at-run-time=org.msgpack.core.buffer.DirectBufferAccess")
// prevent storing `homeDir` in native image
add("--initialize-at-run-time=org.pkl.core.util.BaseDirectory")
add("--initialize-at-run-time=org.pkl.core.util.BaseDirectories")
// prevent storing `isEnabled`
add("--initialize-at-run-time=org.pkl.core.util.DebugLogger")
// needed for jline-terminal-jni
add("--initialize-at-run-time=org.jline.nativ,org.jline.terminal.impl.jni")
add("--no-fallback")
@@ -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_.
+23 -4
View File
@@ -1377,14 +1377,24 @@ 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`.
Unless configured explicitly, Pkl will look in the following locations:
. `$XDG_CONFIG_HOME/pkl/settings.pkl`
. `%APPDATA%/pkl/settings.pkl` (on Windows only)
. `~/.config/pkl/settings.pkl`
. Subdirectory `pkl/settings.pkl` within one of the paths described by `$XDG_CONFIG_DIRS`
. `/etc/xdg/pkl/settings.pkl`
. `~/.pkl/settings.pkl` (legacy location used by Pkl 0.32 and lower)
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>
@@ -1406,10 +1416,19 @@ 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 <path>`.
- Add them to a directory at path `~/.pkl/cacerts/`.
- Add them to a user directory.
If CA certificates are not explicitly configured, Pkl will look in the following locations:
. `$XDG_CONFIG_HOME/pkl/cacerts`
. `%APPDATA%/pkl/cacerts` (on Windows only)
. `~/.config/pkl/cacerts`
. Subdirectory `pkl/cacerts` within one of the paths described by `$XDG_CONFIG_DIRS`
. `/etc/xdg/pkl/cacerts`
. `~/.pkl/cacerts` (legacy location used by Pkl 0.32 and lower)
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]]
@@ -36,9 +36,15 @@ Possible values:
.--cache-dir
[%collapsible]
====
Default: `~/.pkl/cache` +
Example: `/path/to/module/cache/` +
The cache directory for storing packages.
If unset, defaults to the following locations:
. `$XDG_CACHE_HOME/pkl`
. `$LOCALAPPDATA/pkl/Cache` (on Windows only)
. `~/.cache/pkl` (if `$XDG_CACHE_HOME` and `$LOCALAPPDATA` are both unset)
====
.--no-cache
@@ -97,7 +103,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
@@ -64,7 +64,9 @@ 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.
This setting can also be configured using the `$XDG_CACHE_HOME` environment variable.
====
.color: Property<Boolean>
@@ -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[]
+37 -1
View File
@@ -13,7 +13,43 @@ include::partial$intro.adoc[]
== Noteworthy [small]#🎶#
=== XXX
=== CLI Changes
==== Default file locations
For new setups, the CLI no longer stores anything under `~/.pkl` (https://github.com/apple/pkl/pull/1809[#1809]).
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`
| none
| 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`
| none
|===
On every OS, these locations can be overridden with XDG-style env vars.
For example, setting `XDG_CACHE_HOME` will configure the cache directory.
Note that the existing `~/.pkl/cache` directory is ignored, so Pkl will download packages to populate its cache if configured to do so.
== Breaking Changes [small]#💔#
@@ -63,7 +63,11 @@ 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")))
// Will be null if `user.home` property is not set.
// If so, don't bother writing repl history.
IoUtils.getReplHistoryFile()?.let { historyFile ->
variable(LineReader.HISTORY_FILE, historyFile)
}
}
.build()
@@ -80,7 +84,7 @@ internal class Repl(workingDir: Path, private val server: ReplServer, private va
fun run() {
// JLine 2 history file is incompatible with JLine 3
IoUtils.getPklHomeDir().resolve("repl-history.bin").deleteIfExists()
IoUtils.getLegacyPklHomeDir().resolve("repl-history.bin").deleteIfExists()
println(ReplMessages.welcome)
println()
@@ -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<Path> = listOf(),
@@ -20,6 +20,7 @@ import java.net.URI
import java.nio.file.Files
import java.nio.file.Path
import java.util.regex.Pattern
import kotlin.io.path.absolutePathString
import kotlin.io.path.isRegularFile
import org.pkl.core.*
import org.pkl.core.evaluatorSettings.PklEvaluatorSettings
@@ -33,6 +34,7 @@ import org.pkl.core.project.Project
import org.pkl.core.resource.ResourceReader
import org.pkl.core.resource.ResourceReaders
import org.pkl.core.settings.PklSettings
import org.pkl.core.util.DebugLogger
import org.pkl.core.util.IoUtils
/** Building block for CLI commands. Configured programmatically to allow for embedding. */
@@ -69,7 +71,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
if (cliOptions.normalizedSettingsModule != null) {
PklSettings.load(ModuleSource.uri(cliOptions.normalizedSettingsModule))
} else {
PklSettings.loadFromPklHomeDir()
PklSettings.loadFromSystem()
}
} catch (e: PklException) {
// do not use `errorRenderer` because it depends on `settings`
@@ -146,7 +148,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
?: evaluatorSettings?.let { settings ->
if (settings.noCache == true) null else settings.moduleCacheDir
}
?: IoUtils.getDefaultModuleCacheDir()
?: IoUtils.getSystemModuleCacheDir()
}
protected val modulePath: List<Path> by lazy {
@@ -215,7 +217,7 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
}
private fun HttpClient.Builder.addDefaultCliCertificates() {
val caCertsDir = IoUtils.getPklHomeDir().resolve("cacerts")
val caCertsDir = IoUtils.getSystemCaCertsDir()
var certsAdded = false
if (Files.isDirectory(caCertsDir)) {
Files.list(caCertsDir)
@@ -225,7 +227,10 @@ abstract class CliCommand(protected val cliOptions: CliBaseOptions) {
addCertificates(cert)
}
}
if (!certsAdded) {
if (certsAdded) {
DebugLogger.log("Loading CA certificates from ${caCertsDir.normalize().absolutePathString()}")
} else {
DebugLogger.log("Using built-in CA certificates")
val defaultCerts =
this@CliCommand.javaClass.classLoader.getResourceAsStream(
"org/pkl/commons/cli/PklCARoots.pem"
@@ -144,7 +144,7 @@ class CliCommandTest {
assertThat(cliTest.myRootDir).isNull()
assertThat(builder.environmentVariables).isEqualTo(System.getenv())
assertThat(builder.externalProperties).isEmpty()
assertThat(builder.moduleCacheDir).isEqualTo(IoUtils.getDefaultModuleCacheDir())
assertThat(builder.moduleCacheDir).isEqualTo(IoUtils.getSystemModuleCacheDir())
assertThat(cliTest.myModulePath).isEmpty()
assertThat(builder.color).isFalse
assertThat(cliTest.myProxyAddress).isNull()
@@ -58,7 +58,7 @@ public final class EvaluatorBuilder {
private java.time.@Nullable Duration timeout;
private @Nullable Path moduleCacheDir = IoUtils.getDefaultModuleCacheDir();
private @Nullable Path moduleCacheDir = IoUtils.getSystemModuleCacheDir();
private @Nullable String outputFormat;
@@ -113,7 +113,7 @@ public final class ModuleCache {
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();
@@ -26,6 +26,7 @@ import org.pkl.core.module.ModuleKeyFactories;
import org.pkl.core.resource.ResourceReaders;
import org.pkl.core.runtime.VmEvalException;
import org.pkl.core.runtime.VmExceptionBuilder;
import org.pkl.core.util.DebugLogger;
import org.pkl.core.util.IoUtils;
/**
@@ -33,8 +34,10 @@ import org.pkl.core.util.IoUtils;
* {@literal pkl.settings} standard library module. To load a settings file, use one of the static
* {@code load} methods.
*/
// keep in sync with stdlib/settings.pkl
public record PklSettings(Editor editor, PklEvaluatorSettings.@Nullable Http http) {
// keep in sync with stdlib/settings.pkl
public static final PklSettings defaultInstance = new PklSettings(Editor.SYSTEM, null);
private static final List<Pattern> ALLOWED_MODULES =
List.of(Pattern.compile("pkl:"), Pattern.compile("file:"));
@@ -42,16 +45,43 @@ 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.
*
* <p>Prefers XDG_CONFIG_HOME (e.g. {@code ~/.config/pkl/settings.pkl}), falling back to the
* legacy {@code ~/.pkl/settings.pkl}.
*
* <p>If neither file exists, returns default settings defined by module {@code pkl.settings}.
*/
public static PklSettings loadFromSystem() throws VmEvalException {
var file = IoUtils.getSystemSettingsFile();
if (Files.exists(file)) {
DebugLogger.log("Loading settings file from " + file.normalize().toAbsolutePath());
return load(ModuleSource.path(file));
}
return defaultInstance;
}
/**
* Loads the user settings file.
*
* @deprecated As of 0.33.0, use {@link #loadFromSystem()}, which now prefers {@code
* ~/.config/pkl/settings.pkl} over the legacy {@code ~/.pkl/settings.pkl}.
*/
@Deprecated(since = "0.33.0", forRemoval = true)
public static PklSettings loadFromPklHomeDir() throws VmEvalException {
return loadFromPklHomeDir(IoUtils.getPklHomeDir());
var candidate = IoUtils.getLegacyPklHomeDir().resolve("settings.pkl");
if (Files.exists(candidate)) {
return load(ModuleSource.path(candidate));
}
return defaultInstance;
}
/** 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);
@@ -0,0 +1,33 @@
/*
* Copyright © 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.core.util;
final class BaseDirectories {
public static final BaseDirectory config =
new BaseDirectory(
"XDG_CONFIG_HOME",
"XDG_CONFIG_DIRS",
"APPDATA",
null,
".config",
new String[] {"/etc/xdg"});
public static final BaseDirectory cache =
new BaseDirectory("XDG_CACHE_HOME", null, "LOCALAPPDATA", "Cache", ".cache", null);
public static final BaseDirectory state =
new BaseDirectory("XDG_STATE_HOME", null, "LOCALAPPDATA", null, ".local/state", null);
}
@@ -0,0 +1,205 @@
/*
* Copyright © 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.core.util;
import java.io.File;
import java.nio.file.InvalidPathException;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Map;
import java.util.Objects;
import java.util.function.Predicate;
import org.jspecify.annotations.Nullable;
/**
* Utility library for accessing files in base directories.
*
* <p>On macOS and Linux, follows the XDG base directory specification.
*
* <p>On Windows, follows {@code $APPDATA} and {@code $LOCALAPPDATA} conventions, but can be
* overridden by {@code XDG} style env vars.
*/
class BaseDirectory {
private final String xdgHomeEnvVar;
private final @Nullable String xdgDirsEnvVar;
private final String windowsEnvVar;
private final @Nullable String windowsSubpath;
private final String homeDefault;
private final String @Nullable [] dirsDefault;
BaseDirectory(
String xdgHomeEnvVar,
@Nullable String xdgDirsEnvVar,
String windowsEnvVar,
@Nullable String windowsSubpath,
String homeDefault,
String @Nullable [] dirsDefault) {
this.xdgHomeEnvVar = xdgHomeEnvVar;
this.xdgDirsEnvVar = xdgDirsEnvVar;
this.windowsEnvVar = windowsEnvVar;
this.windowsSubpath = windowsSubpath;
this.homeDefault = homeDefault;
this.dirsDefault = dirsDefault;
}
@Nullable Path getHome() {
return getHome(System.getenv(), IoUtils.isWindows());
}
/** Returns the first file within the search hierarchy that exists. */
@Nullable Path firstMatchingPath(String subpath, Predicate<Path> predicate) {
return firstMatchingPath(subpath, System.getenv(), IoUtils.isWindows(), predicate);
}
/** Returns the subpath within the {@code home} of this base directory type. */
@Nullable Path resolveHome(String subpath) {
var homeDir = getHome(System.getenv(), IoUtils.isWindows());
if (homeDir != null) {
return homeDir.resolve(subpath);
}
return null;
}
// for testing only
@Nullable Path firstMatchingPath(
String subpath, Map<String, String> envVars, boolean isWindows, Predicate<Path> predicate) {
var home = getHome(envVars, isWindows);
Path candidate;
if (home != null) {
candidate = home.resolve(subpath);
if (predicate.test(candidate)) {
return candidate;
}
}
var dirs = getDirs(envVars);
if (dirs != null) {
for (var dir : dirs) {
candidate = dir.resolve(subpath);
if (predicate.test(candidate)) {
return candidate;
}
}
}
return null;
}
// possibly null if $HOME is not set.
private static final @Nullable Path homeDir;
static {
var userHome = System.getProperty("user.home");
if (userHome != null) {
homeDir = Path.of(userHome);
} else {
homeDir = null;
}
}
private static Path @Nullable [] getConfiguredPaths(String envVar, Map<String, String> envVars) {
try {
var value = envVars.get(envVar);
if (value == null || value.isEmpty()) {
return null;
}
var strs = value.split(File.pathSeparator);
var ret = new Path[strs.length];
for (var i = 0; i < strs.length; i++) {
var dir = strs[i];
if (dir.isEmpty()) {
continue;
}
ret[i] = Path.of(dir).resolve("pkl");
}
return Arrays.stream(ret).filter(Objects::nonNull).toArray(Path[]::new);
} catch (InvalidPathException e) {
// can't use org.pkl.core.Logger here; logger isn't yet available
// (can't call `VmContext.get()`).
// do the next best thing and just write to stderr.
System.err.println(
"[org.pkl.core.util.BaseDirectory] '"
+ envVar
+ "' env var contains an invalid path: "
+ e.getMessage());
return null;
}
}
private static @Nullable Path getConfiguredPath(String envVar, Map<String, String> envVars) {
try {
var value = envVars.get(envVar);
if (value == null || value.isEmpty()) {
return null;
}
return Path.of(value);
} catch (InvalidPathException e) {
// can't use org.pkl.core.Logger here; logger isn't yet available
// (can't call `VmContext.get()`).
// do the next best thing and just write to stderr.
System.err.println(
"[org.pkl.core.util.BaseDirectory] '"
+ envVar
+ "' env var is an invalid path: "
+ e.getMessage());
return null;
}
}
@Nullable Path getHome(Map<String, String> envVars, boolean isWindows) {
var configuredHome = getConfiguredPath(xdgHomeEnvVar, envVars);
if (configuredHome != null) {
return configuredHome.resolve("pkl");
}
if (isWindows) {
configuredHome = getConfiguredPath(windowsEnvVar, envVars);
if (configuredHome != null) {
configuredHome = configuredHome.resolve("pkl");
if (windowsSubpath != null) {
configuredHome = configuredHome.resolve(windowsSubpath);
}
return configuredHome;
}
}
if (homeDir != null) {
return homeDir.resolve(homeDefault).resolve("pkl");
}
return null;
}
Path @Nullable [] getDirs(Map<String, String> envVars) {
if (xdgDirsEnvVar != null) {
var paths = getConfiguredPaths(xdgDirsEnvVar, envVars);
if (paths != null) {
return paths;
}
}
if (dirsDefault == null) {
return null;
}
var ret = new Path[dirsDefault.length];
for (var i = 0; i < dirsDefault.length; i++) {
var dir = dirsDefault[i];
if (!dir.startsWith("/")) {
if (homeDir == null) {
return null;
}
ret[i] = homeDir.resolve(dir).resolve("pkl");
} else {
ret[i] = Path.of(dir).resolve("pkl");
}
}
return ret;
}
}
@@ -0,0 +1,35 @@
/*
* Copyright © 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.core.util;
import java.util.Objects;
public final class DebugLogger {
private DebugLogger() {}
private static final boolean isEnabled;
static {
isEnabled = Objects.equals(System.getenv("PKL_DEBUG"), "1");
}
public static void log(String message) {
if (!isEnabled) {
return;
}
System.err.println("[pkl] " + message);
}
}
@@ -49,7 +49,6 @@ import org.pkl.core.runtime.VmExceptionBuilder;
import org.pkl.core.util.GlobResolver.InvalidGlobPatternException;
public final class IoUtils {
// Don't match paths like `C:\`, which are drive letters on Windows.
private static final Pattern uriLike = Pattern.compile("[\\w+.-]+:[^\\\\].*");
@@ -228,13 +227,33 @@ public final class IoUtils {
}
// not stored to avoid build-time initialization by native-image
public static Path getPklHomeDir() {
public static Path getLegacyPklHomeDir() {
return Path.of(System.getProperty("user.home"), ".pkl");
}
public static @Nullable Path getSystemModuleCacheDir() {
return BaseDirectories.cache.getHome();
}
public static Path getSystemSettingsFile() {
var path = BaseDirectories.config.firstMatchingPath("settings.pkl", Files::exists);
if (path == null) {
return getLegacyPklHomeDir().resolve("settings.pkl");
}
return path;
}
public static Path getSystemCaCertsDir() {
var path = BaseDirectories.config.firstMatchingPath("cacerts", Files::isDirectory);
if (path == null) {
return getLegacyPklHomeDir().resolve("cacerts");
}
return path;
}
// not stored to avoid build-time initialization by native-image
public static Path getDefaultModuleCacheDir() {
return getPklHomeDir().resolve("cache");
public static @Nullable Path getReplHistoryFile() {
return BaseDirectories.state.resolveHome("repl-history");
}
// not stored to avoid build-time initialization by native-image
@@ -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`."
)
@@ -0,0 +1,236 @@
/*
* Copyright © 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.core.util
import java.io.File
import java.nio.file.Files
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
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.io.TempDir
class BaseDirectoryTest {
private val subject = BaseDirectories.config
@Test
fun `firstMatchingPath() - finds a file within the XDG-configured home`(@TempDir tempDir: Path) {
val xdgHome = tempDir.resolve("xdg-home").createDirectories()
xdgHome.resolve("pkl/settings.pkl").createParentDirectories().createFile()
val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString())
assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists))
.isEqualTo(xdgHome.resolve("pkl/settings.pkl"))
}
@Test
fun `firstMatchingPath() - prefers home over dirs when both contain the file`(
@TempDir tempDir: Path
) {
val xdgHome = tempDir.resolve("xdg-home").createDirectories()
val dir1 = tempDir.resolve("dir1").createDirectories()
xdgHome.resolve("pkl/settings.pkl").createParentDirectories().createFile()
dir1.resolve("settings.pkl").createFile()
val envVars =
mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString())
assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists))
.isEqualTo(xdgHome.resolve("pkl/settings.pkl"))
}
@Test
fun `firstMatchingPath() - falls back to dirs when home does not contain the file`(
@TempDir tempDir: Path
) {
val xdgHome = tempDir.resolve("xdg-home").createDirectories()
val dir1 = tempDir.resolve("dir1").createDirectories()
dir1.resolve("pkl/settings.pkl").let {
it.createParentDirectories()
it.createFile()
}
val envVars =
mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString())
assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists))
.isEqualTo(dir1.resolve("pkl/settings.pkl"))
}
@Test
fun `firstMatchingPath() - searches multiple dirs in order`(@TempDir tempDir: Path) {
val xdgHome = tempDir.resolve("xdg-home").createDirectories()
val dir1 = tempDir.resolve("dir1").createDirectories()
val dir2 = tempDir.resolve("dir2").createDirectories()
val expected =
dir2.resolve("pkl/settings.pkl").also {
it.createParentDirectories()
it.createFile()
}
val envVars =
mapOf(
"XDG_CONFIG_HOME" to xdgHome.toString(),
"XDG_CONFIG_DIRS" to "$dir1${File.pathSeparator}$dir2",
)
assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists))
.isEqualTo(expected)
}
@Test
fun `firstMatchingPath() - returns null when the file exists nowhere in the search hierarchy`(
@TempDir tempDir: Path
) {
val xdgHome = tempDir.resolve("xdg-home").createDirectories()
val dir1 = tempDir.resolve("dir1").createDirectories()
val envVars =
mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "XDG_CONFIG_DIRS" to dir1.toString())
assertThat(subject.firstMatchingPath("missing.pkl", envVars, false, Files::exists)).isNull()
}
@Test
fun `firstMatchingPath() - XDG env var wins over the Windows env var even when isWindows is true`(
@TempDir tempDir: Path
) {
val xdgHome = tempDir.resolve("xdg-home").createDirectories()
val appData = tempDir.resolve("app-data").createDirectories()
xdgHome.resolve("pkl/settings.pkl").createParentDirectories().createFile()
appData.resolve("pkl/settings.pkl").createParentDirectories().createFile()
val envVars = mapOf("XDG_CONFIG_HOME" to xdgHome.toString(), "AppData" to appData.toString())
assertThat(subject.firstMatchingPath("settings.pkl", envVars, true, Files::exists))
.isEqualTo(xdgHome.resolve("pkl/settings.pkl"))
}
@Test
fun `firstMatchingPath() - uses the Windows env var when the XDG env var is unset and isWindows is true`(
@TempDir tempDir: Path
) {
val appData = tempDir.resolve("app-data").createDirectories()
appData.resolve("pkl/settings.pkl").createParentDirectories().createFile()
val envVars = mapOf("APPDATA" to appData.toString())
assertThat(subject.firstMatchingPath("settings.pkl", envVars, true, Files::exists))
.isEqualTo(appData.resolve("pkl/settings.pkl"))
}
@Test
fun `firstMatchingPath() - ignores the Windows env var when isWindows is false`(
@TempDir tempDir: Path
) {
val appData = tempDir.resolve("app-data").createDirectories()
appData.resolve("pkl/settings.pkl").createParentDirectories().createFile()
val envVars = mapOf("APPDATA" to appData.toString())
assertThat(subject.firstMatchingPath("settings.pkl", envVars, false, Files::exists)).isNull()
}
@Test
fun `firstMatchingPath() - appends the Windows subpath after 'pkl' when configured`(
@TempDir tempDir: Path
) {
val localAppData = tempDir.resolve("local-app-data").createDirectories()
localAppData.resolve("pkl/Cache/cache.db").createParentDirectories().createFile()
val envVars = mapOf("LOCALAPPDATA" to localAppData.toString())
assertThat(BaseDirectories.cache.firstMatchingPath("cache.db", envVars, true, Files::exists))
.isEqualTo(localAppData.resolve("pkl/Cache/cache.db"))
}
@Test
fun `firstMatchingPath() - returns null when falling back to defaults that do not contain the file`() {
// Doesn't touch the real filesystem: this subpath is not expected to exist under the real
// `~/.config` or `/etc/xdg`, so the defaults are exercised without creating any real files.
val subpath = "base-directory-test/definitely-does-not-exist.txt"
assertThat(subject.firstMatchingPath(subpath, emptyMap(), false, Files::exists)).isNull()
assertThat(subject.firstMatchingPath(subpath, emptyMap(), true, Files::exists)).isNull()
}
@Test
fun `getHome() - appends 'pkl' to the default home directory when no env vars are set`() {
val expected = Path.of(System.getProperty("user.home")).resolve(".config").resolve("pkl")
assertThat(subject.getHome(emptyMap(), false)).isEqualTo(expected)
// Same fallback applies on Windows when the Windows env var is also unset.
assertThat(subject.getHome(emptyMap(), true)).isEqualTo(expected)
}
@Test
fun `getHome() - appends 'pkl' to the default cache home when no env vars are set`() {
assertThat(BaseDirectories.cache.getHome(emptyMap(), false))
.isEqualTo(Path.of(System.getProperty("user.home")).resolve(".cache").resolve("pkl"))
}
@Test
fun `getHome() - appends 'pkl' to the default state home when no env vars are set`() {
assertThat(BaseDirectories.state.getHome(emptyMap(), false))
.isEqualTo(Path.of(System.getProperty("user.home")).resolve(".local/state").resolve("pkl"))
}
@Test
fun `getHome() - treats an empty XDG env var as unset`() {
val expected = Path.of(System.getProperty("user.home")).resolve(".config").resolve("pkl")
assertThat(subject.getHome(mapOf("XDG_CONFIG_HOME" to ""), false)).isEqualTo(expected)
}
@Test
fun `getHome() - treats an empty Windows env var as unset`() {
val expected = Path.of(System.getProperty("user.home")).resolve(".config").resolve("pkl")
assertThat(subject.getHome(mapOf("APPDATA" to ""), true)).isEqualTo(expected)
}
@Test
fun `getHome() - an empty XDG env var falls through to a configured Windows env var`(
@TempDir tempDir: Path
) {
val appData = tempDir.resolve("app-data")
val envVars = mapOf("XDG_CONFIG_HOME" to "", "APPDATA" to appData.toString())
assertThat(subject.getHome(envVars, true)).isEqualTo(appData.resolve("pkl"))
}
@Test
fun `getDirs() - treats an empty XDG_CONFIG_DIRS as unset, falling back to defaults`() {
assertThat(subject.getDirs(mapOf("XDG_CONFIG_DIRS" to "")))
.containsExactly(Path.of("/etc/xdg").resolve("pkl"))
}
@Test
fun `getDirs() - skips empty entries within an otherwise non-empty XDG_CONFIG_DIRS list`(
@TempDir tempDir: Path
) {
val dir1 = tempDir.resolve("dir1")
val dir2 = tempDir.resolve("dir2")
// A double separator produces a literal empty segment (`"/a::/b".split(":")` keeps the
// middle `""`, unlike a trailing separator, which java.lang.String#split drops).
val envVars = mapOf("XDG_CONFIG_DIRS" to "$dir1${File.pathSeparator}${File.pathSeparator}$dir2")
assertThat(subject.getDirs(envVars)).containsExactly(dir1.resolve("pkl"), dir2.resolve("pkl"))
}
@Test
fun `firstMatchingPath() - does not crash when XDG_CONFIG_DIRS contains a leading empty segment`(
@TempDir tempDir: Path
) {
val dir1 = tempDir.resolve("dir1").createDirectories()
val envVars = mapOf("XDG_CONFIG_DIRS" to "${File.pathSeparator}$dir1")
assertThat(subject.firstMatchingPath("missing.pkl", envVars, false, Files::exists)).isNull()
}
}
@@ -16,9 +16,11 @@
package org.pkl.executor;
import java.net.URI;
import java.nio.file.InvalidPathException;
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 org.jspecify.annotations.Nullable;
@@ -26,6 +28,8 @@ import org.pkl.executor.spi.v1.ExecutorSpiOptions;
import org.pkl.executor.spi.v1.ExecutorSpiOptions2;
import org.pkl.executor.spi.v1.ExecutorSpiOptions3;
import org.pkl.executor.spi.v1.ExecutorSpiOptions4;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Options for {@link Executor#evaluatePath}.
@@ -33,6 +37,8 @@ import org.pkl.executor.spi.v1.ExecutorSpiOptions4;
* <p>To create {@code ExecutorOptions}, use its {@linkplain #builder builder}.
*/
public final class ExecutorOptions {
private static final Logger logger = LoggerFactory.getLogger(ExecutorOptions.class);
private final List<String> allowedModules;
private final List<String> allowedResources;
@@ -67,7 +73,42 @@ 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, Map<String, String> environmentVariables) {
// Keep in sync with org.pkl.core.util.IoUtils.getSystemModuleCacheDir (pkl-executor cannot
// depend on pkl-core).
//
// On Unix prefer the XDG-style `~/.cache/pkl`.
// On Windows prefer `%LOCALAPPDATA%/pkl/Cache`.
var xdgConfig = environmentVariables.get("XDG_CACHE_HOME");
if (xdgConfig != null && !xdgConfig.isEmpty()) {
try {
return Path.of(xdgConfig).resolve("pkl");
} catch (InvalidPathException e) {
logger.warn("'XDG_CACHE_HOME' is an invalid path: {}", e.getMessage());
}
}
if (isWindows) {
var localAppData = environmentVariables.get("LOCALAPPDATA");
if (localAppData != null && !localAppData.isEmpty()) {
try {
return Path.of(localAppData).resolve("pkl/Cache");
} catch (InvalidPathException e) {
logger.warn("'LOCALAPPDATA' is an invalid path: {}", e.getMessage());
}
}
}
return home.resolve(".cache/pkl");
}
private static boolean isWindowsOs() {
var osName = System.getProperty("os.name");
return osName != null && osName.toLowerCase(Locale.ROOT).contains("windows");
}
public static Builder builder() {
@@ -0,0 +1,69 @@
/*
* 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`(@TempDir home: Path) {
val original = System.getProperty("user.home")
try {
System.setProperty("user.home", home.toString())
assertThat(ExecutorOptions.defaultModuleCacheDir())
.isEqualTo(home.resolve(".cache").resolve("pkl"))
assertThat(ExecutorOptions.defaultModuleCacheDir())
.isEqualTo(IoUtils.getSystemModuleCacheDir())
} finally {
System.setProperty("user.home", original)
}
}
@Test
fun `defaultModuleCacheDir on Windows uses LOCALAPPDATA when set`(@TempDir home: Path) {
val localAppData = home.resolve("LocalAppData").createDirectories()
assertThat(
ExecutorOptions.defaultModuleCacheDir(
home,
true,
mapOf("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, mapOf()))
.isEqualTo(home.resolve(".cache").resolve("pkl"))
}
@Test
fun `defaultModuleCacheDir on Windows still falls XDG style default dir`(@TempDir home: Path) {
home.resolve(".pkl").resolve("cache").createDirectories()
assertThat(ExecutorOptions.defaultModuleCacheDir(home, true, mapOf()))
.isEqualTo(home.resolve(".cache").resolve("pkl"))
}
}
@@ -322,7 +322,10 @@ public class PklPlugin implements Plugin<Project> {
// Therefore, we don't set any initial value for the environmentVariables property.
// Not using `convention()` to allow the user to unset this property, disabling the cache.
spec.getModuleCacheDir().set(IoUtils.getDefaultModuleCacheDir().toFile());
var systemCacheDir = IoUtils.getSystemModuleCacheDir();
if (systemCacheDir != null) {
spec.getModuleCacheDir().set(systemCacheDir.toFile());
}
spec.getNoCache().convention(false);
+10 -1
View File
@@ -17,8 +17,17 @@
/// Configuration settings for Pkl itself.
///
/// 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 look in the following locations in order of precedence:
///
/// 1. `$XDG_CONFIG_HOME/pkl/settings.pkl`
/// 2. `$APPDATA/pkl/settings.pkl` (on Windows only)
/// 3. `~/.config/pkl/settings.pkl`
/// 4. Path `pkl/settings.pkl` within the `$XDG_CONFIG_DIRS` search path
/// (dirs separated by `:` on Unix, `;` on Windows).
/// 4. `/etc/xdg/pkl/settings.pkl`
/// 5. `~/.pkl/settings.pkl` (legacy location used by Pkl 0.32 and lower)
@ModuleInfo { minPklVersion = "0.33.0" }
module pkl.settings