From 1a83a19a4f4bda62f1ad919da53e4dac3b81204f Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 02:25:20 +0000 Subject: [PATCH 01/10] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=F0=9F=94=A7?= =?UTF-8?q?=EF=BC=9Apin=20.nvmrc=20to=20the=20version=20package.json=20req?= =?UTF-8?q?uires?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `engines.node` is an exact pin and .npmrc sets engine-strict, so a floating alias in .nvmrc stops matching the moment the next patch release lands in that line -- at which point nvm.fish activates a version that every pnpm command then rejects. nvm.fish resolves a bare `nvm use` from this file, so it has to name the same version the package manager enforces. Co-Authored-By: Claude Opus 5 --- .nvmrc | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.nvmrc b/.nvmrc index b03f40867..60ade1ae0 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/krypton +24.19.0 From aaed9c70a7858e1040a997490a39dd0b0fdcffdc Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 02:25:20 +0000 Subject: [PATCH 02/10] =?UTF-8?q?=F0=9F=8F=97=EF=B8=8F=F0=9F=94=A7?= =?UTF-8?q?=EF=BC=9Ahoist=20the=20dprint=20platform=20package=20where=20it?= =?UTF-8?q?s=20extension=20looks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dprint VS Code extension resolves its executable from node_modules/@dprint/--, walking up from the workspace. That package is an optionalDependency of dprint, so pnpm keeps it in the virtual store and the directory the extension looks in never exists. Failing that, the extension falls back to a bare `dprint` on PATH, which was only ever on fish's PATH, so it found nothing either. The workaround had been a separately installed dprint. That copy came from an install script that always fetches the latest release, so the editor could format with one version while CI checked with the pinned one. Hoisting publicly puts a real native binary at the path the extension prefers, at the version package.json pins. Note this belongs in pnpm-workspace.yaml rather than .npmrc: pnpm 11 moved these settings, and `public-hoist-pattern` in .npmrc is silently ignored. Verified: node_modules/@dprint/linux-arm64-glibc/dprint reports 0.55.2, matching the devDependency, and the toml and dockerfile tasks still pass. Co-Authored-By: Claude Opus 5 --- pnpm-workspace.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 0b5c77966..a2196d434 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -15,3 +15,10 @@ allowBuilds: es5-ext: false esbuild: false vnu-jar: false + +# The dprint VS Code extension resolves its executable from +# node_modules/@dprint/. That package is an optionalDependency of +# dprint, so pnpm keeps it in the virtual store where the extension cannot see +# it -- which is why the extension used to need a separately installed dprint. +publicHoistPattern: + - '@dprint/*' From 294e9a166c4a911f1904ea2ac5160e7ab3705a1b Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 02:25:41 +0000 Subject: [PATCH 03/10] =?UTF-8?q?=F0=9F=90=8B=E2=99=BB=EF=B8=8F=EF=BC=9Are?= =?UTF-8?q?place=20the=20dev=20container=20with=20a=20stock=20image=20and?= =?UTF-8?q?=20features?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Swaps a bespoke Dockerfile, a .dockerignore and ~90 lines of fish across two lifecycle scripts for a stock image, four features and one bash script. The old base image, openinf/grimesai-salvage-tex:lunar, was last published in February 2024, so the daily development environment had been sitting on a two-year-old base. Only two repositories in the org reference it, and after this, one. Dropped as dead, each checked before removing: - ruby-dev, rbenv and the rbenv PATH entry. package-scripts.yml maps verify.ruby and format.ruby to two task files that do not exist, and there is no Gemfile, .ruby-version or Rakefile. The rubocop extension and the [ruby] block in .vscode/settings.json were already commented out. - Ports 4000 and 35729, which are Jekyll's server and LiveReload. This site is Eleventy behind browser-sync, on 3000 with its UI on 3001. - The dprint install script, which fetched an unpinned second copy of a tool already pinned in devDependencies. The previous commit fixes the reason it was needed. - build-essential, now that every install script is denied and nothing compiles from source. - The sshd on 2222. VS Code forwards the host's ssh-agent and gpg-agent natively, which is what commit signing actually depends on. Kept: default-jre, without which verify.svg and verify.htmlValidForVNU die on `command not found: java`; git-lfs, which .gitattributes routes 19 file patterns through; the extension list; and fish. Two Node version managers are set up, because they do different jobs. nvm.fish is the interactive one -- a bare `nvm use` reads .nvmrc, and it switches that fish session only. The image's nvm sets the container-wide baseline: the image puts nvm/current/bin on the container's PATH and sets NVM_SYMLINK_CURRENT, so `nvm use` there repoints a symlink that every non-fish process follows. Neither can do the other's job -- nvm.fish prepends to fish's own PATH and keeps versions under XDG_DATA_HOME, so nothing outside fish sees it. No Node version appears in devcontainer.json. It is in package.json and .nvmrc, each read by a different tool, and post-create.sh fails with an explanation if the two disagree rather than adding a third copy. node_modules/.bin now reaches the whole container through remoteEnv instead of `fish_add_path (pnpm bin)` in config.fish, which left every non-fish caller without those binaries. The fish feature's own fisher install is disabled because it pulls from main; post-create.sh installs a pinned tag instead. Verified without a container rebuild, which is not possible here: the fisher and nvm.fish installs, `nvm use` resolving .nvmrc, the universal nvm_default_version that new interactive sessions read, agreement between fish, bash and sh on the required version, and the full verify suite. Co-Authored-By: Claude Opus 5 --- .devcontainer/.dockerignore | 2 - .devcontainer/devcontainer.json | 87 +++++++++++++----------- .devcontainer/experimental/Dockerfile | 36 ---------- .devcontainer/post-create.sh | 97 +++++++++++++++++++++++++++ tools/devcontainer/post-create.fish | 21 ------ tools/devcontainer/post-start.fish | 88 ------------------------ 6 files changed, 145 insertions(+), 186 deletions(-) delete mode 100755 .devcontainer/.dockerignore delete mode 100644 .devcontainer/experimental/Dockerfile create mode 100755 .devcontainer/post-create.sh delete mode 100644 tools/devcontainer/post-create.fish delete mode 100644 tools/devcontainer/post-start.fish diff --git a/.devcontainer/.dockerignore b/.devcontainer/.dockerignore deleted file mode 100755 index 93f136199..000000000 --- a/.devcontainer/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -node_modules -npm-debug.log diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 8eee0f1f5..a786ed708 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,27 +1,58 @@ // For format details, see https://aka.ms/devcontainer.json. For config options, -// see documentation located at the following URL: -// https://github.com/OpenINF/docker-fisher +// see https://containers.dev. { - "name": "🦞 OpenINF GrimesAI Salvage Tex: Lunar (OpenINF Community)", - "build": { - "dockerfile": "./experimental/Dockerfile" + "name": "OpenINF portal", + + // A stock image plus features, rather than a bespoke Dockerfile. + "image": "mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie", + + // No Node version appears in this file on purpose. It lives in package.json's + // `engines.node` and in .nvmrc -- the first is what pnpm enforces, the second + // is what nvm.fish reads for a bare `nvm use`. Both files are needed by their + // own tool, so post-create.sh checks they agree rather than adding a third + // copy here. + "features": { + // pnpm self-manages to the version in `packageManager`, so any recent + // release is enough to bootstrap. + "ghcr.io/devcontainers-extra/features/pnpm:2": {}, + // Required by vnu-jar, which backs `verify.svg` and + // `verify.htmlValidForVNU`. Without it both tasks fail with + // `command not found: java`. + "ghcr.io/devcontainers/features/java:1": { + "version": "21", + "installMaven": false, + "installGradle": false + }, + // .gitattributes routes archives, images and fonts through LFS. + "ghcr.io/devcontainers/features/git-lfs:1": {}, + // The interactive shell. Its own fisher install is disabled because it + // pulls fisher from `main`; post-create.sh installs a pinned tag instead, + // then adds nvm.fish so `nvm use` reads .nvmrc the fish way. + "ghcr.io/meaningful-ooo/devcontainer-features/fish:1": { + "fisher": false + } + }, + + // browser-sync, started by `pnpm start`, serves on 3000 with its UI on 3001. + "forwardPorts": [3000, 3001], + + // So `dprint`, `biome`, `nps`, `eleventy` and friends work when typed + // directly, not only under `pnpm exec`. The old setup did this with + // `fish_add_path (pnpm bin)` in config.fish, which left every non-fish + // caller -- CI, editor tasks, agents -- without them. Here it applies to the + // whole container, which is also how the workflows do it (they append + // node_modules/.bin to GITHUB_PATH). + "remoteEnv": { + "PATH": "${containerWorkspaceFolder}/node_modules/.bin:${containerEnv:PATH}" }, - "runArgs": [], - // Configure tool-specific properties. + + "postCreateCommand": "bash .devcontainer/post-create.sh", + "customizations": { - // Configure properties specific to VS Code. "vscode": { - // Set *default* container-specific settings.json values on container - // create. "settings": { - "terminal.integrated.profiles.linux": { - "fish": { - "path": "fish" - } - }, "terminal.integrated.defaultProfile.linux": "fish" }, - // Add the IDs of extensions you want installed when the container is created. "extensions": [ // https://marketplace.visualstudio.com/items?itemName=aaron-bond.better-comments "aaron-bond.better-comments", @@ -41,8 +72,6 @@ "KatjanaKosic.vscode-json5", // https://marketplace.visualstudio.com/items?itemName=redhat.vscode-xml "redhat.vscode-xml", - // https://marketplace.visualstudio.com/items?itemName=rubocop.vscode-rubocop - // "rubocop.vscode-rubocop", // https://marketplace.visualstudio.com/items?itemName=Shopify.theme-check-vscode "Shopify.theme-check-vscode", // https://marketplace.visualstudio.com/items?itemName=streetsidesoftware.code-spell-checker @@ -54,26 +83,6 @@ ] } }, - // Use 'forwardPorts' to make a list of ports inside the container available - // locally. - "forwardPorts": [ - 4000, // Jekyll server - 35729, // Live reload server - 2222 // OpenSSH server - ], - "overrideCommand": false, - "workspaceMount": "source=${localWorkspaceFolder},target=/workspace,type=bind", - "workspaceFolder": "/workspace", - - // Use 'postCreateCommand' to run commands after creating the container. - // https://code.visualstudio.com/docs/devcontainers/create-dev-container#_rebuild - "postCreateCommand": "fish -i '${containerWorkspaceFolder}/tools/devcontainer/post-create.fish'", - "postStartCommand": "fish -i '${containerWorkspaceFolder}/tools/devcontainer/post-start.fish'", - "remoteEnv": { - "PATH": "${containerEnv:PATH}:~/.rbenv/shims/" - }, - // Comment out to connect as root instead. - // More info: https://aka.ms/vscode-remote/containers/non-root. - "remoteUser": "vscode" + "remoteUser": "node" } diff --git a/.devcontainer/experimental/Dockerfile b/.devcontainer/experimental/Dockerfile deleted file mode 100644 index e00cc16cf..000000000 --- a/.devcontainer/experimental/Dockerfile +++ /dev/null @@ -1,36 +0,0 @@ -FROM openinf/grimesai-salvage-tex:lunar - -ARG USERNAME=vscode -ARG USER_UID=1000 -ARG USER_GID=$USER_UID -ARG UPGRADE_PACKAGES="true" - -USER 0 - -# COPY library-scripts/*.sh /tmp/library-scripts/ -# skipcq: DOK-DL3008 -RUN apt-get update && export DEBIAN_FRONTEND=noninteractive \ -# && /bin/bash /tmp/library-scripts/common-debian.sh "${USERNAME}" "${USER_UID}" "${USER_GID}" "${UPGRADE_PACKAGES}" \ -# && /bin/bash /tmp/library-scripts/fish-debian.sh "${USERNAME}" \ -# && /bin/bash /tmp/library-scripts/sshd-debian.sh "2222" "${USERNAME}" "true" "root" \ -# -# **************************************************************************** -# * TODO: Add any additional OS packages you want included in the definition * -# * here. We want to do this before cleanup to keep the "layer" small. * -# **************************************************************************** - && apt-get -y install --no-install-recommends build-essential default-jre ruby-dev git-lfs \ - && apt-get clean \ - && rm -rf /var/lib/apt/lists/* - -USER ${USERNAME} - -# ENV Variables required by Jekyll. -ENV LANG=en_US.UTF-8 \ - LANGUAGE=en_US:en \ - TZ=Etc/UTC \ - LC_ALL=en_US.UTF-8 \ - LANG=en_US.UTF-8 \ - LANGUAGE=en_US - -ENTRYPOINT ["/usr/local/share/ssh-init.sh"] -CMD ["sleep", "infinity"] diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh new file mode 100755 index 000000000..775831240 --- /dev/null +++ b/.devcontainer/post-create.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# ------------------------------------------------------------------------------ +# Copyright (c) The OpenINF Authors & Friends. All rights reserved. +# License: MIT OR Apache-2.0 OR BlueOak-1.0.0 +# ------------------------------------------------------------------------------ +# +# Runs once, after the container is created. +# +# Two Node version managers are set up here on purpose, because they do +# different jobs and neither can do the other's: +# +# nvm.fish - what you use. `nvm use` with no arguments reads .nvmrc, and it +# switches the version for your fish session only, which is what +# you want from an interactive version switch. +# the image's nvm - sets the container-wide baseline. The image puts +# /usr/local/share/nvm/current/bin on the container's PATH and +# sets NVM_SYMLINK_CURRENT, so `nvm use` here repoints a symlink +# that every non-fish process follows: editor tasks, CI, agents. +# +# nvm.fish cannot serve that second role -- it prepends to fish's own PATH and +# keeps its versions under $XDG_DATA_HOME/nvm, so nothing outside fish sees it. +# The image's nvm cannot serve the first -- it is not a fish program. + +set -euo pipefail + +readonly FISHER_VERSION=4.4.8 + +# .nvmrc is what nvm.fish reads; engines.node is what pnpm enforces. Both files +# have to exist for their own tool, so the only thing to guard is that they say +# the same thing -- an exact version, since a floating alias like lts/krypton +# resolves to whatever the newest release in that line happens to be and will +# eventually stop matching the exact pin in package.json. +required="$(node -p 'require("./package.json").engines.node')" +declared="$(tr -d '[:space:]' < .nvmrc)" + +if [ "${declared}" != "${required}" ]; then + cat >&2 </dev/null +nvm alias default "${required}" >/dev/null +nvm use "${required}" >/dev/null + +corepack enable +pnpm install + +# ------------------------------------------------------------------------------ +# fish tooling +# ------------------------------------------------------------------------------ + +# The old base image shipped fisher; a stock image does not. Pinned rather than +# tracking main, since this is a remote script being sourced. +fish -c " + curl -fsSL https://raw.githubusercontent.com/jorgebucaran/fisher/${FISHER_VERSION}/functions/fisher.fish | source + and fisher install jorgebucaran/fisher@${FISHER_VERSION} + and fisher install OpenINF/openinf-nvm.fish +" + +# Install the pinned version into nvm.fish's own store so a bare `nvm use` +# works immediately, and set nvm_default_version, which is what nvm.fish's +# conf.d reads to activate a version in new interactive sessions. `nvm use` +# alone would not do it: it sets a session variable, so it would apply to this +# one throwaway shell and nothing else. +fish -c " + nvm install (cat .nvmrc) + and set --universal nvm_default_version (cat .nvmrc) +" >/dev/null 2>&1 || true + +# gpg needs its keyring to exist before commit signing works. VS Code forwards +# the host's gpg-agent, so no key material is created or copied here. +gpg --list-keys >/dev/null 2>&1 || true + +cat <> $HOME/.config/fish/config.fish - -# ********************* -# Subroutines -# ********************* - -# function - -# ------------------------------------------------------------------------------ - -source $HOME/.config/fish/config.fish - diff --git a/tools/devcontainer/post-start.fish b/tools/devcontainer/post-start.fish deleted file mode 100644 index 1da3c3390..000000000 --- a/tools/devcontainer/post-start.fish +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env fish -# ------------------------------------------------------------------------------ -# Copyright (c) The OpenINF Authors & Friends. All rights reserved. -# License: MIT OR Apache-2.0 OR BlueOak-1.0.0 -# ------------------------------------------------------------------------------ - -# ********************* -# Requirements -# ********************* - -set -gx SHELL fish -set -gx COREPACK_ENABLE_DOWNLOAD_PROMPT 0 - -# ********************* -# Fisher Plugins -# ********************* - -fisher install OpenINF/openinf-nvm.fish -fisher install OpenINF/openinf-bass - -######################################################################## - -# If there's a `.nvmrc`, then run `nvm install`. -if test -e .nvmrc - # Install the specified version of Node.js. - nvm install -end - -# If there's a package.json, then run `pnpm install`. -if test -e package.json - corepack enable - corepack prepare pnpm@latest --activate - bass pnpm setup >> /dev/null - source $HOME/.config/fish/config.fish - pnpm add -g pnpm - pnpm install -end - -echo 'rbenv rehash && nvm use' >> $HOME/.config/fish/config.fish - -# Installs the dprint utility, and adds it to the user's $PATH. -# It makes the tool available to the user when one opens a new terminal window. - -# Install dprint -bass curl -fsSL https://dprint.dev/install.sh | sh >> /dev/null -set -gx DPRINT_INSTALL $HOME/.dprint -echo 'set -gx DPRINT_INSTALL $HOME/.dprint' >> $HOME/.config/fish/config.fish -fish_add_path -g $DPRINT_INSTALL -echo 'fish_add_path -g $DPRINT_INSTALL' >> $HOME/.config/fish/config.fish - -set -gx DPRINT_HOME $DPRINT_INSTALL/bin -echo 'set -gx DPRINT_HOME $DPRINT_INSTALL/bin' >> $HOME/.config/fish/config.fish -fish_add_path -g $DPRINT_HOME -echo 'fish_add_path -g $DPRINT_HOME' >> $HOME/.config/fish/config.fish - -# Add dir in which executables of local Node deps are linked to the user's $PATH. -echo 'fish_add_path -g (pnpm bin)' >> $HOME/.config/fish/config.fish - -######################################################################## - -# Populate user's $HOME/.gnupg directory with empty keyring files -# it will create the $HOME/.gnupg directory if it does not already exist (expected) -gpg --list-keys - -# If there's a .gnupg directory, then perform the following setup tasks. -if test -e $HOME/.gnupg/ - # Configure Git to use `gpg2`. - echo 'git config --global gpg.program gpg2' >> $HOME/.config/fish/config.fish - - echo 'set -gx GPG_TTY (tty)' >> $HOME/.config/fish/config.fish - - # To fix the " gpg: WARNING: unsafe permissions on homedir - # '/home/path/to/user/.gnupg' " error, make sure that the .gnupg directory and - # its contents is accessibile by your user. - # chown -R (whoami) $HOME/.gnupg/ - - # Also correct the permissions and access rights on the directory. - # chmod 600 $HOME/.gnupg/* - # chmod 700 $HOME/.gnupg - - echo no-autostart >> $HOME/.gnupg/gpg.conf - - # Remove an existing Unix-domain socket file for remote port forwarding before - # creating a new one when gpgtunnel connection is made. - # rm $HOME/.gnupg/S.gpg-agent - - printf '\n%s\n\n\t%s\n\n' 'Enable commit signing:' 'git config --global commit.gpgsign true' -end From 2620cd4a27b990d964953a6a1b359bb65088323e Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 02:51:10 +0000 Subject: [PATCH 04/10] =?UTF-8?q?=F0=9F=90=8B=F0=9F=94=A7=EF=BC=9Ainstall?= =?UTF-8?q?=20fish=20from=20debian=20rather=20than=20an=20opensuse=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The meaningful-ooo fish feature adds an openSUSE Build Service apt source, and its v1 points at the fish 3 repository, which has no Debian 13 directory. The build failed on it: deb http://download.opensuse.org/repositories/shells:/fish:/release:/3/Debian_13/ / Err:8 ... shells:/fish:/release:/3/Debian_13 Release 404 Not Found Its v2 moved to the fish 4 repository, which does resolve for Debian 13, but that only postpones the problem: it leaves the container depending on OBS publishing a directory for every future Debian release, and this is what happens when they do not. Trixie ships fish 4.0.2 in its own repositories, which is also the build that the fisher and nvm.fish setup was tested against, so this drops the third-party apt source entirely. The other three features installed fine -- the failure was the last step of the build, with pnpm, git-lfs and java already cached above it. Co-Authored-By: Claude Opus 5 --- .devcontainer/devcontainer.json | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index a786ed708..7bb8ac062 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -25,12 +25,17 @@ }, // .gitattributes routes archives, images and fonts through LFS. "ghcr.io/devcontainers/features/git-lfs:1": {}, - // The interactive shell. Its own fisher install is disabled because it - // pulls fisher from `main`; post-create.sh installs a pinned tag instead, - // then adds nvm.fish so `nvm use` reads .nvmrc the fish way. - "ghcr.io/meaningful-ooo/devcontainer-features/fish:1": { - "fisher": false - } + // The interactive shell, from Debian's own repository. The obvious + // alternative, meaningful-ooo/fish, adds an openSUSE Build Service apt + // source instead -- and its v1 points at the fish 3 repo, which returns 404 + // for Debian 13 and fails the build outright. Its v2 moved to the fish 4 + // repo, which does resolve, but that leaves the container depending on OBS + // publishing for every future Debian release. Trixie ships fish 4.0.2 + // itself, so no third-party apt source is needed for it. + // + // Neither feature installs fisher the way this repository wants it: + // post-create.sh installs a pinned tag, then adds nvm.fish on top. + "ghcr.io/devcontainers-extra/features/fish-apt-get:1": {} }, // browser-sync, started by `pnpm start`, serves on 3000 with its UI on 3001. From 367bf0039f31280cf5f7e7e966f75a5da0fccfe6 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 03:16:23 +0000 Subject: [PATCH 05/10] =?UTF-8?q?=F0=9F=90=8B=F0=9F=94=A7=EF=BC=9Astop=20p?= =?UTF-8?q?ost-create=20dying=20while=20nvm=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The script failed with a bare `exit code 3` and printed nothing at all. Sourcing nvm.sh is not a neutral act: it auto-detects .nvmrc in the working directory and tries to activate that version as it loads. The postCreateCommand runs in the workspace, .nvmrc names an exact version, and in a freshly built container nothing is installed yet -- so that lookup resolves to N/A and returns 3, which under `set -e` killed the script before it reached the install a few lines below. It said nothing because nvm reports through nvm_echo, which writes to stdout, and the three nvm calls were redirecting stdout to /dev/null. So the one message that explained the failure was the one being thrown away. Sourcing may now fail without taking the script down, nothing is silenced, each phase announces itself, and the version is checked after the switch rather than assumed. Verified by running the script end to end against an empty NVM_DIR with no terminal attached, from the repository root, which is what the container does: it now reaches the end and exits 0. Co-Authored-By: Claude Opus 5 --- .devcontainer/post-create.sh | 37 ++++++++++++++++++++++++++++++++---- 1 file changed, 33 insertions(+), 4 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index 775831240..f0e7255ab 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -47,12 +47,39 @@ fi # Container-wide baseline, for everything that is not a fish session. # ------------------------------------------------------------------------------ +# Nothing below is silenced. nvm reports several failures through nvm_echo, +# which writes to stdout, so redirecting it away turns a diagnosable problem +# into a bare non-zero exit -- `nvm use` on a version that is not installed +# returns 3 and says why, but only if you let it speak. + +echo "==> Node ${required} (image ships $(node -v))" + +# Sourcing nvm.sh is allowed to fail. It auto-detects .nvmrc in the working +# directory and tries to activate that version as it loads; in a container +# where nothing is installed yet, that resolves to N/A and returns 3. Under +# `set -e` that killed this script before it reached the install below, with no +# message, because the explanation goes through nvm_echo to stdout. # shellcheck source=/dev/null -. "${NVM_DIR}/nvm.sh" -nvm install "${required}" >/dev/null -nvm alias default "${required}" >/dev/null -nvm use "${required}" >/dev/null +. "${NVM_DIR}/nvm.sh" || true +if ! nvm install "${required}"; then + echo "Failed to install Node ${required} via ${NVM_DIR}." >&2 + exit 1 +fi + +nvm alias default "${required}" +nvm use "${required}" + +if [ "$(node -v)" != "v${required}" ]; then + echo "Expected v${required} after nvm use, got $(node -v)." >&2 + exit 1 +fi + +echo "==> Dependencies" + +# Corepack prompts before fetching the pinned pnpm, which fails where there is +# no terminal to answer it. +export COREPACK_ENABLE_DOWNLOAD_PROMPT=0 corepack enable pnpm install @@ -60,6 +87,8 @@ pnpm install # fish tooling # ------------------------------------------------------------------------------ +echo "==> fish tooling" + # The old base image shipped fisher; a stock image does not. Pinned rather than # tracking main, since this is a remote script being sourced. fish -c " From 94368a57ceb24e66ceec27fcc86de41dcea4f4c2 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 03:49:23 +0000 Subject: [PATCH 06/10] =?UTF-8?q?=F0=9F=90=8B=F0=9F=94=A7=EF=BC=9Atake=20n?= =?UTF-8?q?vm.fish=20from=20upstream=20and=20install=20it=20in=20one=20pas?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things were being fetched under one banner, which read as fisher twice: fisher itself, and the nvm plugin. They are different packages, but fisher was being run twice to get them, once per plugin. It now installs both in a single `fisher install`, so there is one pass and one pair of fetches. The curl above it is fisher's own bootstrap -- it defines the function in memory so it can then install itself properly -- and is not removable without giving up a persistent fisher. The plugin now comes from jorgebucaran/nvm.fish rather than the OpenINF fork. The fork is byte-identical to upstream, zero commits ahead or behind, so it bought nothing and cost a second source to watch. It is also pinned now, where the fork was tracked at HEAD. Verified from a container-fresh state -- no ~/.config/fish, empty NVM_DIR, no terminal attached: two fetches, two plugins installed, exit 0, and a new interactive fish session lands on the pinned Node. Co-Authored-By: Claude Opus 5 --- .devcontainer/post-create.sh | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index f0e7255ab..bb271de79 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -24,6 +24,7 @@ set -euo pipefail readonly FISHER_VERSION=4.4.8 +readonly NVM_FISH_VERSION=2.2.17 # .nvmrc is what nvm.fish reads; engines.node is what pnpm enforces. Both files # have to exist for their own tool, so the only thing to guard is that they say @@ -89,12 +90,19 @@ pnpm install echo "==> fish tooling" -# The old base image shipped fisher; a stock image does not. Pinned rather than -# tracking main, since this is a remote script being sourced. +# The old base image shipped fisher; a stock image does not. The curl is +# fisher's own bootstrap: it defines the function in memory, which is then used +# to install fisher properly so it survives into later sessions. Both are +# pinned rather than tracking a branch, since this is remote code being sourced. +# +# nvm.fish comes from upstream. The OpenINF fork it used to come from is +# byte-identical to it -- zero commits ahead or behind -- so the fork bought +# nothing and cost a second source to keep watching. +# +# One `fisher install` for both, rather than one each, so this is a single pass. fish -c " curl -fsSL https://raw.githubusercontent.com/jorgebucaran/fisher/${FISHER_VERSION}/functions/fisher.fish | source - and fisher install jorgebucaran/fisher@${FISHER_VERSION} - and fisher install OpenINF/openinf-nvm.fish + and fisher install jorgebucaran/fisher@${FISHER_VERSION} jorgebucaran/nvm.fish@${NVM_FISH_VERSION} " # Install the pinned version into nvm.fish's own store so a bare `nvm use` From 91a53a534e4db2c6bf9036e31af9c73cc8dd10de Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 05:16:03 +0000 Subject: [PATCH 07/10] =?UTF-8?q?=F0=9F=90=8B=E2=99=BB=EF=B8=8F=EF=BC=9Ain?= =?UTF-8?q?stall=20fish=20with=20apt=20instead=20of=20through=20a=20featur?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trixie packages fish 4.0.2, so installing it needs one apt call. The feature that was doing it, devcontainers-extra/fish-apt-get, downloads the 20 MB nanolayer binary and runs it as root to invoke a second feature which runs exactly that apt call -- more than twice the size of the 8.6 MB package it installs, two extra fetches, and a third-party binary executing during the image build, all to avoid writing four lines. Those four lines now live in a Dockerfile beside devcontainer.json. This is not a return to maintaining a base image: it is the same stock Microsoft image as before with one package added, and it bakes into a cached layer rather than re-running on every container create. Everything else still comes from features. Co-Authored-By: Claude Opus 5 --- .devcontainer/Dockerfile | 14 ++++++++++++++ .devcontainer/devcontainer.json | 21 +++++++-------------- 2 files changed, 21 insertions(+), 14 deletions(-) create mode 100644 .devcontainer/Dockerfile diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile new file mode 100644 index 000000000..36ba7a3a6 --- /dev/null +++ b/.devcontainer/Dockerfile @@ -0,0 +1,14 @@ +# Everything else comes from features. This exists only because fish does not +# need a feature: Debian trixie ships fish 4.0.2, so installing it is one apt +# call, and doing it here bakes it into a cached image layer. +# +# The alternative considered was ghcr.io/devcontainers-extra/features/fish-apt-get, +# which downloads the 20 MB nanolayer binary and runs it as root to invoke a +# second feature that runs exactly the apt call below -- more than twice the +# size of the package it installs, for a package the distribution already has. +FROM mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie + +RUN apt-get update \ + && apt-get install -y --no-install-recommends fish \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 7bb8ac062..3871ed395 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -3,8 +3,12 @@ { "name": "OpenINF portal", - // A stock image plus features, rather than a bespoke Dockerfile. - "image": "mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie", + // A stock image plus features. The Dockerfile beside this file adds exactly + // one thing on top of that image -- fish, which trixie packages itself -- and + // is not a return to maintaining a base image. + "build": { + "dockerfile": "Dockerfile" + }, // No Node version appears in this file on purpose. It lives in package.json's // `engines.node` and in .nvmrc -- the first is what pnpm enforces, the second @@ -24,18 +28,7 @@ "installGradle": false }, // .gitattributes routes archives, images and fonts through LFS. - "ghcr.io/devcontainers/features/git-lfs:1": {}, - // The interactive shell, from Debian's own repository. The obvious - // alternative, meaningful-ooo/fish, adds an openSUSE Build Service apt - // source instead -- and its v1 points at the fish 3 repo, which returns 404 - // for Debian 13 and fails the build outright. Its v2 moved to the fish 4 - // repo, which does resolve, but that leaves the container depending on OBS - // publishing for every future Debian release. Trixie ships fish 4.0.2 - // itself, so no third-party apt source is needed for it. - // - // Neither feature installs fisher the way this repository wants it: - // post-create.sh installs a pinned tag, then adds nvm.fish on top. - "ghcr.io/devcontainers-extra/features/fish-apt-get:1": {} + "ghcr.io/devcontainers/features/git-lfs:1": {} }, // browser-sync, started by `pnpm start`, serves on 3000 with its UI on 3001. From 9d66a087a98b2f58b116739c447f05f54b60333b Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 05:23:41 +0000 Subject: [PATCH 08/10] =?UTF-8?q?=F0=9F=90=8B=F0=9F=94=A7=EF=BC=9Asuppress?= =?UTF-8?q?=20DL3008=20and=20point=20deepsource=20at=20the=20new=20dockerf?= =?UTF-8?q?ile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things deepsource caught, one of them mine. .deepsource.toml still aimed its docker analyzer at .devcontainer/experimental/Dockerfile, which this branch deletes. Pointed at the file that now exists. DL3008 wants the apt package pinned to an exact version. That is a different bargain from pinning an npm dependency, which this repository does everywhere: Debian drops superseded versions from the archive, so the first security update to fish would leave the build asking for a version that is no longer served and failing outright, and nothing watches apt pins to raise a PR the way renovate does for the rest. The previous Dockerfile suppressed this same rule for the same reason, so the suppression is carried over rather than invented, with the reasoning written down. Co-Authored-By: Claude Opus 5 --- .deepsource.toml | 2 +- .devcontainer/Dockerfile | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/.deepsource.toml b/.deepsource.toml index 3305ce5ad..abc9d2fcb 100644 --- a/.deepsource.toml +++ b/.deepsource.toml @@ -20,4 +20,4 @@ environment = [ name = "docker" [analyzers.meta] -dockerfile_paths = [".devcontainer/experimental/Dockerfile"] +dockerfile_paths = [".devcontainer/Dockerfile"] diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 36ba7a3a6..63b398596 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -8,6 +8,13 @@ # size of the package it installs, for a package the distribution already has. FROM mcr.microsoft.com/devcontainers/typescript-node:4-24-trixie +# DL3008 wants `fish=4.0.2-1`. Pinning an apt package is not the same bargain +# as pinning an npm one: Debian drops superseded versions from the archive, so +# the first security update to fish would leave this asking for a version that +# no longer exists and fail the build outright. Nothing tracks apt pins to +# raise a PR the way renovate does for everything else here. The previous +# Dockerfile suppressed this same rule for the same reason. +# skipcq: DOK-DL3008 RUN apt-get update \ && apt-get install -y --no-install-recommends fish \ && apt-get clean \ From 1ae62d2b9d8d8050862f23b34b025cbfa784a4c0 Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 01:57:20 -0400 Subject: [PATCH 09/10] =?UTF-8?q?=F0=9F=90=8B=E2=9C=A8=EF=BC=9Aadd=20the?= =?UTF-8?q?=20dev=20container=20lock=20file?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records the digest each feature tag resolved to, so pnpm, java and git-lfs cannot shift underneath a rebuild. --- .devcontainer/devcontainer-lock.json | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 .devcontainer/devcontainer-lock.json diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 000000000..63e042dec --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,19 @@ +{ + "features": { + "ghcr.io/devcontainers-extra/features/pnpm:2": { + "version": "2.0.5", + "resolved": "ghcr.io/devcontainers-extra/features/pnpm@sha256:694c2b6182435c9e9c06f6071728b087c1181b38c18cecf0defe2ab8c11cddd6", + "integrity": "sha256:694c2b6182435c9e9c06f6071728b087c1181b38c18cecf0defe2ab8c11cddd6" + }, + "ghcr.io/devcontainers/features/git-lfs:1": { + "version": "1.2.5", + "resolved": "ghcr.io/devcontainers/features/git-lfs@sha256:71c2b371cf12ab7fcec47cf17369c6f59156100dad9abf9e4c593049d789de72", + "integrity": "sha256:71c2b371cf12ab7fcec47cf17369c6f59156100dad9abf9e4c593049d789de72" + }, + "ghcr.io/devcontainers/features/java:1": { + "version": "1.8.1", + "resolved": "ghcr.io/devcontainers/features/java@sha256:8157bab2d8d71e40b2f3128c162fab763e2b11038fdd33784549290a5d386b48", + "integrity": "sha256:8157bab2d8d71e40b2f3128c162fab763e2b11038fdd33784549290a5d386b48" + } + } +} From aca0ca7df4cbe0436c60a4b79c3fc1bb351f458f Mon Sep 17 00:00:00 2001 From: Derek Lewis Date: Sat, 8 Aug 2026 06:00:49 +0000 Subject: [PATCH 10/10] =?UTF-8?q?=F0=9F=90=8B=F0=9F=94=A7=EF=BC=9Adeclare?= =?UTF-8?q?=20the=20fish=20terminal=20profile=20again?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous configuration named the profile as well as selecting it: "terminal.integrated.profiles.linux": { "fish": { "path": "fish" } }, "terminal.integrated.defaultProfile.linux": "fish" This one had kept only the second line. VS Code can usually discover fish from /etc/shells by itself, which is why terminals still opened in it, but that is discovery doing the work rather than the configuration, and there is no reason to rely on it. Co-Authored-By: Claude Opus 5 --- .devcontainer/devcontainer.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3871ed395..f5634d058 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -49,6 +49,14 @@ "customizations": { "vscode": { "settings": { + // Declared, not just selected. VS Code can usually discover fish from + // /etc/shells on its own, but the previous configuration named it + // explicitly and there is no reason to depend on the discovery. + "terminal.integrated.profiles.linux": { + "fish": { + "path": "fish" + } + }, "terminal.integrated.defaultProfile.linux": "fish" }, "extensions": [