Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ jobs:
"mingw",
"nginx",
"node",
"npm-packages",
"playwright-deps",
"python",
"sonar-scanner-cli",
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ Below is a list with included features, click on the link for more details.
| [mingw](./features/src/mingw/README.md) | Installs MinGW. |
| [nginx](./features/src/nginx/README.md) | Installs Nginx. |
| [node](./features/src/node/README.md) | Installs Node.js. |
| [npm-packages](./features/src/npm-packages/README.md) | Installs NPM packages globally. |
| [playwright-deps](./features/src/playwright-deps/README.md) | Installs all dependencies required to run Playwright. |
| [python](./features/src/python/README.md) | Installs Python. |
| [sonar-scanner-cli](./features/src/sonar-scanner-cli/README.md) | Installs the SonarScanner CLI. |
Expand Down
5 changes: 5 additions & 0 deletions build/build.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,11 @@ func init() {
gotaskr.Task("Feature:node:Test", func() error { return testFeature("node") })
gotaskr.Task("Feature:node:Publish", func() error { return publishFeature("node") })

////////// npm-packages
gotaskr.Task("Feature:npm-packages:Package", func() error { return packageFeature("npm-packages") })
gotaskr.Task("Feature:npm-packages:Test", func() error { return testFeature("npm-packages") })
gotaskr.Task("Feature:npm-packages:Publish", func() error { return publishFeature("npm-packages") })

////////// playwright-deps
gotaskr.Task("Feature:playwright-deps:Package", func() error { return packageFeature("playwright-deps") })
gotaskr.Task("Feature:playwright-deps:Test", func() error { return testFeature("playwright-deps") })
Expand Down
19 changes: 19 additions & 0 deletions features/src/npm-packages/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# NPM Packages (npm-packages)

Installs NPM packages globally.

## Example Usage

```json
"features": {
"ghcr.io/postfinance/devcontainer-features/npm-packages:0.1.0": {
"packages": ""
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The example usage shows an empty string for the packages option, which would result in no packages being installed. Consider using a more meaningful example, such as the one from the proposals field: "@devcontainers/cli,which@3.0.1". This would better demonstrate the feature's functionality to users.

Suggested change
"packages": ""
"packages": "@devcontainers/cli,which@3.0.1"

Copilot uses AI. Check for mistakes.
}
}
```

## Options

| Option | Description | Type | Default Value | Proposals |
|-----|-----|-----|-----|-----|
| packages | Comma-separated list of packages to install | string | <empty> | @devcontainers/cli,which@3.0.1 |
20 changes: 20 additions & 0 deletions features/src/npm-packages/devcontainer-feature.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"id": "npm-packages",
"version": "0.1.0",
"name": "NPM Packages",
"description": "Installs NPM packages globally.",
"options": {
"packages": {
"type": "string",
"proposals": [
"@devcontainers/cli,which@3.0.1"
],
"default": "",
"description": "Comma-separated list of packages to install"
}
},
"installsAfter": [
"ghcr.io/postfinance/devcontainer-features/node",
"ghcr.io/devcontainers/features/node"
]
}
4 changes: 4 additions & 0 deletions features/src/npm-packages/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
. ./functions.sh

"./installer_$(detect_arch)" \
-packages="${PACKAGES:-""}"
51 changes: 51 additions & 0 deletions features/src/npm-packages/installer.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package main

import (
"builder/installer"
"flag"
"fmt"
"os"
"os/exec"
"strings"
)

//////////
// Main
//////////

func main() {
if err := runMain(); err != nil {
fmt.Printf("Error: %v\n", err)
os.Exit(1)
}
}

