From 0e0b1351cf781e9d2a0eba727e137bf984594d16 Mon Sep 17 00:00:00 2001 From: Will Eastler <32074613+weastler@users.noreply.github.com> Date: Sun, 18 Oct 2026 17:18:59 -0700 Subject: [PATCH] fix(hub): support opt-in npm registry proxies in devcontainers Allow a credential-free registry URL for CLI installation and container setup, then save it in the container user's npm settings. Disable unused pnpm installation without changing locked dependencies or default registry behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: bd1d4a40-2eb6-4e35-9c28-55affc9e5e96 --- hub/.devcontainer/cli.Dockerfile | 2 + hub/.devcontainer/devcontainer.json | 3 +- hub/bin/devcontainer | 69 +++++++- hub/docs/development.md | 21 +++ hub/test/bin/devcontainer_test.rb | 240 +++++++++++++++++++++++++--- 5 files changed, 304 insertions(+), 31 deletions(-) diff --git a/hub/.devcontainer/cli.Dockerfile b/hub/.devcontainer/cli.Dockerfile index edda6b8..5c961fb 100644 --- a/hub/.devcontainer/cli.Dockerfile +++ b/hub/.devcontainer/cli.Dockerfile @@ -4,6 +4,8 @@ FROM node:24-bookworm-slim COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker COPY --from=docker /usr/local/libexec/docker /usr/local/libexec/docker +ARG NPM_CONFIG_REGISTRY=https://registry.npmjs.org/ + RUN npm install --global @devcontainers/cli@0.89.0 ENTRYPOINT ["devcontainer"] diff --git a/hub/.devcontainer/devcontainer.json b/hub/.devcontainer/devcontainer.json index 49670af..1c2fde6 100644 --- a/hub/.devcontainer/devcontainer.json +++ b/hub/.devcontainer/devcontainer.json @@ -9,7 +9,8 @@ "features": { "ghcr.io/devcontainers/features/github-cli:1": {}, "ghcr.io/devcontainers/features/node:1": { - "version": "24" + "version": "24", + "pnpmVersion": "none" } }, "forwardPorts": [19500, 3036, 6006], diff --git a/hub/bin/devcontainer b/hub/bin/devcontainer index d4522e7..854595d 100755 --- a/hub/bin/devcontainer +++ b/hub/bin/devcontainer @@ -6,7 +6,7 @@ CLI_IMAGE="replica-hub-devcontainer-cli:0.89.0" usage() { printf '%s\n' \ - "Usage: bin/devcontainer COMMAND [arguments]" \ + "Usage: bin/devcontainer COMMAND [--use-npm-proxy URL] [arguments]" \ "" \ "Commands:" \ " up Create or start the development container" \ @@ -16,11 +16,18 @@ usage() { " stop Stop the development container" \ " exec COMMAND... Run a command in the container" \ " shell Open a shell in the container" \ - " down Remove the development container" + " down Remove the development container" \ + "" \ + "Options for up, rebuild, dev, storybook, exec, and shell:" \ + " --use-npm-proxy URL Use an HTTP(S) npm registry proxy without credentials" } ensure_cli_image() { - if ! docker image inspect "$CLI_IMAGE" >/dev/null 2>&1; then + if [ -n "$npm_proxy" ]; then + CLI_IMAGE="$CLI_IMAGE-npm-proxy" + docker build --build-arg "NPM_CONFIG_REGISTRY=$npm_proxy" \ + --tag "$CLI_IMAGE" --file "$APP_ROOT/.devcontainer/cli.Dockerfile" "$APP_ROOT/.devcontainer" + elif ! docker image inspect "$CLI_IMAGE" >/dev/null 2>&1; then docker build --tag "$CLI_IMAGE" --file "$APP_ROOT/.devcontainer/cli.Dockerfile" "$APP_ROOT/.devcontainer" fi } @@ -35,7 +42,14 @@ run_cli() { "$CLI_IMAGE" "$@" } -up() { run_cli up --workspace-folder "$APP_ROOT"; } +up() { + if [ -n "$npm_proxy" ]; then + run_cli up --workspace-folder "$APP_ROOT" --remote-env "NPM_CONFIG_REGISTRY=$npm_proxy" "$@" + run_cli exec --workspace-folder "$APP_ROOT" npm config set registry "$npm_proxy" --location=user + else + run_cli up --workspace-folder "$APP_ROOT" "$@" + fi +} exec_in_container() { if [ "${REPLICA_LOCAL_MODE+x}" = x ]; then @@ -60,12 +74,53 @@ clear_server_pid() { command=${1:-help} [ "$#" -eq 0 ] || shift +npm_proxy="" +if [ "${1:-}" = --use-npm-proxy ]; then + case "$command" in + up|rebuild|dev|storybook|exec|shell) ;; + *) usage >&2; exit 2 ;; + esac + if [ "$#" -lt 2 ]; then + printf '%s\n' "--use-npm-proxy requires an HTTP(S) npm registry URL" >&2 + exit 2 + fi + npm_proxy=$2 + proxy_host="" + case "$npm_proxy" in + http://*|https://*) + proxy_host=${npm_proxy#*://} + proxy_host=${proxy_host%%/*} + ;; + esac + case "$npm_proxy" in + *[[:space:]]*|*\?*|*\#*) proxy_host="" ;; + esac + case "$proxy_host" in + ""|*@*) + printf '%s\n' "--use-npm-proxy requires an HTTP(S) registry URL without credentials, whitespace, a query, or a fragment" >&2 + exit 2 + ;; + esac + shift 2 +fi + +case "$command" in + up|rebuild|dev|storybook|shell|stop|down|help|-h|--help) + [ "$#" -eq 0 ] || { usage >&2; exit 2; } + ;; + exec) [ "$#" -gt 0 ] || { usage >&2; exit 2; } ;; + *) usage >&2; exit 2 ;; +esac + +case "$command" in + help|-h|--help) usage; exit 0 ;; +esac ensure_cli_image case "$command" in up) up ;; rebuild) - run_cli up --workspace-folder "$APP_ROOT" --remove-existing-container + up --remove-existing-container clear_server_pid ;; dev) @@ -85,7 +140,7 @@ case "$command" in exec_in_container bin/dev ;; storybook) up >/dev/null; exec_in_container npm run storybook ;; - exec) [ "$#" -gt 0 ] || { usage >&2; exit 2; }; up >/dev/null; exec_in_container "$@" ;; + exec) up >/dev/null; exec_in_container "$@" ;; shell) up >/dev/null; exec_in_container bash ;; stop) ids=$(container_ids) @@ -97,6 +152,4 @@ case "$command" in [ -z "$ids" ] || docker rm --force $ids clear_server_pid ;; - help|-h|--help) usage ;; - *) usage >&2; exit 2 ;; esac diff --git a/hub/docs/development.md b/hub/docs/development.md index 0785523..fe27e3d 100644 --- a/hub/docs/development.md +++ b/hub/docs/development.md @@ -2,6 +2,8 @@ Docker-compatible tooling is the only host requirement. Run these commands from `hub/`. The provided devcontainer provides Ruby, Node, SQLite, Git, GitHub CLI, and Chromium. +Hub uses npm. The Node devcontainer feature's optional pnpm installation is disabled. + ## Start and stop ```text @@ -32,6 +34,25 @@ For Copilot plugin development, follow the [contributor workflow](prototype-canv Use `rebuild` after changing the container definition, `stop` to stop the container, and `down` to remove it. +### Use an npm registry proxy + +If your network requires an npm registry proxy, pass its URL when starting the container: + +```sh +bin/devcontainer up --use-npm-proxy https://npm-proxy.example.com/npm/ +bin/devcontainer dev +``` + +The option also works with `dev`, `rebuild`, `storybook`, `exec`, and `shell`; place it before any `exec` command. It selects the registry for the CLI image's npm installation and the container's initial dependency setup. After startup succeeds, it saves the registry in the container user's npm configuration, so later npm commands, including CI's `npm ci`, use it without repeating the option. It also updates an existing container without rebuilding or removing it. + +The registry must serve the locked dependencies for Hub and `packages/learning/`, plus `starter/` and `actions/build/` for local mode. `up` installs the first two; the first local-mode `dev` also prepares the example and shared build dependencies. + +Use an HTTP(S) registry URL without credentials, whitespace, a query, or a fragment. This option does not configure an HTTP network proxy or disable TLS verification. It does not change the host's npm settings, repository `.npmrc` files, lockfiles, or the production image. Without the option, the wrapper leaves the registry configuration unchanged. + +Tools that fetch registry URLs directly do not use npm's settings. In particular, `bin/check-dependency-age` still requests publication metadata from the public registries. + +To remove the saved override, run `bin/devcontainer exec npm config delete registry --location=user`. Removing the container also removes its saved registry setting. + Restart `bin/devcontainer dev` after changing Rails routes or browser scripts served directly by Rails. Restart Vite when browser assets do not match the saved source. The production image includes shared Controls source from `app/javascript/utils/prototype-controls/`. It also installs `replica-learning` from `packages/learning/`, with Node.js, under `/opt/replica/learning`, because Hub runs that command to reach Copilot. From `hub/`, build it with `docker build --build-context learning=../packages/learning --tag replica-hub:local .`. CI and release builds supply that same narrow named context. The final image keeps `/rails` as its application directory and `/rails/storage` as its persistent path. diff --git a/hub/test/bin/devcontainer_test.rb b/hub/test/bin/devcontainer_test.rb index f5fff92..7e25f7d 100644 --- a/hub/test/bin/devcontainer_test.rb +++ b/hub/test/bin/devcontainer_test.rb @@ -1,8 +1,8 @@ require "test_helper" +require "fileutils" require "open3" -require "tmpdir" +require "securerandom" -# Checks startup mode selection without starting another development container. class DevcontainerTest < ActiveSupport::TestCase test "dev starts local mode and prepares the example by default" do calls = dev_calls(mode: nil) @@ -27,29 +27,225 @@ class DevcontainerTest < ActiveSupport::TestCase assert_match(/env REPLICA_LOCAL_MODE=0 bin\/dev\z/, calls[1]) end + test "up without a proxy keeps the public npm startup path" do + result = devcontainer("up", image_exists: false) + + assert result[:status].success?, result[:output] + build = result[:calls].find { |call| call.first == "build" } + assert build, "A missing CLI image must be built" + assert_nil option_value(build, "--build-arg") + assert_equal 1, cli_calls(result).length + assert_equal "up", cli_calls(result).first.first + assert_nil option_value(cli_calls(result).first, "--remote-env") + end + + test "up configures the requested proxy for CLI installation and the container lifetime" do + registry = "https://packages.example.test/npm/" + result = devcontainer("up", "--use-npm-proxy", registry) + + assert result[:status].success?, result[:output] + build = result[:calls].find { |call| call.first == "build" } + assert build, "Selecting a proxy must not reuse an existing public-registry CLI image" + assert_equal "NPM_CONFIG_REGISTRY=#{registry}", option_value(build, "--build-arg") + startup, persistence = cli_calls(result) + assert_equal "up", startup.first + assert_equal "NPM_CONFIG_REGISTRY=#{registry}", option_value(startup, "--remote-env") + assert_equal [ "npm", "config", "set", "registry", registry, "--location=user" ], exec_payload(persistence) + assert_equal 2, cli_calls(result).length + assert_equal "registry=https://host.example.test/npm/\n", result[:host_npm_config] + assert_equal "min-release-age=10080\n", result[:project_npm_config] + assert_empty result[:host_npm_calls] + refute_match(/strict[-_]ssl[= ]false|NODE_TLS_REJECT_UNAUTHORIZED=0/i, result[:calls].flatten.join(" ")) + end + + test "dev with a proxy still prepares and starts local development after configuring npm" do + registry = "https://packages.example.test/npm/" + result = devcontainer("dev", "--use-npm-proxy", registry) + + assert result[:status].success?, result[:output] + calls = cli_calls(result) + assert_equal 5, calls.length + assert_equal "NPM_CONFIG_REGISTRY=#{registry}", option_value(calls[0], "--remote-env") + assert_includes exec_payload(calls[1]), registry + assert_match(/env REPLICA_LOCAL_MODE=1 bin\/rails db:prepare\z/, calls[2].join(" ")) + assert_match(/env REPLICA_LOCAL_MODE=1 bin\/rails runner LocalDevelopment.seed_example!\z/, calls[3].join(" ")) + assert_match(/env REPLICA_LOCAL_MODE=1 bin\/dev\z/, calls[4].join(" ")) + end + + test "other startup commands accept the proxy without changing their payload" do + registry = "http://packages.example.test:4873/npm/" + commands = { + "rebuild" => [], + "storybook" => [ "npm", "run", "storybook" ], + "shell" => [ "bash" ], + "exec" => [ "printf", "%s", "two words", "--use-npm-proxy", "payload value" ] + } + + commands.each do |command, payload| + arguments = command == "exec" ? payload : [] + result = devcontainer(command, "--use-npm-proxy", registry, *arguments) + + assert result[:status].success?, "#{command}: #{result[:output]}" + calls = cli_calls(result) + assert_equal "NPM_CONFIG_REGISTRY=#{registry}", option_value(calls[0], "--remote-env"), command + assert_equal [ "npm", "config", "set", "registry", registry, "--location=user" ], exec_payload(calls[1]), command + if command == "rebuild" + assert_includes calls[0], "--remove-existing-container" + assert_equal 2, calls.length + else + assert_equal payload, exec_payload(calls[2]), command + assert_equal 3, calls.length + end + end + end + + test "exec preserves proxy-looking payload arguments and explicit container environment" do + payload = [ "printf", "%s\n", "two words", "", "--use-npm-proxy", "not a registry", "single'quote" ] + secret = "bootstrap secret with 'quotes'" + result = devcontainer("exec", *payload, mode: "0", bootstrap_secret: secret) + + assert result[:status].success?, result[:output] + calls = cli_calls(result) + assert_equal 2, calls.length + assert_nil option_value(calls.first, "--remote-env") + arguments = exec_payload(calls.last) + assert_equal "env", arguments.shift + environment = arguments.shift(2) + assert_includes environment, "REPLICA_LOCAL_MODE=0" + assert_includes environment, "REPLICA_BOOTSTRAP_SECRET=#{secret}" + assert_equal payload, arguments + assert_nil result[:calls].find { |call| call.first == "build" } + end + + test "failed startup does not save the proxy inside the container" do + result = devcontainer("up", "--use-npm-proxy", "https://packages.example.test/npm/", up_success: false) + + refute result[:status].success? + assert_equal [ "up" ], cli_calls(result).map(&:first) + end + + test "missing and invalid proxy URLs fail before Docker is called" do + invalid_arguments = [ + [], + [ "" ], + [ "not-a-url" ], + [ "ftp://packages.example.test/npm/" ], + [ "https://" ], + [ "https:///npm/" ], + [ "https://user:password@packages.example.test/npm/" ], + [ "https://packages.example.test/npm with spaces/" ], + [ "https://packages.example.test/npm/\n" ], + [ "https://packages.example.test/npm/?token=value" ], + [ "https://packages.example.test/npm/#fragment" ] + ] + + invalid_arguments.each do |arguments| + result = devcontainer("up", "--use-npm-proxy", *arguments) + + refute result[:status].success?, arguments.inspect + assert_empty result[:calls], arguments.inspect + assert_not_empty result[:output].strip, arguments.inspect + end + end + + test "unexpected command arguments fail before Docker is called" do + [ + [ "up", "unexpected" ], + [ "dev", "--unknown" ], + [ "rebuild", "--use-npm-proxy", "https://packages.example.test/npm/", "unexpected" ], + [ "storybook", "unexpected" ], + [ "shell", "unexpected" ], + [ "stop", "unexpected" ], + [ "down", "unexpected" ], + [ "exec" ], + [ "exec", "--use-npm-proxy", "https://packages.example.test/npm/" ] + ].each do |arguments| + result = devcontainer(*arguments) + + refute result[:status].success?, arguments.inspect + assert_empty result[:calls], arguments.inspect + end + end + private def dev_calls(mode:) - Dir.mktmpdir("devcontainer-test-") do |directory| - docker = File.join(directory, "docker") - calls = File.join(directory, "calls") - File.write(docker, <<~'SH') - #!/bin/sh - if [ "$1" = image ]; then exit 0; fi - printf '%s\n' "$*" >> "$DOCKER_CALLS" - SH - File.chmod(0o755, docker) - - output, error, status = Open3.capture3( - { - "PATH" => "#{directory}:#{ENV.fetch('PATH')}", - "DOCKER_CALLS" => calls, - "REPLICA_LOCAL_MODE" => mode - }, - Rails.root.join("bin/devcontainer").to_s, "dev" - ) - assert status.success?, "#{output}\n#{error}" - File.readlines(calls).map(&:strip) + result = devcontainer("dev", mode: mode) + assert result[:status].success?, result[:output] + cli_calls(result).map { |call| call.join(" ") } + end + + def cli_calls(result) + result[:calls].filter_map do |call| + next unless call.first == "run" + + command = call.index { |argument| %w[up exec].include?(argument) } + call.drop(command) end end + + def exec_payload(call) + assert call, "Expected a command inside the container" + assert_equal "exec", call.first + call.drop(call.index("--workspace-folder") + 2) + end + + def option_value(arguments, option) + index = arguments.index(option) + return arguments[index + 1] if index + + arguments.find { |argument| argument.start_with?("#{option}=") }&.delete_prefix("#{option}=") + end + + def devcontainer(*arguments, mode: nil, bootstrap_secret: nil, image_exists: true, up_success: true) + directory = Rails.root.join("test", ".devcontainer-#{SecureRandom.hex(8)}") + FileUtils.mkdir_p([ directory.join("bin"), directory.join("home") ]) + FileUtils.cp(Rails.root.join("bin/devcontainer"), directory.join("bin/devcontainer")) + File.write(directory.join(".npmrc"), "min-release-age=10080\n") + File.write(directory.join("home/.npmrc"), "registry=https://host.example.test/npm/\n") + File.write(directory.join("bin/docker"), <<~'SH') + #!/bin/sh + printf '%s\0' "$#" "$@" >> "$DOCKER_CALLS" + if [ "$1" = image ]; then exit "$DOCKER_IMAGE_STATUS"; fi + if [ "$DOCKER_UP_STATUS" != 0 ]; then + for argument do + if [ "$argument" = up ]; then exit "$DOCKER_UP_STATUS"; fi + done + fi + SH + File.write(directory.join("bin/npm"), <<~'SH') + #!/bin/sh + printf '%s\n' "$*" >> "$HOST_NPM_CALLS" + exit 1 + SH + FileUtils.chmod(0o755, [ directory.join("bin/docker"), directory.join("bin/npm") ]) + environment = { + "PATH" => "#{directory.join('bin')}:#{ENV.fetch('PATH')}", + "DOCKER_CALLS" => directory.join("calls").to_s, + "DOCKER_IMAGE_STATUS" => image_exists ? "0" : "1", + "DOCKER_UP_STATUS" => up_success ? "0" : "1", + "HOST_NPM_CALLS" => directory.join("host-npm-calls").to_s, + "HOME" => directory.join("home").to_s, + "NPM_CONFIG_USERCONFIG" => directory.join("home/.npmrc").to_s, + "REPLICA_LOCAL_MODE" => mode, + "REPLICA_BOOTSTRAP_SECRET" => bootstrap_secret + } + output, error, status = Open3.capture3 \ + environment, + directory.join("bin/devcontainer").to_s, + *arguments + recorded = directory.join("calls").exist? ? directory.join("calls").binread.split("\0", -1) : [] + calls = [] + calls << recorded.shift(recorded.shift.to_i) while recorded.length > 1 + { + calls: calls, + output: "#{output}\n#{error}", + status: status, + host_npm_config: directory.join("home/.npmrc").read, + project_npm_config: directory.join(".npmrc").read, + host_npm_calls: directory.join("host-npm-calls").exist? ? directory.join("host-npm-calls").read : "" + } + ensure + FileUtils.remove_entry(directory) if directory&.exist? + end end