From 8ab79aa215c3dda93b6dec76c390c8375ab15878 Mon Sep 17 00:00:00 2001
From: gdlol <.>
Date: Sun, 8 Nov 2026 11:48:45 +0000
Subject: [PATCH 1/2] code-server feature
---
.config/cspell/cspell.json | 7 +-
.config/dotnet/.globalconfig | 1 +
.config/dotnet/Format.targets | 7 +-
.github/workflows/code-server.yml | 27 ++
.github/workflows/renovate.yml | 2 +-
features/Features/OptionsSchema.cs | 19 +-
features/code-server/Dockerfile | 9 +
features/code-server/Feature/Feature.csproj | 20 ++
features/code-server/Feature/Install.cs | 19 ++
features/code-server/Feature/PostStart.cs | 180 +++++++++++++
features/code-server/Feature/Program.cs | 33 +++
features/code-server/NOTES.md | 53 ++++
features/code-server/Options/Options.cs | 164 ++++++++++++
features/code-server/Options/Options.csproj | 11 +
features/code-server/Options/Program.cs | 6 +
features/code-server/README.md | 96 +++++++
features/code-server/config.json | 18 ++
features/code-server/feature.json | 76 ++++++
features/code-server/install | 2 +
features/code-server/launch | 13 +
features/code-server/postStart | 2 +
features/dotnet/Feature/Registration.cs | 8 +-
scripts/tasks/features/architecture.ts | 11 +
scripts/tasks/features/code-server/feature.ts | 33 +++
.../features/code-server/generateConfig.ts | 59 +++++
.../tasks/features/code-server/packBinary.ts | 13 +
scripts/tasks/features/code-server/payload.ts | 47 ++++
scripts/tasks/features/config.ts | 46 ++++
.../tasks/features/dotnet/generateConfig.ts | 34 +--
scripts/tasks/features/featureBinary.ts | 27 +-
scripts/tasks/features/features.ts | 8 +-
scripts/tasks/features/githubReleases.ts | 120 +++++++++
.../tasks/features/ripgrep/generateConfig.ts | 152 ++---------
scripts/tasks/features/ripgrep/payload.ts | 51 +---
scripts/tasks/features/tarball.ts | 41 +++
scripts/tasks/features/uv/feature.ts | 4 +-
scripts/tasks/features/uv/generateConfig.ts | 34 +--
scripts/tasks/features/uv/packBinary.ts | 27 +-
scripts/tasks/features/uv/paths.ts | 8 +-
scripts/tasks/features/uv/payload.ts | 38 +--
scripts/tasks/imageFeatures.ts | 11 +-
.../code-server/assets/options-unknown.json | 3 +
scripts/verify/code-server/scenarios.ts | 241 ++++++++++++++++++
scripts/verify/code-server/selection.ts | 38 +++
.../code-server/test-image-options.Dockerfile | 8 +
.../verify/code-server/test-image.Dockerfile | 7 +
scripts/verify/code-server/verify.ts | 32 +++
scripts/verify/dotnet/selection.ts | 29 +--
scripts/verify/userInit.ts | 37 +++
scripts/verify/uv/selection.ts | 30 +--
50 files changed, 1605 insertions(+), 357 deletions(-)
create mode 100644 .github/workflows/code-server.yml
create mode 100644 features/code-server/Dockerfile
create mode 100644 features/code-server/Feature/Feature.csproj
create mode 100644 features/code-server/Feature/Install.cs
create mode 100644 features/code-server/Feature/PostStart.cs
create mode 100644 features/code-server/Feature/Program.cs
create mode 100644 features/code-server/NOTES.md
create mode 100644 features/code-server/Options/Options.cs
create mode 100644 features/code-server/Options/Options.csproj
create mode 100644 features/code-server/Options/Program.cs
create mode 100644 features/code-server/README.md
create mode 100644 features/code-server/config.json
create mode 100644 features/code-server/feature.json
create mode 100755 features/code-server/install
create mode 100755 features/code-server/launch
create mode 100755 features/code-server/postStart
create mode 100644 scripts/tasks/features/code-server/feature.ts
create mode 100644 scripts/tasks/features/code-server/generateConfig.ts
create mode 100644 scripts/tasks/features/code-server/packBinary.ts
create mode 100644 scripts/tasks/features/code-server/payload.ts
create mode 100644 scripts/tasks/features/githubReleases.ts
create mode 100644 scripts/tasks/features/tarball.ts
create mode 100644 scripts/verify/code-server/assets/options-unknown.json
create mode 100644 scripts/verify/code-server/scenarios.ts
create mode 100644 scripts/verify/code-server/selection.ts
create mode 100644 scripts/verify/code-server/test-image-options.Dockerfile
create mode 100644 scripts/verify/code-server/test-image.Dockerfile
create mode 100644 scripts/verify/code-server/verify.ts
create mode 100644 scripts/verify/userInit.ts
diff --git a/.config/cspell/cspell.json b/.config/cspell/cspell.json
index 6019151..6e84fb7 100644
--- a/.config/cspell/cspell.json
+++ b/.config/cspell/cspell.json
@@ -35,6 +35,11 @@
"slsa",
"uvx",
"ndjson",
- "venv"
+ "venv",
+ "cmdline",
+ "GLIBCXX",
+ "libstdc",
+ "noproxy",
+ "pgrep"
]
}
diff --git a/.config/dotnet/.globalconfig b/.config/dotnet/.globalconfig
index 43e49c1..961e394 100644
--- a/.config/dotnet/.globalconfig
+++ b/.config/dotnet/.globalconfig
@@ -1 +1,2 @@
dotnet_diagnostic.IDE0005.severity=warning
+dotnet_diagnostic.IDE0305.severity=warning
diff --git a/.config/dotnet/Format.targets b/.config/dotnet/Format.targets
index ddcc8d5..b39f3bf 100644
--- a/.config/dotnet/Format.targets
+++ b/.config/dotnet/Format.targets
@@ -1,9 +1,12 @@
-
+
-
+
diff --git a/.github/workflows/code-server.yml b/.github/workflows/code-server.yml
new file mode 100644
index 0000000..f831f8d
--- /dev/null
+++ b/.github/workflows/code-server.yml
@@ -0,0 +1,27 @@
+name: "code-server"
+
+on:
+ pull_request:
+ paths:
+ - "features/code-server/config.json"
+ push:
+ branches:
+ - main
+ paths:
+ - "features/code-server/config.json"
+ workflow_dispatch:
+
+concurrency:
+ group: "code-server-${{ github.ref }}"
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+
+jobs:
+ verify:
+ uses: ./.github/workflows/verify.yml
+ permissions:
+ contents: write
+ packages: write
+ with:
+ feature: code-server
+ binaries: true
+ scenarios: '["default", "password", "socket", "args"]'
diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml
index 1258e25..00ca32c 100644
--- a/.github/workflows/renovate.yml
+++ b/.github/workflows/renovate.yml
@@ -15,7 +15,7 @@ jobs:
strategy:
fail-fast: false
matrix:
- feature: [dotnet, ripgrep, uv]
+ feature: [dotnet, ripgrep, code-server, uv]
steps:
- name: Checkout (GitHub)
uses: actions/checkout@v7
diff --git a/features/Features/OptionsSchema.cs b/features/Features/OptionsSchema.cs
index a3abd57..af8027f 100644
--- a/features/Features/OptionsSchema.cs
+++ b/features/Features/OptionsSchema.cs
@@ -1,4 +1,5 @@
using System.ComponentModel;
+using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization.Metadata;
@@ -9,14 +10,26 @@ public static class OptionsSchema
{
private static readonly JsonSchemaExporterOptions ExporterOptions = new()
{
+ TreatNullObliviousAsNonNullable = true,
TransformSchemaNode = (context, node) =>
{
+ if (context.PropertyInfo is not { AttributeProvider: { } provider } property)
+ {
+ return node;
+ }
+ if (provider.GetCustomAttributes(typeof(DescriptionAttribute), false) is [DescriptionAttribute description])
+ {
+ ((JsonObject)node)["description"] = description.Description;
+ }
if (
- context.PropertyInfo?.AttributeProvider?.GetCustomAttributes(typeof(DescriptionAttribute), false)
- is [DescriptionAttribute description]
+ provider.GetCustomAttributes(typeof(DefaultValueAttribute), false)
+ is [DefaultValueAttribute defaultValue]
)
{
- ((JsonObject)node)["description"] = description.Description;
+ ((JsonObject)node)["default"] = JsonSerializer.SerializeToNode(
+ defaultValue.Value,
+ property.Options.GetTypeInfo(property.PropertyType)
+ );
}
return node;
},
diff --git a/features/code-server/Dockerfile b/features/code-server/Dockerfile
new file mode 100644
index 0000000..6c27e42
--- /dev/null
+++ b/features/code-server/Dockerfile
@@ -0,0 +1,9 @@
+FROM scratch AS linux-amd64-base
+FROM scratch AS linux-arm64-base
+
+ARG TARGETOS
+ARG TARGETARCH
+FROM ${TARGETOS}-${TARGETARCH}-base
+ARG TARGETOS
+ARG TARGETARCH
+COPY ${TARGETOS}/${TARGETARCH}/features/code-server/ /features/code-server/
diff --git a/features/code-server/Feature/Feature.csproj b/features/code-server/Feature/Feature.csproj
new file mode 100644
index 0000000..c4661db
--- /dev/null
+++ b/features/code-server/Feature/Feature.csproj
@@ -0,0 +1,20 @@
+
+
+ $(DefaultTargetFramework)
+ Feature
+ Exe
+ DevcontainerConfig.CodeServer.Feature
+ false
+
+
+
+
+
+
+
+
+ true
+ true
+ lld
+
+
diff --git a/features/code-server/Feature/Install.cs b/features/code-server/Feature/Install.cs
new file mode 100644
index 0000000..eb9499b
--- /dev/null
+++ b/features/code-server/Feature/Install.cs
@@ -0,0 +1,19 @@
+using DevcontainerConfig.Features;
+
+namespace DevcontainerConfig.CodeServer.Feature;
+
+internal static class Install
+{
+ private const string BinDir = "/usr/local/bin";
+ private const string LinkPath = "/usr/local/bin/code-server";
+
+ internal static void Run(string stagedDir)
+ {
+ Options options = OptionsFile.Read(stagedDir, OptionsContext.Default.FeatureOptions);
+ options.Validate();
+
+ Directory.CreateDirectory(BinDir);
+ File.Delete(LinkPath);
+ File.CreateSymbolicLink(LinkPath, Path.Join(stagedDir, "code-server", "bin", "code-server"));
+ }
+}
diff --git a/features/code-server/Feature/PostStart.cs b/features/code-server/Feature/PostStart.cs
new file mode 100644
index 0000000..38da603
--- /dev/null
+++ b/features/code-server/Feature/PostStart.cs
@@ -0,0 +1,180 @@
+using System.Diagnostics;
+using System.Globalization;
+using DevcontainerConfig.Features;
+
+namespace DevcontainerConfig.CodeServer.Feature;
+
+internal static class PostStart
+{
+ internal static void Run(string stagedDir)
+ {
+ Options options = OptionsFile.Read(stagedDir, OptionsContext.Default.FeatureOptions);
+ options.Validate();
+
+ string cachePath = XdgPath("XDG_CACHE_HOME", ".cache", "code-server");
+ string statePath = XdgPath("XDG_STATE_HOME", Path.Join(".local", "state"), "code-server");
+ Directory.CreateDirectory(cachePath);
+ Directory.CreateDirectory(statePath);
+ if (options.Socket.Length > 0)
+ {
+ Directory.CreateDirectory(Path.GetDirectoryName(options.Socket)!);
+ }
+
+ string logPath = Path.Join(cachePath, "code-server.log");
+ string pidPath = Path.Join(statePath, "code-server.pid");
+ if (IsRunning(pidPath, stagedDir))
+ {
+ return;
+ }
+
+ ProcessStartInfo startInfo = new(Path.Join(stagedDir, "launch")) { UseShellExecute = false };
+ startInfo.ArgumentList.Add(logPath);
+ startInfo.ArgumentList.Add(Path.Join(stagedDir, "code-server", "bin", "code-server"));
+ foreach (string argument in ArgumentsOf(options))
+ {
+ startInfo.ArgumentList.Add(argument);
+ }
+ SetSecret(startInfo, "PASSWORD", options.PasswordFile);
+ SetSecret(startInfo, "HASHED_PASSWORD", options.HashedPasswordFile);
+ SetSecret(startInfo, "GITHUB_TOKEN", options.GithubAuthTokenFile);
+
+ using Process process = new() { StartInfo = startInfo };
+ try
+ {
+ if (!process.Start())
+ {
+ throw new FeatureException("code-server did not start");
+ }
+ File.WriteAllText(pidPath, $"{process.Id.ToString(CultureInfo.InvariantCulture)}\n");
+ }
+ catch
+ {
+ try
+ {
+ process.Kill(entireProcessTree: true);
+ }
+ catch (InvalidOperationException)
+ {
+ // The process already exited, so there is no server left to stop.
+ }
+ throw;
+ }
+ }
+
+ private static List ArgumentsOf(Options options)
+ {
+ List arguments = ["--auth", options.Auth];
+ if (options.Socket.Length > 0)
+ {
+ arguments.Add("--socket");
+ arguments.Add(options.Socket);
+ arguments.Add("--socket-mode");
+ arguments.Add(options.SocketMode);
+ }
+ else
+ {
+ arguments.Add("--bind-addr");
+ arguments.Add($"{options.Host}:{options.Port.ToString(CultureInfo.InvariantCulture)}");
+ }
+ AddFlag(arguments, options.DisableTelemetry, "--disable-telemetry");
+ AddFlag(arguments, options.DisableUpdateCheck, "--disable-update-check");
+ AddFlag(arguments, options.DisableWorkspaceTrust, "--disable-workspace-trust");
+ AddFlag(arguments, options.DisableFileDownloads, "--disable-file-downloads");
+ AddFlag(arguments, options.DisableFileUploads, "--disable-file-uploads");
+ AddFlag(arguments, options.DisableProxy, "--disable-proxy");
+ AddRepeated(arguments, "--proxy-domain", options.ProxyDomain);
+ if (options.AbsProxyBasePath.Length > 0)
+ {
+ arguments.Add("--abs-proxy-base-path");
+ arguments.Add(options.AbsProxyBasePath);
+ }
+ AddRepeated(arguments, "--trusted-origins", options.TrustedOrigins);
+ if (options.Locale.Length > 0)
+ {
+ arguments.Add("--locale");
+ arguments.Add(options.Locale);
+ }
+ AddFlag(arguments, options.Verbose, "--verbose");
+ arguments.Add(options.Workspace.Length > 0 ? options.Workspace : Directory.GetCurrentDirectory());
+ arguments.AddRange(options.Args);
+ return arguments;
+ }
+
+ private static void AddFlag(List arguments, bool enabled, string flag)
+ {
+ if (enabled)
+ {
+ arguments.Add(flag);
+ }
+ }
+
+ private static void AddRepeated(List arguments, string flag, string[] values)
+ {
+ foreach (string value in values)
+ {
+ arguments.Add(flag);
+ arguments.Add(value);
+ }
+ }
+
+ private static void SetSecret(ProcessStartInfo startInfo, string name, string path)
+ {
+ if (path.Length == 0)
+ {
+ return;
+ }
+ string value;
+ try
+ {
+ value = File.ReadAllText(path).TrimEnd('\r', '\n');
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ throw new FeatureException($"{name} file {path} cannot be read: {e.Message}");
+ }
+ if (value.Length == 0)
+ {
+ throw new FeatureException($"{name} file {path} is empty");
+ }
+ startInfo.Environment[name] = value;
+ }
+
+ private static string XdgPath(string variable, string fallback, string suffix)
+ {
+ string? root = Environment.GetEnvironmentVariable(variable);
+ if (root is null || root.Length == 0 || !Path.IsPathRooted(root))
+ {
+ string home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
+ if (home.Length == 0)
+ {
+ throw new FeatureException($"HOME is not set; cannot resolve {variable}");
+ }
+ root = Path.Join(home, fallback);
+ }
+ return Path.Join(root, suffix);
+ }
+
+ private static bool IsRunning(string pidPath, string stagedDir)
+ {
+ if (!File.Exists(pidPath))
+ {
+ return false;
+ }
+ string text = File.ReadAllText(pidPath).Trim();
+ if (!int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out int pid) || pid <= 0)
+ {
+ return false;
+ }
+ string cmdline;
+ try
+ {
+ cmdline = File.ReadAllText(Path.Join("/proc", pid.ToString(CultureInfo.InvariantCulture), "cmdline"));
+ }
+ catch (Exception e) when (e is IOException or UnauthorizedAccessException)
+ {
+ return false;
+ }
+ string nodePath = Path.Join(stagedDir, "code-server", "lib", "node");
+ return cmdline.Split('\0').Contains(nodePath, StringComparer.Ordinal);
+ }
+}
diff --git a/features/code-server/Feature/Program.cs b/features/code-server/Feature/Program.cs
new file mode 100644
index 0000000..73c895c
--- /dev/null
+++ b/features/code-server/Feature/Program.cs
@@ -0,0 +1,33 @@
+using System.Runtime.Versioning;
+using DevcontainerConfig.CodeServer.Feature;
+
+[assembly: SupportedOSPlatform("linux")]
+
+string hook = args.Length > 0 ? args[0] : "";
+if (hook is not ("install" or "postStart"))
+{
+ Console.Error.WriteLine("usage: Feature ");
+ return 2;
+}
+
+string stagedDir = Environment.GetEnvironmentVariable("FEATURES_DIR") is { Length: > 0 } dir
+ ? dir
+ : Directory.GetCurrentDirectory();
+
+try
+{
+ if (hook == "install")
+ {
+ Install.Run(stagedDir);
+ }
+ else
+ {
+ PostStart.Run(stagedDir);
+ }
+ return 0;
+}
+catch (Exception e)
+{
+ Console.Error.WriteLine($"{hook}: {e.Message.ReplaceLineEndings(" ")}");
+ return 1;
+}
diff --git a/features/code-server/NOTES.md b/features/code-server/NOTES.md
new file mode 100644
index 0000000..d12fa06
--- /dev/null
+++ b/features/code-server/NOTES.md
@@ -0,0 +1,53 @@
+## Staging
+
+The consumer copies the feature directory as shown in Example Usage. The `features` dispatcher runs `install` at image build time and `postStart` when the container starts. `postStart` runs as the container user and starts the server through `launch`, which detaches the server's stdio and appends its output to the log, so the lifecycle hook returns.
+
+## Options file
+
+Options are read from `options.json` in the feature directory:
+
+```json
+{
+ "auth": "password",
+ "passwordFile": "/run/secrets/code-server-password",
+ "host": "127.0.0.1",
+ "port": 8080
+}
+```
+
+`passwordFile`, `hashedPasswordFile` and `githubAuthTokenFile` are read at `postStart` into `PASSWORD`, `HASHED_PASSWORD` and `GITHUB_TOKEN`. Trailing newlines are removed. `HASHED_PASSWORD` takes precedence over `PASSWORD`.
+
+`workspace` is the positional workspace path. An empty value uses the hook's current directory, the workspace folder.
+
+## Authentication
+
+`auth` defaults to `none`, which gives anything that can reach the listen address the remote user's access. `host` defaults to `127.0.0.1`, so the server accepts connections from inside the container only. A consumer that publishes the port sets `host` to `0.0.0.0` and uses `auth: password` or terminates access at a reverse proxy.
+
+## Address
+
+With `socket` set, the server listens on that absolute Unix socket with `socketMode` as its file mode. `host` and `port` are ignored. Without `socket`, the server listens on `host:port`.
+
+## Paths
+
+- Log: `${XDG_CACHE_HOME:-$HOME/.cache}/code-server/code-server.log`
+- PID: `${XDG_STATE_HOME:-$HOME/.local/state}/code-server/code-server.pid`
+
+An unset, empty or relative XDG value uses the `$HOME` path.
+
+## Arguments
+
+`args` is an array of code-server arguments appended after the option-derived arguments:
+
+```json
+{ "args": ["--verbose", "--bind-addr", "0.0.0.0:9000"] }
+```
+
+One element is one argument. A later scalar flag wins in code-server, so `args` can override an option. `--` ends option parsing.
+
+## Requirements
+
+Linux with glibc 2.28 or newer and libstdc++ GLIBCXX_3.4.21, on amd64 or arm64. The feature installs no packages and adds no dependencies.
+
+## Restart
+
+A plain `docker restart` does not run `postStart`. Use `devcontainer up` or start `/usr/local/bin/code-server` manually.
diff --git a/features/code-server/Options/Options.cs b/features/code-server/Options/Options.cs
new file mode 100644
index 0000000..bc46a1d
--- /dev/null
+++ b/features/code-server/Options/Options.cs
@@ -0,0 +1,164 @@
+using System.ComponentModel;
+using System.Text.Json.Serialization;
+using DevcontainerConfig.Features;
+
+namespace DevcontainerConfig.CodeServer;
+
+public sealed class Options(
+ string auth = "none",
+ string host = "127.0.0.1",
+ int port = 8080,
+ string socket = "",
+ string socketMode = "0600",
+ string workspace = "",
+ string passwordFile = "",
+ string hashedPasswordFile = "",
+ string githubAuthTokenFile = "",
+ bool disableTelemetry = true,
+ bool disableUpdateCheck = true,
+ bool disableWorkspaceTrust = true,
+ bool disableFileDownloads = false,
+ bool disableFileUploads = false,
+ string[] proxyDomain = null!,
+ string absProxyBasePath = "",
+ string[] trustedOrigins = null!,
+ bool disableProxy = false,
+ string locale = "",
+ bool verbose = false,
+ string[] args = null!
+)
+{
+ [JsonPropertyName("auth")]
+ [Description(
+ "The authentication type, none or password. The default none gives anything that can reach the listen address the remote user's access."
+ )]
+ public string Auth { get; } = auth;
+
+ [JsonPropertyName("host")]
+ [Description("The address in --bind-addr when socket is empty. The default keeps the server on loopback.")]
+ public string Host { get; } = host;
+
+ [JsonPropertyName("port")]
+ [Description("The port in --bind-addr when socket is empty.")]
+ public int Port { get; } = port;
+
+ [JsonPropertyName("socket")]
+ [Description("An absolute Unix socket path. When set, the socket is used instead of host and port.")]
+ public string Socket { get; } = socket;
+
+ [JsonPropertyName("socketMode")]
+ [Description("The file mode of the Unix socket, as octal digits. Used only when socket is set.")]
+ public string SocketMode { get; } = socketMode;
+
+ [JsonPropertyName("workspace")]
+ [Description("The positional workspace path. An empty value uses the hook's current directory.")]
+ public string Workspace { get; } = workspace;
+
+ [JsonPropertyName("passwordFile")]
+ [Description("A file read at postStart into PASSWORD. Trailing newlines are removed.")]
+ public string PasswordFile { get; } = passwordFile;
+
+ [JsonPropertyName("hashedPasswordFile")]
+ [Description("A file read at postStart into HASHED_PASSWORD. Takes precedence over passwordFile.")]
+ public string HashedPasswordFile { get; } = hashedPasswordFile;
+
+ [JsonPropertyName("githubAuthTokenFile")]
+ [Description("A file read at postStart into GITHUB_TOKEN.")]
+ public string GithubAuthTokenFile { get; } = githubAuthTokenFile;
+
+ [JsonPropertyName("disableTelemetry")]
+ [Description("Pass --disable-telemetry.")]
+ public bool DisableTelemetry { get; } = disableTelemetry;
+
+ [JsonPropertyName("disableUpdateCheck")]
+ [Description("Pass --disable-update-check.")]
+ public bool DisableUpdateCheck { get; } = disableUpdateCheck;
+
+ [JsonPropertyName("disableWorkspaceTrust")]
+ [Description("Pass --disable-workspace-trust.")]
+ public bool DisableWorkspaceTrust { get; } = disableWorkspaceTrust;
+
+ [JsonPropertyName("disableFileDownloads")]
+ [Description("Pass --disable-file-downloads.")]
+ public bool DisableFileDownloads { get; } = disableFileDownloads;
+
+ [JsonPropertyName("disableFileUploads")]
+ [Description("Pass --disable-file-uploads.")]
+ public bool DisableFileUploads { get; } = disableFileUploads;
+
+ [JsonPropertyName("proxyDomain")]
+ [Description("One --proxy-domain argument per element.")]
+ [DefaultValue(new string[] { })]
+ public string[] ProxyDomain { get; } = proxyDomain ?? [];
+
+ [JsonPropertyName("absProxyBasePath")]
+ [Description("Pass --abs-proxy-base-path.")]
+ public string AbsProxyBasePath { get; } = absProxyBasePath;
+
+ [JsonPropertyName("trustedOrigins")]
+ [Description("One --trusted-origins argument per element.")]
+ [DefaultValue(new string[] { })]
+ public string[] TrustedOrigins { get; } = trustedOrigins ?? [];
+
+ [JsonPropertyName("disableProxy")]
+ [Description("Pass --disable-proxy.")]
+ public bool DisableProxy { get; } = disableProxy;
+
+ [JsonPropertyName("locale")]
+ [Description("Pass --locale.")]
+ public string Locale { get; } = locale;
+
+ [JsonPropertyName("verbose")]
+ [Description("Pass --verbose.")]
+ public bool Verbose { get; } = verbose;
+
+ [JsonPropertyName("args")]
+ [Description("One code-server argument per element, appended after the option-derived arguments.")]
+ [DefaultValue(new string[] { })]
+ public string[] Args { get; } = args ?? [];
+
+ public void Validate()
+ {
+ if (Auth is not ("none" or "password"))
+ {
+ throw new FeatureException($"auth must be none or password, got {Auth}");
+ }
+ if (Port is < 1 or > 65535)
+ {
+ throw new FeatureException($"port must be between 1 and 65535, got {Port}");
+ }
+ if (Socket.Length > 0 && !Path.IsPathRooted(Socket))
+ {
+ throw new FeatureException($"socket must be an absolute path, got {Socket}");
+ }
+ if (Socket.Length > 0 && !IsOctalMode(SocketMode))
+ {
+ throw new FeatureException($"socketMode must be one to four octal digits, got {SocketMode}");
+ }
+ if (Socket.Length > 0 && Path.GetDirectoryName(Socket) is not { Length: > 0 })
+ {
+ throw new FeatureException($"socket must include a parent directory, got {Socket}");
+ }
+ ValidateElements("proxyDomain", ProxyDomain);
+ ValidateElements("trustedOrigins", TrustedOrigins);
+ ValidateElements("args", Args);
+ }
+
+ private static void ValidateElements(string name, string[] values)
+ {
+ if (Array.Exists(values, value => value is null))
+ {
+ throw new FeatureException($"{name} must not contain null elements");
+ }
+ }
+
+ private static bool IsOctalMode(string value) =>
+ value.Length is >= 1 and <= 4 && value.All(digit => digit is >= '0' and <= '7');
+}
+
+[JsonSourceGenerationOptions(
+ UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
+ RespectNullableAnnotations = true
+)]
+[JsonSerializable(typeof(Options), TypeInfoPropertyName = "FeatureOptions")]
+public sealed partial class OptionsContext : JsonSerializerContext;
diff --git a/features/code-server/Options/Options.csproj b/features/code-server/Options/Options.csproj
new file mode 100644
index 0000000..19a84c5
--- /dev/null
+++ b/features/code-server/Options/Options.csproj
@@ -0,0 +1,11 @@
+
+
+ $(DefaultTargetFramework)
+ Exe
+ DevcontainerConfig.CodeServer
+
+
+
+
+
+
diff --git a/features/code-server/Options/Program.cs b/features/code-server/Options/Program.cs
new file mode 100644
index 0000000..fa1472a
--- /dev/null
+++ b/features/code-server/Options/Program.cs
@@ -0,0 +1,6 @@
+using System.Text.Json.Nodes;
+using DevcontainerConfig.CodeServer;
+using DevcontainerConfig.Features;
+
+JsonNode schema = OptionsSchema.Of(OptionsContext.Default.FeatureOptions);
+Console.WriteLine(schema.ToJsonString());
diff --git a/features/code-server/README.md b/features/code-server/README.md
new file mode 100644
index 0000000..2771f4d
--- /dev/null
+++ b/features/code-server/README.md
@@ -0,0 +1,96 @@
+# code-server
+
+Installs the code-server standalone release and starts the server from postStart.
+
+## Example Usage
+
+```dockerfile
+FROM ghcr.io/devcontainer-config/features/code-server AS code-server
+
+FROM base-image
+COPY --from=code-server /features/code-server/ /opt/devcontainer-config/features/code-server/
+```
+
+## Options
+
+| Options Id | Description | Type | Default Value |
+| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------- |
+| auth | The authentication type, none or password. The default none gives anything that can reach the listen address the remote user's access. | string | none |
+| host | The address in --bind-addr when socket is empty. The default keeps the server on loopback. | string | 127.0.0.1 |
+| port | The port in --bind-addr when socket is empty. | integer | 8080 |
+| socket | An absolute Unix socket path. When set, the socket is used instead of host and port. | string | |
+| socketMode | The file mode of the Unix socket, as octal digits. Used only when socket is set. | string | 0600 |
+| workspace | The positional workspace path. An empty value uses the hook's current directory. | string | |
+| passwordFile | A file read at postStart into PASSWORD. Trailing newlines are removed. | string | |
+| hashedPasswordFile | A file read at postStart into HASHED_PASSWORD. Takes precedence over passwordFile. | string | |
+| githubAuthTokenFile | A file read at postStart into GITHUB_TOKEN. | string | |
+| disableTelemetry | Pass --disable-telemetry. | boolean | true |
+| disableUpdateCheck | Pass --disable-update-check. | boolean | true |
+| disableWorkspaceTrust | Pass --disable-workspace-trust. | boolean | true |
+| disableFileDownloads | Pass --disable-file-downloads. | boolean | false |
+| disableFileUploads | Pass --disable-file-uploads. | boolean | false |
+| proxyDomain | One --proxy-domain argument per element. | array | [] |
+| absProxyBasePath | Pass --abs-proxy-base-path. | string | |
+| trustedOrigins | One --trusted-origins argument per element. | array | [] |
+| disableProxy | Pass --disable-proxy. | boolean | false |
+| locale | Pass --locale. | string | |
+| verbose | Pass --verbose. | boolean | false |
+| args | One code-server argument per element, appended after the option-derived arguments. | array | [] |
+
+## Staging
+
+The consumer copies the feature directory as shown in Example Usage. The `features` dispatcher runs `install` at image build time and `postStart` when the container starts. `postStart` runs as the container user and starts the server through `launch`, which detaches the server's stdio and appends its output to the log, so the lifecycle hook returns.
+
+## Options file
+
+Options are read from `options.json` in the feature directory:
+
+```json
+{
+ "auth": "password",
+ "passwordFile": "/run/secrets/code-server-password",
+ "host": "127.0.0.1",
+ "port": 8080
+}
+```
+
+`passwordFile`, `hashedPasswordFile` and `githubAuthTokenFile` are read at `postStart` into `PASSWORD`, `HASHED_PASSWORD` and `GITHUB_TOKEN`. Trailing newlines are removed. `HASHED_PASSWORD` takes precedence over `PASSWORD`.
+
+`workspace` is the positional workspace path. An empty value uses the hook's current directory, the workspace folder.
+
+## Authentication
+
+`auth` defaults to `none`, which gives anything that can reach the listen address the remote user's access. `host` defaults to `127.0.0.1`, so the server accepts connections from inside the container only. A consumer that publishes the port sets `host` to `0.0.0.0` and uses `auth: password` or terminates access at a reverse proxy.
+
+## Address
+
+With `socket` set, the server listens on that absolute Unix socket with `socketMode` as its file mode. `host` and `port` are ignored. Without `socket`, the server listens on `host:port`.
+
+## Paths
+
+- Log: `${XDG_CACHE_HOME:-$HOME/.cache}/code-server/code-server.log`
+- PID: `${XDG_STATE_HOME:-$HOME/.local/state}/code-server/code-server.pid`
+
+An unset, empty or relative XDG value uses the `$HOME` path.
+
+## Arguments
+
+`args` is an array of code-server arguments appended after the option-derived arguments:
+
+```json
+{ "args": ["--verbose", "--bind-addr", "0.0.0.0:9000"] }
+```
+
+One element is one argument. A later scalar flag wins in code-server, so `args` can override an option. `--` ends option parsing.
+
+## Requirements
+
+Linux with glibc 2.28 or newer and libstdc++ GLIBCXX_3.4.21, on amd64 or arm64. The feature installs no packages and adds no dependencies.
+
+## Restart
+
+A plain `docker restart` does not run `postStart`. Use `devcontainer up` or start `/usr/local/bin/code-server` manually.
+
+---
+
+_Note: This file was auto-generated from the [feature.json](https://github.com/devcontainer-config/features/blob/main/features/code-server/feature.json). Add additional notes to a `NOTES.md`._
diff --git a/features/code-server/config.json b/features/code-server/config.json
new file mode 100644
index 0000000..36de4e7
--- /dev/null
+++ b/features/code-server/config.json
@@ -0,0 +1,18 @@
+{
+ "revision": 1,
+ "versions": {
+ "4.145.2": {
+ "linux-x64": {
+ "url": "https://github.com/coder/code-server/releases/download/v4.145.2/code-server-4.145.2-linux-amd64.tar.gz",
+ "sha256": "2f6db6c3e8b733dd6b3aeee83532e148b2bae2e392353ba85452d03572348962"
+ },
+ "linux-arm64": {
+ "url": "https://github.com/coder/code-server/releases/download/v4.145.2/code-server-4.145.2-linux-arm64.tar.gz",
+ "sha256": "e867a34416e2da4a83496b09580001893e7a4e5bb58a0624c4fcd22819e7d53d"
+ }
+ }
+ },
+ "aliases": {
+ "4.145.2": ["4.145", "4", "latest"]
+ }
+}
diff --git a/features/code-server/feature.json b/features/code-server/feature.json
new file mode 100644
index 0000000..b341fbf
--- /dev/null
+++ b/features/code-server/feature.json
@@ -0,0 +1,76 @@
+{
+ "id": "code-server",
+ "name": "code-server",
+ "description": "Installs the code-server standalone release and starts the server from postStart.",
+ "image": "ghcr.io/devcontainer-config/features/code-server",
+ "options": {
+ "auth": {
+ "type": "string",
+ "default": "none",
+ "description": "The authentication type, none or password. The default none gives anything that can reach the listen address the remote user's access."
+ },
+ "host": {
+ "type": "string",
+ "default": "127.0.0.1",
+ "description": "The address in --bind-addr when socket is empty. The default keeps the server on loopback."
+ },
+ "port": { "type": "integer", "default": 8080, "description": "The port in --bind-addr when socket is empty." },
+ "socket": {
+ "type": "string",
+ "default": "",
+ "description": "An absolute Unix socket path. When set, the socket is used instead of host and port."
+ },
+ "socketMode": {
+ "type": "string",
+ "default": "0600",
+ "description": "The file mode of the Unix socket, as octal digits. Used only when socket is set."
+ },
+ "workspace": {
+ "type": "string",
+ "default": "",
+ "description": "The positional workspace path. An empty value uses the hook's current directory."
+ },
+ "passwordFile": {
+ "type": "string",
+ "default": "",
+ "description": "A file read at postStart into PASSWORD. Trailing newlines are removed."
+ },
+ "hashedPasswordFile": {
+ "type": "string",
+ "default": "",
+ "description": "A file read at postStart into HASHED_PASSWORD. Takes precedence over passwordFile."
+ },
+ "githubAuthTokenFile": {
+ "type": "string",
+ "default": "",
+ "description": "A file read at postStart into GITHUB_TOKEN."
+ },
+ "disableTelemetry": { "type": "boolean", "default": true, "description": "Pass --disable-telemetry." },
+ "disableUpdateCheck": { "type": "boolean", "default": true, "description": "Pass --disable-update-check." },
+ "disableWorkspaceTrust": { "type": "boolean", "default": true, "description": "Pass --disable-workspace-trust." },
+ "disableFileDownloads": { "type": "boolean", "default": false, "description": "Pass --disable-file-downloads." },
+ "disableFileUploads": { "type": "boolean", "default": false, "description": "Pass --disable-file-uploads." },
+ "proxyDomain": {
+ "type": "array",
+ "items": { "type": "string" },
+ "default": [],
+ "description": "One --proxy-domain argument per element."
+ },
+ "absProxyBasePath": { "type": "string", "default": "", "description": "Pass --abs-proxy-base-path." },
+ "trustedOrigins": {
+ "type": "array",
+ "items": { "type": "string" },
+ "default": [],
+ "description": "One --trusted-origins argument per element."
+ },
+ "disableProxy": { "type": "boolean", "default": false, "description": "Pass --disable-proxy." },
+ "locale": { "type": "string", "default": "", "description": "Pass --locale." },
+ "verbose": { "type": "boolean", "default": false, "description": "Pass --verbose." },
+ "args": {
+ "type": "array",
+ "items": { "type": "string" },
+ "default": [],
+ "description": "One code-server argument per element, appended after the option-derived arguments."
+ }
+ }
+}
diff --git a/features/code-server/install b/features/code-server/install
new file mode 100755
index 0000000..9b4448a
--- /dev/null
+++ b/features/code-server/install
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec "$(dirname "$0")/Feature" install
diff --git a/features/code-server/launch b/features/code-server/launch
new file mode 100755
index 0000000..0743ebd
--- /dev/null
+++ b/features/code-server/launch
@@ -0,0 +1,13 @@
+#!/bin/sh
+# launch [args...]
+#
+# Runs the command with its stdio detached from the caller's: stdin reads EOF,
+# and stdout and stderr append to . The postStart hook returns at once and
+# the devcontainer CLI closes the hook's pipes with it, so the server must not
+# hold them.
+#
+# exec replaces this shell, so the PID the caller records is the command's.
+set -eu
+log=$1
+shift
+exec "$@" < /dev/null >> "$log" 2>&1
diff --git a/features/code-server/postStart b/features/code-server/postStart
new file mode 100755
index 0000000..82d902b
--- /dev/null
+++ b/features/code-server/postStart
@@ -0,0 +1,2 @@
+#!/bin/sh
+exec "$(dirname "$0")/Feature" postStart
diff --git a/features/dotnet/Feature/Registration.cs b/features/dotnet/Feature/Registration.cs
index ae44991..3c84fbc 100644
--- a/features/dotnet/Feature/Registration.cs
+++ b/features/dotnet/Feature/Registration.cs
@@ -1,5 +1,3 @@
-using DevcontainerConfig.Features;
-
namespace DevcontainerConfig.Dotnet.Feature;
internal static class Registration
@@ -26,13 +24,9 @@ private static void WriteInstallLocation(string root)
private static void CreateSymlink(string root)
{
string target = Path.Join(root, "dotnet");
- if (Directory.Exists(LinkPath))
- {
- throw new FeatureException($"{LinkPath} is a directory.");
- }
// rename(2) replaces whatever else the path holds (absent, symlink or regular file) and never resolves the
- // destination, so the link lands in one step.
+ // destination, so the link lands in one step; a real directory fails the rename.
string temp = Path.Join(Path.GetDirectoryName(LinkPath), $".dotnet-{Guid.NewGuid():N}");
File.CreateSymbolicLink(temp, target);
try
diff --git a/scripts/tasks/features/architecture.ts b/scripts/tasks/features/architecture.ts
index ba5f55b..d5e894d 100644
--- a/scripts/tasks/features/architecture.ts
+++ b/scripts/tasks/features/architecture.ts
@@ -31,3 +31,14 @@ export const isFile = async (candidate: string): Promise => {
throw error;
}
};
+
+export const isDirectory = async (candidate: string): Promise => {
+ try {
+ return (await stat(candidate)).isDirectory();
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return false;
+ }
+ throw error;
+ }
+};
diff --git a/scripts/tasks/features/code-server/feature.ts b/scripts/tasks/features/code-server/feature.ts
new file mode 100644
index 0000000..24eff06
--- /dev/null
+++ b/scripts/tasks/features/code-server/feature.ts
@@ -0,0 +1,33 @@
+import type { ConfigDocument } from "@/scripts/tasks/features/config.js";
+import type { Feature, FeatureStage } from "@/scripts/tasks/features/feature.js";
+
+import type { FeatureConfig } from "./generateConfig.js";
+import { configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js";
+import { packFeatureBinary } from "./packBinary.js";
+import { derive, ensurePayload, loadSection, payloadPresent } from "./payload.js";
+
+const component = "code-server";
+
+export const documentOf = (config: FeatureConfig): ConfigDocument => ({
+ revision: config.revision,
+ namespaces: [{ name: component, versions: config.versions, aliases: config.aliases }],
+});
+
+const stage: FeatureStage = { subdir: ["code-server"], files: ["install", "launch", "postStart"], binary: true };
+
+export const codeServer: Feature = {
+ id: "code-server",
+ configPath,
+ components: [component],
+ document: async () => documentOf(await readConfig()),
+ parseDocument: (text, source) => documentOf(parseConfig(text, source)),
+ generateConfig,
+ repository: () => "code-server",
+ stage: () => stage,
+ payload: {
+ ensure: async (_component, key, arch, force) => ensurePayload(await loadSection(key), arch, force),
+ present: async (_component, key, arch) => payloadPresent(derive(await loadSection(key), arch)),
+ },
+ binary: { ensure: packFeatureBinary },
+ gitTag: (_component, canonical) => `features-code-server-${canonical}`,
+};
diff --git a/scripts/tasks/features/code-server/generateConfig.ts b/scripts/tasks/features/code-server/generateConfig.ts
new file mode 100644
index 0000000..e663162
--- /dev/null
+++ b/scripts/tasks/features/code-server/generateConfig.ts
@@ -0,0 +1,59 @@
+import path from "node:path";
+
+import { z } from "zod";
+
+import { projectRoot } from "@/scripts/project.js";
+import { checkTagNamespace, configFile } from "@/scripts/tasks/features/config.js";
+
+import type { TrackedRelease } from "../githubReleases.js";
+import { artifactOf, trackedRelease, versionAliases } from "../githubReleases.js";
+
+export const configPath = path.resolve(projectRoot, "features/code-server/config.json");
+
+const artifactSchema = z.object({ url: z.string(), sha256: z.string() });
+
+const versionEntrySchema = z.strictObject({
+ "linux-x64": artifactSchema,
+ "linux-arm64": artifactSchema,
+});
+
+const aliasTableSchema = z.record(z.string(), z.array(z.string()));
+
+export const featureConfigSchema = z.strictObject({
+ revision: z.int().min(1),
+ versions: z.record(z.string(), versionEntrySchema),
+ aliases: aliasTableSchema,
+});
+
+export type VersionEntry = z.infer;
+export type FeatureConfig = z.infer;
+
+const validateConfig = (config: FeatureConfig, source: string): void => {
+ checkTagNamespace({
+ source,
+ revision: config.revision,
+ versions: config.versions,
+ aliases: config.aliases,
+ requiredClaims: ["latest"],
+ });
+};
+
+const config = configFile({ path: configPath, schema: featureConfigSchema, validate: validateConfig });
+
+export const parseConfig = config.parse;
+export const readConfig = config.read;
+
+const versionEntry = (release: TrackedRelease): VersionEntry => ({
+ "linux-x64": artifactOf(release, `code-server-${release.version}-linux-amd64.tar.gz`),
+ "linux-arm64": artifactOf(release, `code-server-${release.version}-linux-arm64.tar.gz`),
+});
+
+export const generateConfig = async (): Promise => {
+ const release = await trackedRelease("coder/code-server");
+ const document: FeatureConfig = {
+ revision: await config.revision(),
+ versions: { [release.version]: versionEntry(release) },
+ aliases: { [release.version]: versionAliases(release) },
+ };
+ await config.write(document);
+};
diff --git a/scripts/tasks/features/code-server/packBinary.ts b/scripts/tasks/features/code-server/packBinary.ts
new file mode 100644
index 0000000..75ce0e4
--- /dev/null
+++ b/scripts/tasks/features/code-server/packBinary.ts
@@ -0,0 +1,13 @@
+import path from "node:path";
+
+import { projectRoot, workspaces } from "@/scripts/project.js";
+
+import type { Rid } from "../architecture.js";
+import { ensureFeatureBinary } from "../featureBinary.js";
+
+const csproj = path.resolve(projectRoot, "features/code-server/Feature/Feature.csproj");
+
+const packPath = (rid: Rid): string => path.join(workspaces, "bin/code-server/Feature", rid);
+
+export const packFeatureBinary = (rid: Rid, force: boolean): Promise =>
+ ensureFeatureBinary(csproj, packPath(rid), rid, force);
diff --git a/scripts/tasks/features/code-server/payload.ts b/scripts/tasks/features/code-server/payload.ts
new file mode 100644
index 0000000..9e46bed
--- /dev/null
+++ b/scripts/tasks/features/code-server/payload.ts
@@ -0,0 +1,47 @@
+import type { DockerArch } from "../architecture.js";
+import { isDirectory, ridFor } from "../architecture.js";
+import { payloadPath } from "../paths.js";
+import type { Tarball } from "../tarball.js";
+import { stageTarball } from "../tarball.js";
+import type { FeatureConfig, VersionEntry } from "./generateConfig.js";
+import { configPath, readConfig } from "./generateConfig.js";
+
+export interface PayloadSection {
+ key: string;
+ entry: VersionEntry;
+}
+
+export interface Payload {
+ cache: string;
+ tarball: Tarball;
+}
+
+export const sectionOf = (config: FeatureConfig, key: string): PayloadSection => {
+ const entry = config.versions[key];
+ if (entry === undefined) {
+ throw new Error(`Version ${key} is not in ${configPath}`);
+ }
+ return { key, entry };
+};
+
+export const loadSection = async (key: string): Promise => sectionOf(await readConfig(), key);
+
+export const derive = (section: PayloadSection, arch: DockerArch): Payload => {
+ const cache = payloadPath("code-server", "code-server", section.key, arch);
+ const archive = section.entry[ridFor(arch)];
+ return {
+ cache,
+ tarball: { cache, url: archive.url, sha256: archive.sha256 },
+ };
+};
+
+export const payloadPresent = (payload: Payload): Promise => isDirectory(payload.cache);
+
+export const ensurePayload = async (section: PayloadSection, arch: DockerArch, force: boolean): Promise => {
+ const payload = derive(section, arch);
+ if (!force && (await payloadPresent(payload))) {
+ console.log(`Payload already present at ${payload.cache}; skipping fetch`);
+ return;
+ }
+ await stageTarball(payload.tarball);
+};
diff --git a/scripts/tasks/features/config.ts b/scripts/tasks/features/config.ts
index 61be1a2..53b1d10 100644
--- a/scripts/tasks/features/config.ts
+++ b/scripts/tasks/features/config.ts
@@ -1,4 +1,7 @@
+import { readFile, writeFile } from "node:fs/promises";
+
import * as prettier from "prettier";
+import { z } from "zod";
import prettierOptions from "@/.config/prettier/.prettierrc.json" with { type: "json" };
@@ -103,3 +106,46 @@ export const checkTagNamespace = (namespace: TagNamespace): void => {
export const formatConfig = async (document: unknown, configPath: string): Promise =>
prettier.format(JSON.stringify(document, null, 2), { ...prettierOptions, filepath: configPath });
+
+export interface ConfigFile {
+ parse: (text: string, source: string) => T;
+ read: () => Promise;
+ /** The document's revision on disk, 1 when the file does not exist yet. */
+ revision: () => Promise;
+ /** Validates the document, then writes it canonically. */
+ write: (config: T) => Promise;
+}
+
+export const configFile = >(options: {
+ path: string;
+ schema: S;
+ validate: (config: z.infer, source: string) => void;
+}): ConfigFile> => {
+ const parse = (text: string, source: string): z.infer => {
+ const result = options.schema.safeParse(JSON.parse(text));
+ if (!result.success) {
+ throw new Error(`${source}: ${z.prettifyError(result.error)}`);
+ }
+ options.validate(result.data, source);
+ return result.data;
+ };
+ const read = async (): Promise> => parse(await readFile(options.path, "utf-8"), options.path);
+ return {
+ parse,
+ read,
+ revision: async () => {
+ try {
+ return (await read()).revision;
+ } catch (error) {
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") {
+ return 1;
+ }
+ throw error;
+ }
+ },
+ write: async (config) => {
+ options.validate(config, options.path);
+ await writeFile(options.path, await formatConfig(config, options.path));
+ },
+ };
+};
diff --git a/scripts/tasks/features/dotnet/generateConfig.ts b/scripts/tasks/features/dotnet/generateConfig.ts
index 9df887b..97a03ed 100644
--- a/scripts/tasks/features/dotnet/generateConfig.ts
+++ b/scripts/tasks/features/dotnet/generateConfig.ts
@@ -1,10 +1,9 @@
-import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { projectRoot } from "@/scripts/project.js";
-import { checkTagNamespace, formatConfig } from "@/scripts/tasks/features/config.js";
+import { checkTagNamespace, configFile } from "@/scripts/tasks/features/config.js";
const releasesIndexUrl = "https://builds.dotnet.microsoft.com/dotnet/release-metadata/releases-index.json";
export const configPath = path.resolve(projectRoot, "features/dotnet/config.json");
@@ -208,40 +207,21 @@ const validateConfig = (config: FeatureConfig, source: string): void => {
}
};
-export const parseConfig = (text: string, source: string): FeatureConfig => {
- const result = featureConfigSchema.safeParse(JSON.parse(text));
- if (!result.success) {
- throw new Error(`${source}: ${z.prettifyError(result.error)}`);
- }
- validateConfig(result.data, source);
- return result.data;
-};
+const config = configFile({ path: configPath, schema: featureConfigSchema, validate: validateConfig });
-export const readConfig = async (): Promise =>
- parseConfig(await readFile(configPath, "utf-8"), configPath);
-
-const readRevision = async (): Promise => {
- try {
- return (await readConfig()).revision;
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
- return 1;
- }
- throw error;
- }
-};
+export const parseConfig = config.parse;
+export const readConfig = config.read;
export const generateConfig = async (): Promise => {
const index = await fetchJson(releasesIndexUrl, releasesIndexSchema);
const channelData = await fetchChannelData(selectChannels(index));
- const config: FeatureConfig = {
- revision: await readRevision(),
+ const document: FeatureConfig = {
+ revision: await config.revision(),
components: {
sdk: resolveComponentData("sdk", channelData),
runtime: resolveComponentData("runtime", channelData),
aspnet: resolveComponentData("aspnet", channelData),
},
};
- validateConfig(config, configPath);
- await writeFile(configPath, await formatConfig(config, configPath));
+ await config.write(document);
};
diff --git a/scripts/tasks/features/featureBinary.ts b/scripts/tasks/features/featureBinary.ts
index 7ffa6f9..d24a22a 100644
--- a/scripts/tasks/features/featureBinary.ts
+++ b/scripts/tasks/features/featureBinary.ts
@@ -1,12 +1,35 @@
import { mkdir, rm } from "node:fs/promises";
import path from "node:path";
-import { extractTar } from "@/scripts/tasks/features/tar.js";
+import { project$$ } from "@/scripts/shell.js";
-import type { DockerArch } from "./architecture.js";
+import type { DockerArch, Rid } from "./architecture.js";
import { dockerArchOptions, hostArch, isFile, ridFor } from "./architecture.js";
import type { Feature } from "./feature.js";
import { requireBinary } from "./feature.js";
+import { extractTar } from "./tar.js";
+
+export const ensureFeatureBinary = async (
+ csproj: string,
+ directory: string,
+ rid: Rid,
+ force: boolean,
+): Promise => {
+ const binaryPath = path.join(directory, "Feature");
+ if (!force && (await isFile(binaryPath))) {
+ return binaryPath;
+ }
+ await project$$`dotnet publish ${[
+ ...["--configuration", "Release"],
+ ...["--runtime", rid],
+ csproj,
+ `-p:PublishDir=${directory}/`,
+ ]}`;
+ if (!(await isFile(binaryPath))) {
+ throw new Error(`dotnet publish did not produce ${binaryPath}`);
+ }
+ return binaryPath;
+};
const localBinary = async (binary: NonNullable, arch: DockerArch): Promise => {
if (arch !== hostArch()) {
diff --git a/scripts/tasks/features/features.ts b/scripts/tasks/features/features.ts
index ce6b38f..ac2ba9f 100644
--- a/scripts/tasks/features/features.ts
+++ b/scripts/tasks/features/features.ts
@@ -1,6 +1,12 @@
+import { codeServer } from "./code-server/feature.js";
import { dotnet } from "./dotnet/feature.js";
import type { Feature } from "./feature.js";
import { ripgrep } from "./ripgrep/feature.js";
import { uv } from "./uv/feature.js";
-export const features: Readonly> = { dotnet, ripgrep, uv };
+export const features: Readonly> = {
+ "code-server": codeServer,
+ dotnet,
+ ripgrep,
+ uv,
+};
diff --git a/scripts/tasks/features/githubReleases.ts b/scripts/tasks/features/githubReleases.ts
new file mode 100644
index 0000000..f395ae2
--- /dev/null
+++ b/scripts/tasks/features/githubReleases.ts
@@ -0,0 +1,120 @@
+import { z } from "zod";
+
+export type Version = readonly [number, number, number];
+
+export interface ReleaseAsset {
+ name: string;
+ url: string;
+ digest: string | null;
+}
+
+export interface TrackedRelease {
+ /** The release tag with its `v` prefix removed. */
+ version: string;
+ number: Version;
+ assets: readonly ReleaseAsset[];
+}
+
+const releaseSchema = z.object({
+ tag_name: z.string(),
+ draft: z.boolean(),
+ prerelease: z.boolean(),
+ assets: z.array(
+ z.object({
+ name: z.string(),
+ browser_download_url: z.string(),
+ digest: z.string().nullish(),
+ }),
+ ),
+});
+
+const releasesPageSchema = z.array(releaseSchema);
+
+const sha256Digest = /^sha256:([0-9a-f]{64})$/u;
+const versionTag = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/u;
+
+const versionOf = (tag: string): { version: string; number: Version } | undefined => {
+ const match = versionTag.exec(tag);
+ if (match === null) {
+ return undefined;
+ }
+ const [, major, minor, patch] = match;
+ return { version: match[0].replace(/^v/u, ""), number: [Number(major), Number(minor), Number(patch)] };
+};
+
+const compare = (left: Version, right: Version): number => {
+ for (const [index, part] of left.entries()) {
+ if (part !== right[index]) {
+ return part - right[index];
+ }
+ }
+ return 0;
+};
+
+const fetchReleases = async (repository: string): Promise> => {
+ const releases: z.infer = [];
+ for (let page = 1; ; page++) {
+ const url = `https://api.github.com/repos/${repository}/releases?per_page=100&page=${page}`;
+ const response = await fetch(url, { headers: { accept: "application/vnd.github+json" } });
+ if (!response.ok) {
+ throw new Error(`GET ${url} → HTTP ${response.status}`);
+ }
+ const result = releasesPageSchema.safeParse(await response.json());
+ if (!result.success) {
+ throw new Error(`GET ${url}: ${z.prettifyError(result.error)}`);
+ }
+ if (result.data.length === 0) {
+ return releases;
+ }
+ releases.push(...result.data);
+ }
+};
+
+/** The newest stable release of `repository` at or above `minimum`, by semantic version. */
+export const trackedRelease = async (repository: string, minimum?: Version): Promise => {
+ const releases = await fetchReleases(repository);
+ const candidates = releases.flatMap((release): TrackedRelease[] => {
+ if (release.draft || release.prerelease) {
+ return [];
+ }
+ const parsed = versionOf(release.tag_name);
+ if (parsed === undefined || (minimum !== undefined && compare(parsed.number, minimum) < 0)) {
+ return [];
+ }
+ return [
+ {
+ ...parsed,
+ assets: release.assets.map((asset) => ({
+ name: asset.name,
+ url: asset.browser_download_url,
+ digest: asset.digest ?? null,
+ })),
+ },
+ ];
+ });
+ if (candidates.length === 0) {
+ const limit = minimum === undefined ? "" : ` at or above ${minimum.join(".")}`;
+ throw new Error(`No stable release of ${repository}${limit}`);
+ }
+ return candidates.reduce((best, candidate) => (compare(candidate.number, best.number) > 0 ? candidate : best));
+};
+
+/** The url and sha256 digest of the one release asset named `name`. */
+export const artifactOf = (release: TrackedRelease, name: string): { url: string; sha256: string } => {
+ const matches = release.assets.filter((asset) => asset.name === name);
+ if (matches.length !== 1) {
+ throw new Error(`Release ${release.version}: expected one asset ${name}, found ${matches.length}`);
+ }
+ const [asset] = matches;
+ const match = sha256Digest.exec(asset.digest ?? "");
+ if (match === null) {
+ throw new Error(`Release ${release.version}: asset ${name} has no sha256 digest (${asset.digest ?? "none"})`);
+ }
+ return { url: asset.url, sha256: match[1] };
+};
+
+/** The `.`, `` and `latest` names a release claims. */
+export const versionAliases = (release: TrackedRelease): string[] => {
+ const [major, minor] = release.number;
+ return [`${major}.${minor}`, `${major}`, "latest"];
+};
diff --git a/scripts/tasks/features/ripgrep/generateConfig.ts b/scripts/tasks/features/ripgrep/generateConfig.ts
index 6c72c23..70ea1eb 100644
--- a/scripts/tasks/features/ripgrep/generateConfig.ts
+++ b/scripts/tasks/features/ripgrep/generateConfig.ts
@@ -1,36 +1,20 @@
-import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { projectRoot } from "@/scripts/project.js";
-import { checkTagNamespace, formatConfig } from "@/scripts/tasks/features/config.js";
+import { checkTagNamespace, configFile } from "@/scripts/tasks/features/config.js";
+
+import type { TrackedRelease, Version } from "../githubReleases.js";
+import { artifactOf, trackedRelease, versionAliases } from "../githubReleases.js";
-const releasesUrl = "https://api.github.com/repos/BurntSushi/ripgrep/releases";
export const configPath = path.resolve(projectRoot, "features/ripgrep/config.json");
export const targets = ["x86_64-unknown-linux-musl", "aarch64-unknown-linux-musl"] as const;
export type Target = (typeof targets)[number];
-type Version = readonly [number, number, number];
-
const minimumVersion: Version = [15, 2, 0];
-const releaseSchema = z.object({
- tag_name: z.string(),
- draft: z.boolean(),
- prerelease: z.boolean(),
- assets: z.array(
- z.object({
- name: z.string(),
- browser_download_url: z.string(),
- digest: z.string().nullish(),
- }),
- ),
-});
-
-const releasesPageSchema = z.array(releaseSchema);
-
const artifactSchema = z.object({ url: z.string(), sha256: z.string() });
const versionEntrySchema = z.strictObject({
@@ -46,95 +30,9 @@ export const featureConfigSchema = z.strictObject({
aliases: aliasTableSchema,
});
-export type Release = z.infer;
-export type Artifact = z.infer;
export type VersionEntry = z.infer;
export type FeatureConfig = z.infer;
-interface Candidate {
- tag: string;
- number: Version;
- release: Release;
-}
-
-const versionOf = (tag: string): Version | undefined => {
- const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(tag);
- if (match === null) {
- return undefined;
- }
- return [Number(match[1]), Number(match[2]), Number(match[3])];
-};
-
-const compare = (left: Version, right: Version): number => {
- for (const [index, part] of left.entries()) {
- if (part !== right[index]) {
- return part - right[index];
- }
- }
- return 0;
-};
-
-const fetchReleases = async (): Promise => {
- const releases: Release[] = [];
- for (let page = 1; ; page++) {
- const url = `${releasesUrl}?per_page=100&page=${page}`;
- const response = await fetch(url, { headers: { accept: "application/vnd.github+json" } });
- if (!response.ok) {
- throw new Error(`GET ${url} → HTTP ${response.status}`);
- }
- const result = releasesPageSchema.safeParse(await response.json());
- if (!result.success) {
- throw new Error(`GET ${url}: ${z.prettifyError(result.error)}`);
- }
- if (result.data.length === 0) {
- return releases;
- }
- releases.push(...result.data);
- }
-};
-
-const trackedRelease = async (): Promise => {
- const releases = await fetchReleases();
- const candidates = releases.flatMap((release) => {
- if (release.draft || release.prerelease) {
- return [];
- }
- const number = versionOf(release.tag_name);
- if (number === undefined || compare(number, minimumVersion) < 0) {
- return [];
- }
- return [{ tag: release.tag_name, number, release }];
- });
- if (candidates.length === 0) {
- throw new Error(`No stable release at or above ${minimumVersion.join(".")}`);
- }
- return candidates.reduce((best, candidate) => (compare(candidate.number, best.number) > 0 ? candidate : best));
-};
-
-const resolveArtifact = (candidate: Candidate, target: Target): Artifact => {
- const name = `ripgrep-${candidate.tag}-${target}.tar.gz`;
- const matches = candidate.release.assets.filter((asset) => asset.name === name);
- if (matches.length !== 1) {
- throw new Error(`Release ${candidate.tag}: expected one asset ${name}, found ${matches.length}`);
- }
- const [asset] = matches;
- const match = /^sha256:([0-9a-f]{64})$/.exec(asset.digest ?? "");
- if (match === null) {
- throw new Error(`Release ${candidate.tag}: asset ${name} has no sha256 digest (${asset.digest ?? "none"})`);
- }
- return { url: asset.browser_download_url, sha256: match[1] };
-};
-
-const versionEntry = (candidate: Candidate): VersionEntry => ({
- "x86_64-unknown-linux-musl": resolveArtifact(candidate, "x86_64-unknown-linux-musl"),
- "aarch64-unknown-linux-musl": resolveArtifact(candidate, "aarch64-unknown-linux-musl"),
-});
-
-const versionAliases = (candidate: Candidate): string[] => {
- const [major, minor] = candidate.number;
- return [`${major}.${minor}`, `${major}`, "latest"];
-};
-
const validateConfig = (config: FeatureConfig, source: string): void => {
checkTagNamespace({
source,
@@ -145,36 +43,22 @@ const validateConfig = (config: FeatureConfig, source: string): void => {
});
};
-export const parseConfig = (text: string, source: string): FeatureConfig => {
- const result = featureConfigSchema.safeParse(JSON.parse(text));
- if (!result.success) {
- throw new Error(`${source}: ${z.prettifyError(result.error)}`);
- }
- validateConfig(result.data, source);
- return result.data;
-};
+const config = configFile({ path: configPath, schema: featureConfigSchema, validate: validateConfig });
-export const readConfig = async (): Promise =>
- parseConfig(await readFile(configPath, "utf-8"), configPath);
-
-const readRevision = async (): Promise => {
- try {
- return (await readConfig()).revision;
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
- return 1;
- }
- throw error;
- }
-};
+export const parseConfig = config.parse;
+export const readConfig = config.read;
+
+const versionEntry = (release: TrackedRelease): VersionEntry => ({
+ "x86_64-unknown-linux-musl": artifactOf(release, `ripgrep-${release.version}-x86_64-unknown-linux-musl.tar.gz`),
+ "aarch64-unknown-linux-musl": artifactOf(release, `ripgrep-${release.version}-aarch64-unknown-linux-musl.tar.gz`),
+});
export const generateConfig = async (): Promise => {
- const candidate = await trackedRelease();
- const config: FeatureConfig = {
- revision: await readRevision(),
- versions: { [candidate.tag]: versionEntry(candidate) },
- aliases: { [candidate.tag]: versionAliases(candidate) },
+ const release = await trackedRelease("BurntSushi/ripgrep", minimumVersion);
+ const document: FeatureConfig = {
+ revision: await config.revision(),
+ versions: { [release.version]: versionEntry(release) },
+ aliases: { [release.version]: versionAliases(release) },
};
- validateConfig(config, configPath);
- await writeFile(configPath, await formatConfig(config, configPath));
+ await config.write(document);
};
diff --git a/scripts/tasks/features/ripgrep/payload.ts b/scripts/tasks/features/ripgrep/payload.ts
index 6940f56..0902eb3 100644
--- a/scripts/tasks/features/ripgrep/payload.ts
+++ b/scripts/tasks/features/ripgrep/payload.ts
@@ -1,13 +1,11 @@
-import { mkdir, rename, rm } from "node:fs/promises";
import path from "node:path";
-import { $$ } from "@/scripts/shell.js";
-import { download } from "@/scripts/tasks/features/download.js";
-
import type { DockerArch } from "../architecture.js";
import { isFile } from "../architecture.js";
import { payloadPath } from "../paths.js";
-import type { Artifact, FeatureConfig, Target, VersionEntry } from "./generateConfig.js";
+import type { Tarball } from "../tarball.js";
+import { stageTarball } from "../tarball.js";
+import type { FeatureConfig, Target, VersionEntry } from "./generateConfig.js";
import { configPath, readConfig } from "./generateConfig.js";
const targets: Record = {
@@ -32,12 +30,9 @@ export interface PayloadSection {
}
export interface Payload {
- key: string;
- target: string;
cache: string;
present: string;
- top: string;
- archive: Artifact;
+ tarball: Tarball;
}
export const sectionOf = (config: FeatureConfig, key: string): PayloadSection => {
@@ -53,13 +48,16 @@ export const loadSection = async (key: string): Promise => secti
export const derive = (section: PayloadSection, arch: DockerArch): Payload => {
const target = targets[arch];
const cache = payloadPath("ripgrep", "ripgrep", section.key, arch);
+ const archive = section.entry[target];
return {
- key: section.key,
- target,
cache,
present: path.join(cache, "rg"),
- top: `ripgrep-${section.key}-${target}`,
- archive: section.entry[target],
+ tarball: {
+ cache,
+ url: archive.url,
+ sha256: archive.sha256,
+ extract: { top: `ripgrep-${section.key}-${target}`, entries },
+ },
};
};
@@ -71,30 +69,5 @@ export const ensurePayload = async (section: PayloadSection, arch: DockerArch, f
console.log(`Payload already present at ${payload.cache}; skipping fetch`);
return;
}
- const tempPath = `${payload.cache}.tmp`;
- await rm(tempPath, { recursive: true, force: true });
- const archivePath = path.join(tempPath, "archive.tar.gz");
- const extractedPath = path.join(tempPath, "extracted");
- await mkdir(extractedPath, { recursive: true });
- const actual = await download(payload.archive.url, archivePath);
- if (actual !== payload.archive.sha256) {
- throw new Error(`${payload.archive.url}: sha256 ${actual} does not match the configured ${payload.archive.sha256}`);
- }
- await $$`tar ${[
- "-xzf",
- archivePath,
- "-C",
- extractedPath,
- "--strip-components=1",
- ...entries.map((entry) => `${payload.top}/${entry}`),
- ]}`;
- for (const entry of entries) {
- if (!(await isFile(path.join(extractedPath, entry)))) {
- throw new Error(`${payload.archive.url}: the archive has no ${entry}`);
- }
- }
- await rm(payload.cache, { recursive: true, force: true });
- await rename(extractedPath, payload.cache);
- await rm(tempPath, { recursive: true, force: true });
- console.log(`Payload written to ${payload.cache}`);
+ await stageTarball(payload.tarball);
};
diff --git a/scripts/tasks/features/tarball.ts b/scripts/tasks/features/tarball.ts
new file mode 100644
index 0000000..e6c3b52
--- /dev/null
+++ b/scripts/tasks/features/tarball.ts
@@ -0,0 +1,41 @@
+import { mkdir, rename, rm } from "node:fs/promises";
+import path from "node:path";
+
+import { $$ } from "@/scripts/shell.js";
+
+import { isFile } from "./architecture.js";
+import { download } from "./download.js";
+
+export interface Tarball {
+ /** The payload directory the extracted archive replaces in one rename. */
+ cache: string;
+ url: string;
+ sha256: string;
+ /** The members to extract, under the archive's single top directory; every member when absent. */
+ extract?: { top: string; entries: readonly string[] };
+}
+
+/** Fetches and extracts `tarball`; its cache is replaced only once the archive has passed every check. */
+export const stageTarball = async (tarball: Tarball): Promise => {
+ const tempPath = `${tarball.cache}.tmp`;
+ await rm(tempPath, { recursive: true, force: true });
+ const archivePath = path.join(tempPath, "archive.tar.gz");
+ const extractedPath = path.join(tempPath, "extracted");
+ await mkdir(extractedPath, { recursive: true });
+ const actual = await download(tarball.url, archivePath);
+ if (actual !== tarball.sha256) {
+ throw new Error(`${tarball.url}: sha256 ${actual} does not match the configured ${tarball.sha256}`);
+ }
+ const extract = tarball.extract;
+ const members = extract === undefined ? [] : extract.entries.map((entry) => `${extract.top}/${entry}`);
+ await $$`tar ${["-xzf", archivePath, "-C", extractedPath, "--strip-components=1", ...members]}`;
+ for (const entry of extract?.entries ?? []) {
+ if (!(await isFile(path.join(extractedPath, entry)))) {
+ throw new Error(`${tarball.url}: the archive has no ${entry}`);
+ }
+ }
+ await rm(tarball.cache, { recursive: true, force: true });
+ await rename(extractedPath, tarball.cache);
+ await rm(tempPath, { recursive: true, force: true });
+ console.log(`Payload written to ${tarball.cache}`);
+};
diff --git a/scripts/tasks/features/uv/feature.ts b/scripts/tasks/features/uv/feature.ts
index b1f63ab..76eb942 100644
--- a/scripts/tasks/features/uv/feature.ts
+++ b/scripts/tasks/features/uv/feature.ts
@@ -3,7 +3,7 @@ import type { Feature, FeatureStage } from "@/scripts/tasks/features/feature.js"
import type { Component, FeatureConfig } from "./generateConfig.js";
import { components, componentSchema, configPath, generateConfig, parseConfig, readConfig } from "./generateConfig.js";
-import { ensureFeatureBinary } from "./packBinary.js";
+import { packFeatureBinary } from "./packBinary.js";
import { derive, ensurePayload, payloadPresent, sectionOf } from "./payload.js";
const repositories: Record = { uv: "uv", python: "uv/python" };
@@ -37,6 +37,6 @@ export const uv: Feature = {
present: async (component, key, arch) =>
payloadPresent(derive(sectionOf(await readConfig(), componentSchema.parse(component), key), arch)),
},
- binary: { ensure: ensureFeatureBinary },
+ binary: { ensure: packFeatureBinary },
gitTag: (component, canonical) => `features-uv-${componentSchema.parse(component)}-${canonical}`,
};
diff --git a/scripts/tasks/features/uv/generateConfig.ts b/scripts/tasks/features/uv/generateConfig.ts
index e5cc74e..5f16a30 100644
--- a/scripts/tasks/features/uv/generateConfig.ts
+++ b/scripts/tasks/features/uv/generateConfig.ts
@@ -1,10 +1,9 @@
-import { readFile, writeFile } from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import { projectRoot } from "@/scripts/project.js";
-import { checkTagNamespace, formatConfig } from "@/scripts/tasks/features/config.js";
+import { checkTagNamespace, configFile } from "@/scripts/tasks/features/config.js";
import type { DockerArch, Rid } from "../architecture.js";
import { dockerArchOptions, ridFor, rids } from "../architecture.js";
@@ -226,35 +225,17 @@ const validateConfig = (config: FeatureConfig, source: string): void => {
validatePython(config, `${source} component python`);
};
-export const parseConfig = (text: string, source: string): FeatureConfig => {
- const result = featureConfigSchema.safeParse(JSON.parse(text));
- if (!result.success) {
- throw new Error(`${source}: ${z.prettifyError(result.error)}`);
- }
- validateConfig(result.data, source);
- return result.data;
-};
-
-export const readConfig = async (): Promise =>
- parseConfig(await readFile(configPath, "utf-8"), configPath);
+const config = configFile({ path: configPath, schema: featureConfigSchema, validate: validateConfig });
-const readRevision = async (): Promise => {
- try {
- return (await readConfig()).revision;
- } catch (error) {
- if ((error as NodeJS.ErrnoException).code === "ENOENT") {
- return 1;
- }
- throw error;
- }
-};
+export const parseConfig = config.parse;
+export const readConfig = config.read;
export const generateConfig = async (): Promise => {
const uvRelease = trackedUvRelease(parseIndex(await fetchText(uvIndexUrl), uvIndexUrl));
const pythonReleases = parseIndex(await fetchText(pythonIndexUrl), pythonIndexUrl);
const [major, minor] = uvRelease.version.split(".");
- const config: FeatureConfig = {
- revision: await readRevision(),
+ const document: FeatureConfig = {
+ revision: await config.revision(),
components: {
uv: {
versions: {
@@ -271,6 +252,5 @@ export const generateConfig = async (): Promise => {
python: await pythonVersions(pythonReleases),
},
};
- validateConfig(config, configPath);
- await writeFile(configPath, await formatConfig(config, configPath));
+ await config.write(document);
};
diff --git a/scripts/tasks/features/uv/packBinary.ts b/scripts/tasks/features/uv/packBinary.ts
index d243ff2..a360ab4 100644
--- a/scripts/tasks/features/uv/packBinary.ts
+++ b/scripts/tasks/features/uv/packBinary.ts
@@ -1,24 +1,13 @@
import path from "node:path";
-import { project$$ } from "@/scripts/shell.js";
+import { projectRoot, workspaces } from "@/scripts/project.js";
import type { Rid } from "../architecture.js";
-import { isFile } from "../architecture.js";
-import { packPath } from "./paths.js";
+import { ensureFeatureBinary } from "../featureBinary.js";
-export const ensureFeatureBinary = async (rid: Rid, force: boolean): Promise => {
- const binaryPath = path.join(packPath(rid), "Feature");
- if (!force && (await isFile(binaryPath))) {
- return binaryPath;
- }
- await project$$`dotnet publish ${[
- ...["--configuration", "Release"],
- ...["--runtime", rid],
- "features/uv/Feature/Feature.csproj",
- `-p:PublishDir=${packPath(rid)}/`,
- ]}`;
- if (!(await isFile(binaryPath))) {
- throw new Error(`dotnet publish did not produce ${binaryPath}`);
- }
- return binaryPath;
-};
+const csproj = path.resolve(projectRoot, "features/uv/Feature/Feature.csproj");
+
+const packPath = (rid: Rid): string => path.join(workspaces, "bin/uv/Feature", rid);
+
+export const packFeatureBinary = (rid: Rid, force: boolean): Promise =>
+ ensureFeatureBinary(csproj, packPath(rid), rid, force);
diff --git a/scripts/tasks/features/uv/paths.ts b/scripts/tasks/features/uv/paths.ts
index b83c33a..4c1950d 100644
--- a/scripts/tasks/features/uv/paths.ts
+++ b/scripts/tasks/features/uv/paths.ts
@@ -1,8 +1,4 @@
-import path from "node:path";
-
-import { workspaces } from "@/scripts/project.js";
-
-import type { DockerArch, Rid } from "../architecture.js";
+import type { DockerArch } from "../architecture.js";
export const muslTriples: Record = {
amd64: "x86_64-unknown-linux-musl",
@@ -17,5 +13,3 @@ export const gnuTriples: Record = {
const machines: Record = { amd64: "x86_64", arm64: "aarch64" };
export const machineOf = (arch: DockerArch): string => machines[arch];
-
-export const packPath = (rid: Rid): string => path.join(workspaces, "bin/uv/Feature", rid);
diff --git a/scripts/tasks/features/uv/payload.ts b/scripts/tasks/features/uv/payload.ts
index ae28b2e..2b00b96 100644
--- a/scripts/tasks/features/uv/payload.ts
+++ b/scripts/tasks/features/uv/payload.ts
@@ -1,8 +1,8 @@
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
import path from "node:path";
-import { $$ } from "@/scripts/shell.js";
import { download } from "@/scripts/tasks/features/download.js";
+import { stageTarball } from "@/scripts/tasks/features/tarball.js";
import type { DockerArch } from "../architecture.js";
import { isFile, ridFor } from "../architecture.js";
@@ -186,35 +186,13 @@ export function derive(section: PayloadSection, arch: DockerArch): Payload {
export const payloadPresent = (payload: Payload): Promise => isFile(payload.present);
-const fetchUvPayload = async (payload: UvPayload): Promise => {
- const tempPath = `${payload.cache}.tmp`;
- await rm(tempPath, { recursive: true, force: true });
- const archivePath = path.join(tempPath, "archive.tar.gz");
- const extractedPath = path.join(tempPath, "extracted");
- await mkdir(extractedPath, { recursive: true });
- const actual = await download(payload.archive.url, archivePath);
- if (actual !== payload.archive.sha256) {
- throw new Error(`${payload.archive.url}: sha256 ${actual} does not match the configured ${payload.archive.sha256}`);
- }
- const top = `uv-${payload.triple}`;
- await $$`tar ${[
- "-xzf",
- archivePath,
- "-C",
- extractedPath,
- "--strip-components=1",
- ...uvBinaries.map((binary) => `${top}/${binary}`),
- ]}`;
- for (const binary of uvBinaries) {
- if (!(await isFile(path.join(extractedPath, binary)))) {
- throw new Error(`${payload.archive.url}: the archive has no ${binary}`);
- }
- }
- await rm(payload.cache, { recursive: true, force: true });
- await rename(extractedPath, payload.cache);
- await rm(tempPath, { recursive: true, force: true });
- console.log(`Payload written to ${payload.cache}`);
-};
+const fetchUvPayload = async (payload: UvPayload): Promise =>
+ stageTarball({
+ cache: payload.cache,
+ url: payload.archive.url,
+ sha256: payload.archive.sha256,
+ extract: { top: `uv-${payload.triple}`, entries: uvBinaries },
+ });
const fetchPythonPayload = async (payload: PythonPayload): Promise => {
const tempPath = `${payload.cache}.tmp`;
diff --git a/scripts/tasks/imageFeatures.ts b/scripts/tasks/imageFeatures.ts
index cdfedf6..0e5f29b 100644
--- a/scripts/tasks/imageFeatures.ts
+++ b/scripts/tasks/imageFeatures.ts
@@ -11,9 +11,12 @@ import { projectRoot } from "@/scripts/project.js";
const featuresPath = path.resolve(projectRoot, "features");
+const optionDefaultSchema = z.union([z.string(), z.number(), z.boolean(), z.array(z.string())]);
+
const optionSchema = z.strictObject({
type: z.string(),
- default: z.union([z.string(), z.number(), z.boolean()]),
+ items: z.strictObject({ type: z.union([z.string(), z.array(z.string())]) }).optional(),
+ default: optionDefaultSchema,
description: z.string(),
});
@@ -115,6 +118,9 @@ const optionsOf = async (id: string): Promise => {
return result.data.properties;
};
+const defaultText = (value: z.infer): string =>
+ Array.isArray(value) ? JSON.stringify(value) : String(value);
+
const optionsTable = (options: Options): string => {
const entries = Object.entries(options);
if (entries.length === 0) {
@@ -126,7 +132,8 @@ const optionsTable = (options: Options): string => {
"| Options Id | Description | Type | Default Value |",
"| ---------- | ----------- | ---- | ------------- |",
...entries.map(
- ([optionId, option]) => `| ${optionId} | ${option.description} | ${option.type} | ${option.default} |`,
+ ([optionId, option]) =>
+ `| ${optionId} | ${option.description} | ${option.type} | ${defaultText(option.default)} |`,
),
].join("\n");
};
diff --git a/scripts/verify/code-server/assets/options-unknown.json b/scripts/verify/code-server/assets/options-unknown.json
new file mode 100644
index 0000000..5994354
--- /dev/null
+++ b/scripts/verify/code-server/assets/options-unknown.json
@@ -0,0 +1,3 @@
+{
+ "unknown": true
+}
diff --git a/scripts/verify/code-server/scenarios.ts b/scripts/verify/code-server/scenarios.ts
new file mode 100644
index 0000000..03df604
--- /dev/null
+++ b/scripts/verify/code-server/scenarios.ts
@@ -0,0 +1,241 @@
+import { writeFile } from "node:fs/promises";
+import path, { posix } from "node:path";
+
+import { z } from "zod";
+
+import { selectionSchema } from "../arguments.js";
+import type { TestImage, TestImageFile, Workspace } from "../lifecycle.js";
+import { lifecycle } from "../lifecycle.js";
+import type { Exec, Scenario, ScenarioVariant } from "../run.js";
+import type { Selection, SelectionTarget } from "./selection.js";
+
+const names = ["default", "password", "socket", "args", "smoke"] as const;
+type ScenarioName = (typeof names)[number];
+
+export const scenarioSelectionSchema = selectionSchema(
+ z.enum(names),
+ names,
+ names.filter((name) => name !== "smoke"),
+);
+
+const testImagePrefix = "features-verify-code-server";
+const featureDir = "/opt/devcontainer-config/features/code-server";
+const installedDir = `${featureDir}/code-server`;
+const nodePath = `${installedDir}/lib/node`;
+const postStart = "/opt/devcontainer-config/features-run postStart";
+
+interface ScenarioContext {
+ selection: Selection;
+ target: SelectionTarget | undefined;
+ root: string;
+ workspace: Workspace;
+}
+
+const testImageOf = (name: string, selection: Selection, files?: readonly TestImageFile[]): TestImage => ({
+ tag: `${testImagePrefix}:${name}-${selection.key}`,
+ components: [selection],
+ dockerfile: path.join(
+ import.meta.dirname,
+ files === undefined ? "test-image.Dockerfile" : "test-image-options.Dockerfile",
+ ),
+ args: [`CODE_SERVER_IMAGE_REF=${selection.ref}`],
+ files,
+});
+
+const optionsImage = async (name: string, root: string, selection: Selection, options: unknown): Promise => {
+ const source = path.join(root, `${name}-options.json`);
+ await writeFile(source, `${JSON.stringify(options, null, 2)}\n`);
+ return testImageOf(name, selection, [{ name: "options.json", source }]);
+};
+
+const healthCheck = (selection: Selection, url: string, socketPath?: string): Exec => {
+ const target = socketPath === undefined ? `"${url}"` : `--unix-socket "${socketPath}" "${url}"`;
+ const script = [
+ "deadline=$(( $(date +%s) + 30 ))",
+ 'while [ "$(date +%s)" -lt "$deadline" ]; do',
+ ` status=$(curl -s -o /dev/null -w '%{http_code}' --max-time 1 --noproxy '*' ${target} || true)`,
+ ' if [ "$status" = 200 ]; then echo "$status"; exit 0; fi',
+ " sleep 0.1",
+ "done",
+ 'echo "last status: $status"',
+ `tail -n 50 "${selection.logPath}"`,
+ "exit 1",
+ ].join("\n");
+ return { command: ["sh", "-c", script], expect: { stdout: ["200"] } };
+};
+
+const version = (key: string): Exec => ({
+ command: ["sh", "-c", "code-server --version | awk 'NR == 1 { print $1 }'"],
+ expect: { stdout: [key] },
+});
+
+const installed = (): readonly Exec[] => [
+ { command: ["sh", "-c", "command -v code-server"], expect: { stdout: ["/usr/local/bin/code-server"] } },
+ {
+ command: ["readlink", "-f", "/usr/local/bin/code-server"],
+ expect: { stdout: [`${installedDir}/bin/code-server`] },
+ },
+];
+
+const logLine = (selection: Selection, line: string): Exec => ({
+ command: ["sh", "-c", `grep -Fq -- '${line}' "${selection.logPath}"`],
+ expect: { stdout: [] },
+});
+
+const logCount = (selection: Selection, text: string, count: number): Exec => ({
+ command: ["sh", "-c", `grep -Fc -- '${text}' "${selection.logPath}"`],
+ expect: { stdout: [String(count)] },
+});
+
+const processUser = (selection: Selection): Exec => ({
+ command: ["sh", "-c", `pid=$(cat "${selection.pidPath}"); ps -o user= -p "$pid"`],
+ expect: { stdout: ["verify"], fields: true },
+});
+
+const processCount = (): Exec => ({
+ command: ["sh", "-c", `pgrep -P 1 -c -f '^${nodePath} '`],
+ expect: { stdout: ["1"] },
+});
+
+const reused = (selection: Selection): Exec => ({
+ command: [
+ "sh",
+ "-c",
+ [
+ `pid=$(cat "${selection.pidPath}")`,
+ "output=$(mktemp)",
+ `if ! ${postStart} > "$output" 2>&1; then`,
+ ' cat "$output"',
+ " exit 1",
+ "fi",
+ 'rm -f "$output"',
+ `now=$(cat "${selection.pidPath}")`,
+ 'if [ "$pid" != "$now" ]; then echo "pid changed from $pid to $now"; exit 1; fi',
+ `count=$(pgrep -P 1 -c -f '^${nodePath} ')`,
+ 'if [ "$count" != 1 ]; then echo "process count: $count"; exit 1; fi',
+ "echo reused",
+ ].join("\n"),
+ ],
+ expect: { stdout: ["reused"] },
+});
+
+const stopped = (selection: Selection, socketPath: string): Exec => ({
+ command: [
+ "sh",
+ "-c",
+ [
+ `pid=$(cat "${selection.pidPath}")`,
+ 'kill "$pid"',
+ "deadline=$(( $(date +%s) + 10 ))",
+ `while [ -e "${socketPath}" ] && [ "$(date +%s)" -lt "$deadline" ]; do sleep 0.05; done`,
+ `if [ -e "${socketPath}" ]; then echo "socket still present"; exit 1; fi`,
+ "echo removed",
+ ].join("\n"),
+ ],
+ expect: { stdout: ["removed"] },
+});
+
+const defaultExecs = (selection: Selection): readonly Exec[] => [
+ healthCheck(selection, "http://127.0.0.1:8080/healthz"),
+ version(selection.key),
+ ...installed(),
+ logLine(selection, "HTTP server listening on http://127.0.0.1:8080/"),
+ logLine(selection, " - Authentication is disabled"),
+ processUser(selection),
+ processCount(),
+ reused(selection),
+];
+
+const passwordExecs = (selection: Selection): readonly Exec[] => [
+ healthCheck(selection, "http://127.0.0.1:8080/healthz"),
+ logLine(selection, " - Authentication is enabled"),
+ logLine(selection, " - Using password from $PASSWORD"),
+];
+
+const socketExecs = (selection: Selection, socketPath: string): readonly Exec[] => [
+ healthCheck(selection, "http://localhost/healthz", socketPath),
+ { command: ["stat", "-c", "%a", socketPath], expect: { stdout: ["600"] } },
+ logLine(selection, `HTTP server listening on ${socketPath}`),
+ stopped(selection, socketPath),
+];
+
+const argsExecs = (selection: Selection): readonly Exec[] => [
+ healthCheck(selection, "http://127.0.0.1:18080/healthz"),
+ logLine(selection, "HTTP server listening on http://127.0.0.1:18080/"),
+ logCount(selection, "Using extensions-dir ", 1),
+];
+
+const smokeExecs = (selection: Selection): readonly Exec[] => [
+ healthCheck(selection, "http://127.0.0.1:8080/healthz"),
+ version(selection.key),
+ ...installed(),
+ logLine(selection, " - Authentication is disabled"),
+];
+
+const unknownOptionsVariant = (selection: Selection, root: string): ScenarioVariant => ({
+ name: "options-unknown",
+ workspace: lifecycle.workspaceAt(root, "default-options-unknown"),
+ testImage: testImageOf("default-options-unknown", selection, [
+ { name: "options.json", source: path.join(import.meta.dirname, "assets", "options-unknown.json") },
+ ]),
+ buildFailure: "install: /opt/devcontainer-config/features/code-server/options.json",
+});
+
+const createScenario = async (name: ScenarioName, context: ScenarioContext): Promise => {
+ const { selection, target, root, workspace } = context;
+ switch (name) {
+ case "default":
+ return {
+ name,
+ workspace,
+ testImage: testImageOf(name, selection),
+ execs: defaultExecs(selection),
+ variants: [unknownOptionsVariant(selection, root)],
+ };
+ case "password": {
+ const passwordPath = posix.join(workspace.containerPath, "password");
+ return {
+ name,
+ workspace,
+ testImage: await optionsImage(name, root, selection, { auth: "password", passwordFile: passwordPath }),
+ execs: passwordExecs(selection),
+ stage: async (staged) => {
+ await writeFile(path.join(staged.path, "password"), "verify-password\n");
+ },
+ };
+ }
+ case "socket": {
+ const socketPath = posix.join(workspace.containerPath, "code-server.sock");
+ return {
+ name,
+ workspace,
+ testImage: await optionsImage(name, root, selection, { socket: socketPath }),
+ execs: socketExecs(selection, socketPath),
+ };
+ }
+ case "args":
+ return {
+ name,
+ workspace,
+ testImage: await optionsImage(name, root, selection, {
+ args: ["--verbose", "--bind-addr", "127.0.0.1:18080"],
+ }),
+ execs: argsExecs(selection),
+ };
+ case "smoke": {
+ if (target === undefined) {
+ throw new Error("The smoke scenario requires --component and --version");
+ }
+ return {
+ name,
+ workspace,
+ testImage: testImageOf(name, selection),
+ execs: smokeExecs(selection),
+ };
+ }
+ }
+};
+
+export const scenarios = {
+ create: createScenario,
+} as const;
diff --git a/scripts/verify/code-server/selection.ts b/scripts/verify/code-server/selection.ts
new file mode 100644
index 0000000..a46a31d
--- /dev/null
+++ b/scripts/verify/code-server/selection.ts
@@ -0,0 +1,38 @@
+import { posix } from "node:path";
+
+import { codeServer, documentOf } from "@/scripts/tasks/features/code-server/feature.js";
+import { configPath, readConfig } from "@/scripts/tasks/features/code-server/generateConfig.js";
+import { claimedKey } from "@/scripts/tasks/features/config.js";
+import { imageEntry } from "@/scripts/tasks/features/imageEntries.js";
+
+import type { ComponentImage } from "../lifecycle.js";
+import { userInit } from "../userInit.js";
+
+const component = "code-server";
+
+export interface Selection extends ComponentImage {
+ logPath: string;
+ pidPath: string;
+}
+
+export interface SelectionTarget {
+ component: "code-server";
+ key: string;
+}
+
+const derive = async (channel: string, prefix: string, target?: SelectionTarget): Promise => {
+ const config = await readConfig();
+ const key = target?.key ?? claimedKey(config.aliases, "latest", configPath);
+ const { canonical } = imageEntry(codeServer, documentOf(config), prefix, channel, { component, key });
+ const cachePath = posix.join(await userInit.xdgPath("XDG_CACHE_HOME"), component);
+ const statePath = posix.join(await userInit.xdgPath("XDG_STATE_HOME"), component);
+ return {
+ component,
+ key,
+ ref: canonical,
+ logPath: posix.join(cachePath, "code-server.log"),
+ pidPath: posix.join(statePath, "code-server.pid"),
+ };
+};
+
+export const selection = { derive } as const;
diff --git a/scripts/verify/code-server/test-image-options.Dockerfile b/scripts/verify/code-server/test-image-options.Dockerfile
new file mode 100644
index 0000000..39d7239
--- /dev/null
+++ b/scripts/verify/code-server/test-image-options.Dockerfile
@@ -0,0 +1,8 @@
+# check=skip=InvalidDefaultArgInFrom
+ARG CODE_SERVER_IMAGE_REF
+
+FROM ${CODE_SERVER_IMAGE_REF} AS code-server
+
+FROM mcr.microsoft.com/devcontainers/base:debian
+COPY --from=code-server /features/code-server/ /opt/devcontainer-config/features/code-server/
+COPY options.json /opt/devcontainer-config/features/code-server/options.json
diff --git a/scripts/verify/code-server/test-image.Dockerfile b/scripts/verify/code-server/test-image.Dockerfile
new file mode 100644
index 0000000..dd2893d
--- /dev/null
+++ b/scripts/verify/code-server/test-image.Dockerfile
@@ -0,0 +1,7 @@
+# check=skip=InvalidDefaultArgInFrom
+ARG CODE_SERVER_IMAGE_REF
+
+FROM ${CODE_SERVER_IMAGE_REF} AS code-server
+
+FROM mcr.microsoft.com/devcontainers/base:debian
+COPY --from=code-server /features/code-server/ /opt/devcontainer-config/features/code-server/
diff --git a/scripts/verify/code-server/verify.ts b/scripts/verify/code-server/verify.ts
new file mode 100644
index 0000000..2b6b88d
--- /dev/null
+++ b/scripts/verify/code-server/verify.ts
@@ -0,0 +1,32 @@
+import { z } from "zod";
+
+import { parseVerifyArguments, requireSmokeTarget, targetSchema } from "../arguments.js";
+import { lifecycle } from "../lifecycle.js";
+import { runSuite } from "../run.js";
+import { scenarios, scenarioSelectionSchema } from "./scenarios.js";
+import { selection } from "./selection.js";
+
+export const verify = async (argv: string[]): Promise => {
+ const { options, scenarios: requested, component, version } = await parseVerifyArguments(argv);
+ const selected = scenarioSelectionSchema.parse(requested);
+ const target = targetSchema(z.literal("code-server")).parse({ component, version });
+ requireSmokeTarget(selected, target);
+ const derived = await selection.derive(options.channel, options.prefix, target);
+ console.log(`>>> verify code-server: selection version=${derived.key} ref=${derived.ref}`);
+
+ await runSuite({
+ ...options,
+ name: "code-server",
+ create: async (root) =>
+ Promise.all(
+ selected.map((name) =>
+ scenarios.create(name, {
+ selection: derived,
+ target,
+ root,
+ workspace: lifecycle.workspaceAt(root, name),
+ }),
+ ),
+ ),
+ });
+};
diff --git a/scripts/verify/dotnet/selection.ts b/scripts/verify/dotnet/selection.ts
index 188e108..8a0ce11 100644
--- a/scripts/verify/dotnet/selection.ts
+++ b/scripts/verify/dotnet/selection.ts
@@ -1,23 +1,12 @@
-import { readFile } from "node:fs/promises";
-import path, { posix } from "node:path";
+import { posix } from "node:path";
-import { z } from "zod";
-
-import { projectRoot } from "@/scripts/project.js";
import { claimedKey } from "@/scripts/tasks/features/config.js";
import { documentOf, dotnet as dotnetFeature } from "@/scripts/tasks/features/dotnet/feature.js";
import type { Component, FeatureConfig } from "@/scripts/tasks/features/dotnet/generateConfig.js";
import { configPath, readConfig } from "@/scripts/tasks/features/dotnet/generateConfig.js";
import { imageEntry } from "@/scripts/tasks/features/imageEntries.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({
- mounts: z.array(z.object({ source: z.string(), target: z.string() })),
-});
+import { userInit } from "../userInit.js";
export interface ComponentImage {
component: Component;
@@ -57,18 +46,8 @@ const tfmOf = (sdkVersion: string): string => {
return `net${match[1]}.${match[2]}`;
};
-const readGlobalPackagesPath = async (): Promise => {
- const data: unknown = JSON.parse(await readFile(userInitFeaturePath, "utf-8"));
- const result = userInitFeatureSchema.safeParse(data);
- if (!result.success) {
- throw new Error(`${userInitFeaturePath}: ${z.prettifyError(result.error)}`);
- }
- 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 readGlobalPackagesPath = async (): Promise =>
+ posix.join(await userInit.xdgPath("XDG_DATA_HOME"), "NuGet/global-packages");
const derive = async (channel: string, prefix: string, target?: SelectionTarget): Promise => {
const config = await readConfig();
diff --git a/scripts/verify/userInit.ts b/scripts/verify/userInit.ts
new file mode 100644
index 0000000..d7f223e
--- /dev/null
+++ b/scripts/verify/userInit.ts
@@ -0,0 +1,37 @@
+import { readFile } from "node:fs/promises";
+import path, { posix } from "node:path";
+
+import { z } from "zod";
+
+import { projectRoot } from "@/scripts/project.js";
+
+import { remoteUser } from "./lifecycle.js";
+
+const featurePath = path.resolve(projectRoot, ".devcontainer/features/src/user-init/devcontainer-feature.json");
+const containerId = "${devcontainerId}";
+
+const featureSchema = z.object({
+ mounts: z.array(z.object({ source: z.string(), target: z.string() })),
+});
+
+const readMounts = async (): Promise => {
+ const data: unknown = JSON.parse(await readFile(featurePath, "utf-8"));
+ const result = featureSchema.safeParse(data);
+ if (!result.success) {
+ throw new Error(`${featurePath}: ${z.prettifyError(result.error)}`);
+ }
+ return result.data.mounts;
+};
+
+const xdgRoot = async (variable: string): Promise => {
+ const source = `${variable}-${containerId}`;
+ const root = (await readMounts()).find((mount) => mount.source === source)?.target;
+ if (root === undefined) {
+ throw new Error(`${featurePath}: no mounts entry with source ${source}`);
+ }
+ return root;
+};
+
+const xdgPath = async (variable: string): Promise => posix.join(await xdgRoot(variable), remoteUser);
+
+export const userInit = { xdgRoot, xdgPath } as const;
diff --git a/scripts/verify/uv/selection.ts b/scripts/verify/uv/selection.ts
index 052c7f7..cbf561c 100644
--- a/scripts/verify/uv/selection.ts
+++ b/scripts/verify/uv/selection.ts
@@ -1,9 +1,3 @@
-import { readFile } from "node:fs/promises";
-import path, { posix } from "node:path";
-
-import { z } from "zod";
-
-import { projectRoot } from "@/scripts/project.js";
import { hostArch } from "@/scripts/tasks/features/architecture.js";
import { claimedKey } from "@/scripts/tasks/features/config.js";
import { imageEntry } from "@/scripts/tasks/features/imageEntries.js";
@@ -12,14 +6,7 @@ import type { Component, FeatureConfig, PythonVersion } from "@/scripts/tasks/fe
import { configPath, readConfig } from "@/scripts/tasks/features/uv/generateConfig.js";
import { machineOf, muslTriples } from "@/scripts/tasks/features/uv/paths.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({
- mounts: z.array(z.object({ source: z.string(), target: z.string() })),
-});
+import { userInit } from "../userInit.js";
export interface ComponentImage {
component: Component;
@@ -101,19 +88,6 @@ const previousPython = (
return previous === undefined ? undefined : pythonImage(config, previous[0], channel, prefix);
};
-const readXdgDataPath = async (): Promise => {
- const data: unknown = JSON.parse(await readFile(userInitFeaturePath, "utf-8"));
- const result = userInitFeatureSchema.safeParse(data);
- if (!result.success) {
- throw new Error(`${userInitFeaturePath}: ${z.prettifyError(result.error)}`);
- }
- 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);
-};
-
const derive = async (channel: string, prefix: string, target?: SelectionTarget): Promise => {
const config = await readConfig();
const uvKey =
@@ -129,7 +103,7 @@ const derive = async (channel: string, prefix: string, target?: SelectionTarget)
uv: componentImage(config, "uv", uvKey, channel, prefix),
python: pythonImage(config, pythonKey, channel, prefix),
previous: previousPython(config, pythonKey, channel, prefix),
- xdgDataPath: await readXdgDataPath(),
+ xdgDataPath: await userInit.xdgPath("XDG_DATA_HOME"),
machine: machineOf(arch),
muslTriple: muslTriples[arch],
};
From c2cedc37cc8c6251920976573b5d7c3add708730 Mon Sep 17 00:00:00 2001
From: gdlol <.>
Date: Sun, 8 Nov 2026 11:57:02 +0000
Subject: [PATCH 2/2] Trim notes
---
features/code-server/NOTES.md | 38 ++--------------------------------
features/code-server/README.md | 38 ++--------------------------------
2 files changed, 4 insertions(+), 72 deletions(-)
diff --git a/features/code-server/NOTES.md b/features/code-server/NOTES.md
index d12fa06..370a78a 100644
--- a/features/code-server/NOTES.md
+++ b/features/code-server/NOTES.md
@@ -1,28 +1,14 @@
-## Staging
-
-The consumer copies the feature directory as shown in Example Usage. The `features` dispatcher runs `install` at image build time and `postStart` when the container starts. `postStart` runs as the container user and starts the server through `launch`, which detaches the server's stdio and appends its output to the log, so the lifecycle hook returns.
-
-## Options file
-
-Options are read from `options.json` in the feature directory:
+## Example Options
```json
{
"auth": "password",
- "passwordFile": "/run/secrets/code-server-password",
+ "hashedPasswordFile": "/run/secrets/code-server-password",
"host": "127.0.0.1",
"port": 8080
}
```
-`passwordFile`, `hashedPasswordFile` and `githubAuthTokenFile` are read at `postStart` into `PASSWORD`, `HASHED_PASSWORD` and `GITHUB_TOKEN`. Trailing newlines are removed. `HASHED_PASSWORD` takes precedence over `PASSWORD`.
-
-`workspace` is the positional workspace path. An empty value uses the hook's current directory, the workspace folder.
-
-## Authentication
-
-`auth` defaults to `none`, which gives anything that can reach the listen address the remote user's access. `host` defaults to `127.0.0.1`, so the server accepts connections from inside the container only. A consumer that publishes the port sets `host` to `0.0.0.0` and uses `auth: password` or terminates access at a reverse proxy.
-
## Address
With `socket` set, the server listens on that absolute Unix socket with `socketMode` as its file mode. `host` and `port` are ignored. Without `socket`, the server listens on `host:port`.
@@ -31,23 +17,3 @@ With `socket` set, the server listens on that absolute Unix socket with `socketM
- Log: `${XDG_CACHE_HOME:-$HOME/.cache}/code-server/code-server.log`
- PID: `${XDG_STATE_HOME:-$HOME/.local/state}/code-server/code-server.pid`
-
-An unset, empty or relative XDG value uses the `$HOME` path.
-
-## Arguments
-
-`args` is an array of code-server arguments appended after the option-derived arguments:
-
-```json
-{ "args": ["--verbose", "--bind-addr", "0.0.0.0:9000"] }
-```
-
-One element is one argument. A later scalar flag wins in code-server, so `args` can override an option. `--` ends option parsing.
-
-## Requirements
-
-Linux with glibc 2.28 or newer and libstdc++ GLIBCXX_3.4.21, on amd64 or arm64. The feature installs no packages and adds no dependencies.
-
-## Restart
-
-A plain `docker restart` does not run `postStart`. Use `devcontainer up` or start `/usr/local/bin/code-server` manually.
diff --git a/features/code-server/README.md b/features/code-server/README.md
index 2771f4d..b72b315 100644
--- a/features/code-server/README.md
+++ b/features/code-server/README.md
@@ -37,31 +37,17 @@ COPY --from=code-server /features/code-server/ /opt/devcontainer-config/features
| verbose | Pass --verbose. | boolean | false |
| args | One code-server argument per element, appended after the option-derived arguments. | array | [] |
-## Staging
-
-The consumer copies the feature directory as shown in Example Usage. The `features` dispatcher runs `install` at image build time and `postStart` when the container starts. `postStart` runs as the container user and starts the server through `launch`, which detaches the server's stdio and appends its output to the log, so the lifecycle hook returns.
-
-## Options file
-
-Options are read from `options.json` in the feature directory:
+## Example Options
```json
{
"auth": "password",
- "passwordFile": "/run/secrets/code-server-password",
+ "hashedPasswordFile": "/run/secrets/code-server-password",
"host": "127.0.0.1",
"port": 8080
}
```
-`passwordFile`, `hashedPasswordFile` and `githubAuthTokenFile` are read at `postStart` into `PASSWORD`, `HASHED_PASSWORD` and `GITHUB_TOKEN`. Trailing newlines are removed. `HASHED_PASSWORD` takes precedence over `PASSWORD`.
-
-`workspace` is the positional workspace path. An empty value uses the hook's current directory, the workspace folder.
-
-## Authentication
-
-`auth` defaults to `none`, which gives anything that can reach the listen address the remote user's access. `host` defaults to `127.0.0.1`, so the server accepts connections from inside the container only. A consumer that publishes the port sets `host` to `0.0.0.0` and uses `auth: password` or terminates access at a reverse proxy.
-
## Address
With `socket` set, the server listens on that absolute Unix socket with `socketMode` as its file mode. `host` and `port` are ignored. Without `socket`, the server listens on `host:port`.
@@ -71,26 +57,6 @@ With `socket` set, the server listens on that absolute Unix socket with `socketM
- Log: `${XDG_CACHE_HOME:-$HOME/.cache}/code-server/code-server.log`
- PID: `${XDG_STATE_HOME:-$HOME/.local/state}/code-server/code-server.pid`
-An unset, empty or relative XDG value uses the `$HOME` path.
-
-## Arguments
-
-`args` is an array of code-server arguments appended after the option-derived arguments:
-
-```json
-{ "args": ["--verbose", "--bind-addr", "0.0.0.0:9000"] }
-```
-
-One element is one argument. A later scalar flag wins in code-server, so `args` can override an option. `--` ends option parsing.
-
-## Requirements
-
-Linux with glibc 2.28 or newer and libstdc++ GLIBCXX_3.4.21, on amd64 or arm64. The feature installs no packages and adds no dependencies.
-
-## Restart
-
-A plain `docker restart` does not run `postStart`. Use `devcontainer up` or start `/usr/local/bin/code-server` manually.
-
---
_Note: This file was auto-generated from the [feature.json](https://github.com/devcontainer-config/features/blob/main/features/code-server/feature.json). Add additional notes to a `NOTES.md`._