func runMain() error {
// Handle the flags
packages := flag.String("packages", "", "")
flag.Parse()

if _, err := exec.LookPath("npm"); err != nil {
return fmt.Errorf("could not find npm, make sure to install it first.")
}

packagesList := strings.Split(*packages, ",")
var pkgs []string
for _, p := range packagesList {
trimmed := strings.TrimSpace(p)
if trimmed != "" {
pkgs = append(pkgs, trimmed)
}
}
if len(pkgs) == 0 {
fmt.Println("No packages specified for installation.")
return nil
}
for _, pkg := range pkgs {
fmt.Printf("Installing '%s'\n", pkg)
if err := installer.Tools.Npm.InstallGlobalPackage(pkg); err != nil {
return err
}
}
return nil
}
8 changes: 8 additions & 0 deletions features/test/npm-packages/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
#!/bin/bash
set -e

[[ -f "$(dirname "$0")/../functions.sh" ]] && source "$(dirname "$0")/../functions.sh"
[[ -f "$(dirname "$0")/functions.sh" ]] && source "$(dirname "$0")/functions.sh"

check_version "$(npm list -g @devcontainers/cli 2>&1)" "@devcontainers/cli"
check_version "$(npm list -g which@3.0.1 2>&1)" "which@3.0.1"
Copy link

Copilot AI Jan 23, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test checks for "which@3.0.1" in the npm list output, but npm list may show the package name and version differently depending on the npm version. Consider checking for just "which" to make the test more robust, or verify that npm list consistently outputs the package@version format across all test images.

Suggested change
check_version "$(npm list -g which@3.0.1 2>&1)" "which@3.0.1"
check_version "$(npm list -g which 2>&1)" "which"

Copilot uses AI. Check for mistakes.
18 changes: 18 additions & 0 deletions features/test/npm-packages/scenarios.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"install": {
"build": {
"dockerfile": "Dockerfile",
"options": [
"--add-host=host.docker.internal:host-gateway"
]
},
"features": {
"ghcr.io/postfinance/devcontainer-features/node": {
"version": "24.4.1"
},
"./npm-packages": {
"packages": "@devcontainers/cli,which@3.0.1"
}
}
}
}
5 changes: 5 additions & 0 deletions features/test/npm-packages/test-images.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[
"mcr.microsoft.com/devcontainers/base:debian-11",
"mcr.microsoft.com/devcontainers/base:debian-12",
"mcr.microsoft.com/devcontainers/base:ubuntu-24.04"
]
20 changes: 18 additions & 2 deletions installer/npm.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,15 +9,15 @@ import (

type npm struct{}

func (npm) GetLatestPackageVersion(npmPackage string) (string, error) {
func (t npm) GetLatestPackageVersion(npmPackage string) (string, error) {
stdout, stderr, err := execr.RunGetOutput(false, "npm", "view", npmPackage, "version")
if err != nil {
return "", fmt.Errorf("failed getting latest version for %s: %s", npmPackage, stderr)
}
return stdout, nil
}

func (npm) GetAllPackageVersions(npmPackage string) ([]string, error) {
func (t npm) GetAllPackageVersions(npmPackage string) ([]string, error) {
stdout, stderr, err := execr.RunGetOutput(false, "npm", "view", npmPackage, "versions", "--json")
if err != nil {
return nil, fmt.Errorf("failed getting all versions for %s: %s", npmPackage, stderr)
Expand All @@ -28,3 +28,19 @@ func (npm) GetAllPackageVersions(npmPackage string) ([]string, error) {
}
return jsonData, nil
}

func (t npm) InstallGlobalPackageWithVersion(npmPackage, version string) error {
packageWithVersion := npmPackage
if version != "" && version != "latest" {
packageWithVersion = fmt.Sprintf("%s@%s", npmPackage, version)
}
return t.InstallGlobalPackage(packageWithVersion)
}

func (t npm) InstallGlobalPackage(npmPackage string) error {
_, stderr, err := execr.RunGetOutput(true, "npm", "install", "-g", "--omit=dev", npmPackage)
if err != nil {
return fmt.Errorf("failed installing global npm package %s: %s", npmPackage, stderr)
}
return nil
}
Loading