From a14c49efb50e16d01204f926f468a454e1fac9a2 Mon Sep 17 00:00:00 2001 From: gdlol <.> Date: Fri, 2 Oct 2026 02:12:04 +0000 Subject: [PATCH] Set up per-user XDG base directories --- .devcontainer/Dockerfile | 3 --- .devcontainer/compose.yaml | 1 - .devcontainer/container.env | 1 - .devcontainer/devcontainer.json | 4 ++-- .devcontainer/dot-config.json | 6 ++--- .../features/src/dot-config/README.md | 2 +- .../features/src/dot-config/config.ts | 2 -- .../src/dot-config/devcontainer-feature.json | 11 ++------- .../features/src/dot-config/index.ts | 3 ++- .../features/src/dot-config/install.ts | 4 ---- .../features/src/dot-config/logging.ts | 12 ++++++++-- .devcontainer/features/src/user-init/NOTES.md | 12 ++++++++++ .../features/src/user-init/README.md | 15 +++++++++++- .../src/user-init/devcontainer-feature.json | 6 +---- .../features/src/user-init/install.sh | 23 ++++++++++++++----- .../features/test/user-init/index.test.ts | 10 ++++---- ReadMe.md | 2 +- scripts/verify/dotnet/lifecycle.ts | 2 +- scripts/verify/dotnet/selection.ts | 15 +++++++++--- 19 files changed, 84 insertions(+), 50 deletions(-) delete mode 100644 .devcontainer/container.env create mode 100644 .devcontainer/features/src/user-init/NOTES.md diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index c1418e5..21cdd76 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -10,7 +10,4 @@ RUN apt-get update \ && rm -rf /var/lib/apt/lists/* \ && npm install --global pnpm@latest-11 -RUN mkdir --parents /usr/share/dotnet /usr/share/devcontainer-config/NuGet/global-packages \ - && chmod --recursive a+rwX /usr/share/dotnet /usr/share/devcontainer-config - COPY --from=dotnet /features/dotnet/ /opt/devcontainer-config/features/dotnet/ diff --git a/.devcontainer/compose.yaml b/.devcontainer/compose.yaml index da2d0e9..51473c5 100644 --- a/.devcontainer/compose.yaml +++ b/.devcontainer/compose.yaml @@ -1,7 +1,6 @@ services: devcontainer: env_file: - - container.env - path: ../.local/.env required: false build: diff --git a/.devcontainer/container.env b/.devcontainer/container.env deleted file mode 100644 index 3f90552..0000000 --- a/.devcontainer/container.env +++ /dev/null @@ -1 +0,0 @@ -NUGET_PACKAGES=/usr/share/devcontainer-config/NuGet/global-packages diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 44e5a2a..fa9a5f0 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -20,8 +20,8 @@ } ], "features": { - // "ghcr.io/devcontainer-config/features/user-init:3": {}, - // "ghcr.io/devcontainer-config/features/dot-config:4": {}, + // "ghcr.io/devcontainer-config/features/user-init:4": {}, + // "ghcr.io/devcontainer-config/features/dot-config:5": {}, // "ghcr.io/devcontainer-config/features/features": {}, "./features/src/user-init": {}, "./features/src/dot-config": {}, diff --git a/.devcontainer/dot-config.json b/.devcontainer/dot-config.json index 23abd1c..1f28e36 100644 --- a/.devcontainer/dot-config.json +++ b/.devcontainer/dot-config.json @@ -1,13 +1,13 @@ { "git": { - "attributes": "/etc/devcontainer-config/git/attributes", - "ignore": "/etc/devcontainer-config/git/ignore" + "attributes": "/etc/devcontainer-config/dev/git/attributes", + "ignore": "/etc/devcontainer-config/dev/git/ignore" }, "cspell": { "cspell.json": "cspell.json" }, "pnpm": { - "config.yaml": "/etc/devcontainer-config/pnpm/config.yaml" + "config.yaml": "/etc/devcontainer-config/dev/pnpm/config.yaml" }, "eslint": { "config.ts": "eslint.config.ts" diff --git a/.devcontainer/features/src/dot-config/README.md b/.devcontainer/features/src/dot-config/README.md index b1b15c8..8c4f014 100644 --- a/.devcontainer/features/src/dot-config/README.md +++ b/.devcontainer/features/src/dot-config/README.md @@ -6,7 +6,7 @@ Synchronize configuration files from .config/ to devcontainer workspaces. ```json "features": { - "ghcr.io/devcontainer-config/features/dot-config:4": {} + "ghcr.io/devcontainer-config/features/dot-config:5": {} } ``` diff --git a/.devcontainer/features/src/dot-config/config.ts b/.devcontainer/features/src/dot-config/config.ts index c11a93d..aa3e5e3 100644 --- a/.devcontainer/features/src/dot-config/config.ts +++ b/.devcontainer/features/src/dot-config/config.ts @@ -33,5 +33,3 @@ export const parseConfig = async (projectRoot: string) => { } return mappings; }; - -export const cachePath = "/var/cache/devcontainer-config/dot-config"; diff --git a/.devcontainer/features/src/dot-config/devcontainer-feature.json b/.devcontainer/features/src/dot-config/devcontainer-feature.json index 4d56b9e..83ac2ca 100644 --- a/.devcontainer/features/src/dot-config/devcontainer-feature.json +++ b/.devcontainer/features/src/dot-config/devcontainer-feature.json @@ -1,6 +1,6 @@ { "id": "dot-config", - "version": "4.0.0", + "version": "5.0.0", "name": ".config/", "description": "Synchronize configuration files from .config/ to devcontainer workspaces.", "keywords": ["dot", "config"], @@ -13,12 +13,5 @@ }, "installsAfter": ["ghcr.io/devcontainers/features/node"], "onCreateCommand": "dot-config sync || true", - "postStartCommand": "dot-config watch --detach", - "mounts": [ - { - "type": "volume", - "source": "dot-config-cache-${devcontainerId}", - "target": "/var/cache/devcontainer-config/dot-config" - } - ] + "postStartCommand": "dot-config watch --detach" } diff --git a/.devcontainer/features/src/dot-config/index.ts b/.devcontainer/features/src/dot-config/index.ts index f08cc9d..eacf311 100755 --- a/.devcontainer/features/src/dot-config/index.ts +++ b/.devcontainer/features/src/dot-config/index.ts @@ -8,7 +8,6 @@ import { packageJson } from "./package.js"; import { sync } from "./sync.js"; import { watch } from "./watch.js"; -initializeLogger(); const projectRoot = process.cwd(); program.name(packageJson.name).version(packageJson.version); @@ -17,6 +16,7 @@ program .command("sync") .description("perform a one-time synchronization of .config/ files") .action(async () => { + initializeLogger(); try { await sync(projectRoot); } catch (error) { @@ -33,6 +33,7 @@ program const childProcess = $({ detached: true, stdio: "ignore" })`${packageJson.name} watch`; childProcess.nodeChildProcess.unref(); } else { + initializeLogger(); watch(projectRoot); } }); diff --git a/.devcontainer/features/src/dot-config/install.ts b/.devcontainer/features/src/dot-config/install.ts index 37eb9ba..9523331 100644 --- a/.devcontainer/features/src/dot-config/install.ts +++ b/.devcontainer/features/src/dot-config/install.ts @@ -3,7 +3,6 @@ import path from "node:path"; import { $ } from "execa"; -import { cachePath } from "./config.js"; import { packageJson } from "./package.js"; const insertShebang = async (path: string) => { @@ -20,12 +19,10 @@ export const install = async () => { const workspacesPath = process.env.WORKSPACES ?? "/workspaces"; await mkdir(workspacesPath, { recursive: true }); - await mkdir(cachePath, { recursive: true }); if (path.basename(import.meta.dirname) === "dist") { // called from install.dist.sh. const $$ = $({ stdio: "inherit", verbose: "full" }); await $$`chmod -R a=rwx ${workspacesPath}`; - await $$`chmod -R a=rwx ${cachePath}`; await cp(import.meta.dirname, featureInstallPath, { recursive: true, verbatimSymlinks: true }); await $$`npm install --global ${featureInstallPath}`; @@ -45,7 +42,6 @@ export const install = async () => { }, }); await $$`chmod -R a=rwx ${workspacesPath}`; - await $$`chmod -R a=rwx ${cachePath}`; await $$`tsc --project ${import.meta.dirname} --noEmit false --outDir .`; await writeFile(path.resolve(tempPath, "package.json"), JSON.stringify(packageJson, null, 2)); diff --git a/.devcontainer/features/src/dot-config/logging.ts b/.devcontainer/features/src/dot-config/logging.ts index 4fea496..a9fe12f 100644 --- a/.devcontainer/features/src/dot-config/logging.ts +++ b/.devcontainer/features/src/dot-config/logging.ts @@ -1,8 +1,16 @@ +import { homedir } from "node:os"; import path from "node:path"; import winston from "winston"; -import { cachePath } from "./config.js"; +const cacheRoot = (): string => { + const xdgCacheHome = process.env.XDG_CACHE_HOME; + const cacheHome = + xdgCacheHome !== undefined && xdgCacheHome !== "" && path.isAbsolute(xdgCacheHome) + ? xdgCacheHome + : path.join(homedir(), ".cache"); + return path.join(cacheHome, "dot-config"); +}; export const initializeLogger = () => { winston.configure({ @@ -13,7 +21,7 @@ export const initializeLogger = () => { handleRejections: true, }), new winston.transports.File({ - filename: path.resolve(cachePath, "dot-config.log"), + filename: path.join(cacheRoot(), "dot-config.log"), options: { flags: "w" }, format: winston.format.combine(winston.format.timestamp(), winston.format.json()), handleExceptions: true, diff --git a/.devcontainer/features/src/user-init/NOTES.md b/.devcontainer/features/src/user-init/NOTES.md new file mode 100644 index 0000000..1ab5b63 --- /dev/null +++ b/.devcontainer/features/src/user-init/NOTES.md @@ -0,0 +1,12 @@ +## Notes + +Creates `remoteUser` with UID 1000. + +Each user gets its own subtree under the four volume-mounted XDG roots: + +| Variable | Directory | +| ----------------- | --------------------------------------- | +| `XDG_CONFIG_HOME` | `/etc/devcontainer-config/` | +| `XDG_CACHE_HOME` | `/var/cache/devcontainer-config/` | +| `XDG_DATA_HOME` | `/usr/share/devcontainer-config/` | +| `XDG_STATE_HOME` | `/var/lib/devcontainer-config/` | diff --git a/.devcontainer/features/src/user-init/README.md b/.devcontainer/features/src/user-init/README.md index 39269c2..c8e77d8 100644 --- a/.devcontainer/features/src/user-init/README.md +++ b/.devcontainer/features/src/user-init/README.md @@ -6,10 +6,23 @@ Rename user with UID 1000 to remoteUser and setup XDG base directories. ```json "features": { - "ghcr.io/devcontainer-config/features/user-init:3": {} + "ghcr.io/devcontainer-config/features/user-init:4": {} } ``` +## Notes + +Creates `remoteUser` with UID 1000. + +Each user gets its own subtree under the four volume-mounted XDG roots: + +| Variable | Directory | +| ----------------- | --------------------------------------- | +| `XDG_CONFIG_HOME` | `/etc/devcontainer-config/` | +| `XDG_CACHE_HOME` | `/var/cache/devcontainer-config/` | +| `XDG_DATA_HOME` | `/usr/share/devcontainer-config/` | +| `XDG_STATE_HOME` | `/var/lib/devcontainer-config/` | + --- _Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/devcontainer-config/features/blob/main/.devcontainer/features/src/user-init/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/.devcontainer/features/src/user-init/devcontainer-feature.json b/.devcontainer/features/src/user-init/devcontainer-feature.json index f6903ec..659c0e9 100644 --- a/.devcontainer/features/src/user-init/devcontainer-feature.json +++ b/.devcontainer/features/src/user-init/devcontainer-feature.json @@ -1,15 +1,11 @@ { "id": "user-init", - "version": "3.0.0", + "version": "4.0.0", "name": "Initialize User", "description": "Rename user with UID 1000 to remoteUser and setup XDG base directories.", "keywords": ["initialize", "user", "username", "uid", "common-utils"], "installsAfter": ["ghcr.io/devcontainers/features/common-utils"], "containerEnv": { - "XDG_CONFIG_HOME": "/etc/devcontainer-config", - "XDG_CACHE_HOME": "/var/cache/devcontainer-config", - "XDG_DATA_HOME": "/usr/share/devcontainer-config", - "XDG_STATE_HOME": "/var/lib/devcontainer-config", "XDG_DATA_DIRS": "/usr/local/share:/usr/share", "XDG_CONFIG_DIRS": "/etc/xdg" }, diff --git a/.devcontainer/features/src/user-init/install.sh b/.devcontainer/features/src/user-init/install.sh index 8a91b18..df1982b 100644 --- a/.devcontainer/features/src/user-init/install.sh +++ b/.devcontainer/features/src/user-init/install.sh @@ -15,10 +15,21 @@ chmod ug=r,o= "/etc/sudoers.d/${_REMOTE_USER}" # Ideally these folders would be in ${HOME}, but there's no way to mount volumes base on remoteUser. # See https://github.com/devcontainers/spec/issues/220 -XDG_CONFIG_HOME=/etc/devcontainer-config -XDG_CACHE_HOME=/var/cache/devcontainer-config -XDG_DATA_HOME=/usr/share/devcontainer-config -XDG_STATE_HOME=/var/lib/devcontainer-config -mkdir --parents "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}" "${XDG_DATA_HOME}" "${XDG_STATE_HOME}" -chmod -R a+rwx "${XDG_CONFIG_HOME}" "${XDG_CACHE_HOME}" "${XDG_DATA_HOME}" "${XDG_STATE_HOME}" +XDG_CONFIG_HOME_ROOT=/etc/devcontainer-config +XDG_CACHE_HOME_ROOT=/var/cache/devcontainer-config +XDG_DATA_HOME_ROOT=/usr/share/devcontainer-config +XDG_STATE_HOME_ROOT=/var/lib/devcontainer-config +mkdir --parents "${XDG_CONFIG_HOME_ROOT}" "${XDG_CACHE_HOME_ROOT}" "${XDG_DATA_HOME_ROOT}" "${XDG_STATE_HOME_ROOT}" +chmod -R a+rwx "${XDG_CONFIG_HOME_ROOT}" "${XDG_CACHE_HOME_ROOT}" "${XDG_DATA_HOME_ROOT}" "${XDG_STATE_HOME_ROOT}" + +cat > /etc/profile.d/00-xdg.sh << EOF +XDG_CONFIG_HOME="${XDG_CONFIG_HOME_ROOT}/\$(id -un)" +XDG_CACHE_HOME="${XDG_CACHE_HOME_ROOT}/\$(id -un)" +XDG_DATA_HOME="${XDG_DATA_HOME_ROOT}/\$(id -un)" +XDG_STATE_HOME="${XDG_STATE_HOME_ROOT}/\$(id -un)" +export XDG_CONFIG_HOME XDG_CACHE_HOME XDG_DATA_HOME XDG_STATE_HOME +mkdir -p "\$XDG_CONFIG_HOME" "\$XDG_CACHE_HOME" "\$XDG_DATA_HOME" "\$XDG_STATE_HOME" +EOF +chmod 0644 /etc/profile.d/00-xdg.sh + chown --recursive "${_REMOTE_USER}:${_REMOTE_USER}" "${HOME}" diff --git a/.devcontainer/features/test/user-init/index.test.ts b/.devcontainer/features/test/user-init/index.test.ts index 8bfa9cd..7fc6827 100644 --- a/.devcontainer/features/test/user-init/index.test.ts +++ b/.devcontainer/features/test/user-init/index.test.ts @@ -15,15 +15,17 @@ test("remote user home", async () => { }); test("XDG base directories", async () => { - const dirs = { + const { username } = userInfo(); + const roots = { XDG_CONFIG_HOME: "/etc/devcontainer-config", XDG_CACHE_HOME: "/var/cache/devcontainer-config", XDG_DATA_HOME: "/usr/share/devcontainer-config", XDG_STATE_HOME: "/var/lib/devcontainer-config", } as const; - for (const [name, dir] of Object.entries(dirs)) { - expect(process.env[name]).toBe(dir); - await access(dir, R_OK | W_OK | X_OK); + for (const [name, root] of Object.entries(roots)) { + const directory = `${root}/${username}`; + expect(process.env[name]).toBe(directory); + await access(directory, R_OK | W_OK | X_OK); } }); diff --git a/ReadMe.md b/ReadMe.md index 6ff5566..bc1e66b 100644 --- a/ReadMe.md +++ b/ReadMe.md @@ -13,7 +13,7 @@ Most base images for devcontainers comes with a non-root user with UID 1000. Thi A non-root user can be created via the `ghcr.io/devcontainers/features/common-utils` feature, but it cannot override an existing user with UID 1000. A UID 1000 user in devcontainer is necessary for sensible file permission behavior in the host. -According to Docker behavior, directories mounted as named volumes are owned by root, unless created up-front with the correct ownership. This feature creates default XDG base directories for `remoteUser` so that they have the correct ownership when mounted as named volumes. +According to Docker behavior, directories mounted as named volumes are owned by root, unless created up-front with the correct ownership. This feature creates XDG base directories for `remoteUser` so that they have the correct ownership when mounted as named volumes. Use cases: diff --git a/scripts/verify/dotnet/lifecycle.ts b/scripts/verify/dotnet/lifecycle.ts index f3c61eb..f957664 100644 --- a/scripts/verify/dotnet/lifecycle.ts +++ b/scripts/verify/dotnet/lifecycle.ts @@ -11,7 +11,7 @@ import { login } from "@/scripts/tasks/features/dotnet/registry.js"; import type { ComponentImage } from "./selection.js"; const baseImage = "mcr.microsoft.com/devcontainers/base:debian"; -const remoteUser = "verify"; +export const remoteUser = "verify"; const $$docker = $({ reject: false, stdin: "ignore", stderr: "ignore" }); const $$capture = $({ reject: false, stdio: ["ignore", "pipe", "pipe"], verbose: "full", cwd: projectRoot }); diff --git a/scripts/verify/dotnet/selection.ts b/scripts/verify/dotnet/selection.ts index f40aacb..5e01092 100644 --- a/scripts/verify/dotnet/selection.ts +++ b/scripts/verify/dotnet/selection.ts @@ -1,5 +1,5 @@ import { readFile } from "node:fs/promises"; -import path from "node:path"; +import path, { posix } from "node:path"; import { z } from "zod"; @@ -8,9 +8,14 @@ import type { Component, FeatureConfig } from "@/scripts/tasks/features/dotnet/g import { configPath, readConfig } from "@/scripts/tasks/features/dotnet/generateConfig.js"; import { canonicalTag, imageRef } from "@/scripts/tasks/features/dotnet/tags.js"; +import { remoteUser } from "./lifecycle.js"; + const userInitFeaturePath = path.resolve(projectRoot, ".devcontainer/features/src/user-init/devcontainer-feature.json"); +const dataRootSource = "XDG_DATA_HOME-${devcontainerId}"; -const userInitFeatureSchema = z.object({ containerEnv: z.object({ XDG_DATA_HOME: z.string() }) }); +const userInitFeatureSchema = z.object({ + mounts: z.array(z.object({ source: z.string(), target: z.string() })), +}); export interface ComponentImage { component: Component; @@ -57,7 +62,11 @@ const readGlobalPackagesPath = async (): Promise => { if (!result.success) { throw new Error(`${userInitFeaturePath}: ${z.prettifyError(result.error)}`); } - return `${result.data.containerEnv.XDG_DATA_HOME}/NuGet/global-packages`; + const dataRoot = result.data.mounts.find((mount) => mount.source === dataRootSource)?.target; + if (dataRoot === undefined) { + throw new Error(`${userInitFeaturePath}: no mounts entry with source ${dataRootSource}`); + } + return posix.join(dataRoot, remoteUser, "NuGet/global-packages"); }; const derive = async (channel: string, prefix: string): Promise => {