diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 9514873fd..e95235992 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -1,7 +1,7 @@ # Copyright (c) Microsoft Corporation. All rights reserved. # Licensed under the MIT License. See License.txt in the project root for license information. -ARG VARIANT="16-bullseye" -FROM mcr.microsoft.com/vscode/devcontainers/typescript-node:0-${VARIANT} +ARG VARIANT="20-bookworm" +FROM mcr.microsoft.com/devcontainers/typescript-node:1-${VARIANT} RUN mkdir -p /workspaces && chown node:node /workspaces diff --git a/.devcontainer/devcontainer-lock.json b/.devcontainer/devcontainer-lock.json new file mode 100644 index 000000000..53c5a53c2 --- /dev/null +++ b/.devcontainer/devcontainer-lock.json @@ -0,0 +1,9 @@ +{ + "features": { + "ghcr.io/devcontainers/features/docker-in-docker:2": { + "version": "2.16.1", + "resolved": "ghcr.io/devcontainers/features/docker-in-docker@sha256:ce078b7bf7d9ef3bcb9813b32103795d8d72172446890b64772cbe1dec6baafd", + "integrity": "sha256:ce078b7bf7d9ef3bcb9813b32103795d8d72172446890b64772cbe1dec6baafd" + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1e6fc4fae..974aaedbb 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -7,7 +7,7 @@ "build": { "dockerfile": "Dockerfile", "args": { - "VARIANT": "16-bullseye" + "VARIANT": "20-bookworm" } }, "mounts": [ @@ -27,7 +27,8 @@ "vscode": { "extensions": [ "dbaeumer.vscode-eslint", - "GitHub.vscode-pull-request-github" + "GitHub.vscode-pull-request-github", + "hbenl.vscode-mocha-test-adapter" ] }, "codespaces": { diff --git a/.eslintignore b/.eslintignore deleted file mode 100644 index 4197b94e5..000000000 --- a/.eslintignore +++ /dev/null @@ -1 +0,0 @@ -**/node_modules/** \ No newline at end of file diff --git a/.eslintrc.js b/.eslintrc.js deleted file mode 100644 index 678ded873..000000000 --- a/.eslintrc.js +++ /dev/null @@ -1,63 +0,0 @@ -module.exports = { - 'env': { - 'browser': true, - 'node': true - }, - 'parser': '@typescript-eslint/parser', - 'parserOptions': { - 'sourceType': 'module' - }, - 'plugins': [ - '@typescript-eslint' - ], - 'rules': { - // '@typescript-eslint/class-name-casing': 'warn', https://github.com/typescript-eslint/typescript-eslint/issues/2077 - '@typescript-eslint/member-delimiter-style': [ - 'warn', - { - 'multiline': { - 'delimiter': 'semi', - 'requireLast': true - }, - 'singleline': { - 'delimiter': 'semi', - 'requireLast': false - } - } - ], - '@typescript-eslint/semi': [ - 'warn', - 'always' - ], - 'constructor-super': 'warn', - 'curly': 'warn', - 'eqeqeq': [ - 'warn', - 'always' - ], - 'no-async-promise-executor': 'warn', - 'no-buffer-constructor': 'warn', - 'no-caller': 'warn', - 'no-debugger': 'warn', - 'no-duplicate-case': 'warn', - 'no-duplicate-imports': 'warn', - 'no-eval': 'warn', - 'no-extra-semi': 'warn', - 'no-new-wrappers': 'warn', - 'no-redeclare': 'off', - 'no-sparse-arrays': 'warn', - 'no-throw-literal': 'warn', - 'no-unsafe-finally': 'warn', - 'no-unused-labels': 'warn', - '@typescript-eslint/no-redeclare': 'warn', - 'code-no-unexternalized-strings': 'warn', - 'no-throw-literal': 'warn', - 'no-var': 'warn', - 'code-no-unused-expressions': [ - 'warn', - { - 'allowTernary': true - } - ], - } -}; diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..66f323861 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,31 @@ +version: 2 +updates: + - package-ecosystem: "devcontainers" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + + - package-ecosystem: "npm" + directory: "/" + groups: + all: + patterns: + - "*" + ignore: + - dependency-name: "@types/chai" + update-types: ["version-update:semver-major"] # chai 4 to avoid esm + - dependency-name: "@types/node" + update-types: ["version-update:semver-major"] # Keep Node 20 compatibility + - dependency-name: "chai" + update-types: ["version-update:semver-major"] # chai 4 to avoid esm + - dependency-name: "yargs" + update-types: ["version-update:semver-major"] # yargs 17 for esbuild CJS bundling + - dependency-name: "node-pty" + update-types: ["version-update:semver-minor"] # node-pty 1.1 has broken spawn-helper permissions (microsoft/node-pty#866) + schedule: + interval: "weekly" diff --git a/.github/workflows/build-chat.yml b/.github/workflows/build-chat.yml deleted file mode 100644 index 8d7f95ca8..000000000 --- a/.github/workflows/build-chat.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Build Chat - -on: - workflow_run: - workflows: - - '**' - types: - - completed - branches: - - '**' - -jobs: - main: - runs-on: ubuntu-latest - steps: - - name: Checkout Actions - uses: actions/checkout@v2 - with: - repository: "microsoft/vscode-github-triage-actions" - path: ./actions - - name: Install Actions - run: npm install --production --prefix ./actions - - name: Install Additional Dependencies - # Pulls in a bunch of other packages that arent needed for the rest of the actions - run: npm install @azure/storage-blob@12.1.1 - - name: Build Chat - uses: ./actions/build-chat - with: - token: ${{ secrets.GITHUB_TOKEN }} - slack_token: ${{ secrets.SLACK_TOKEN }} - storage_connection_string: ${{ secrets.BUILD_CHAT_STORAGE_CONNECTION_STRING }} - workflow_run_url: ${{ github.event.workflow_run.url }} - notify_authors: true - log_channel: bot-log diff --git a/.github/workflows/dev-containers.yml b/.github/workflows/dev-containers.yml index a1b5a81af..930d77d31 100644 --- a/.github/workflows/dev-containers.yml +++ b/.github/workflows/dev-containers.yml @@ -8,15 +8,20 @@ on: branches: - '**' +permissions: + contents: read + packages: read + jobs: cli: name: CLI runs-on: ubuntu-latest + timeout-minutes: 10 steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v1 + - uses: actions/checkout@v6 + - uses: actions/setup-node@v5 with: - node-version: '16.x' + node-version: '20.x' registry-url: 'https://npm.pkg.github.com' scope: '@microsoft' - name: Install Dependencies @@ -33,13 +38,14 @@ jobs: echo "TGZ=devcontainers-cli-${VERSION}.tgz" | tee -a $GITHUB_ENV echo "TGZ_UPLOAD=devcontainers-cli-${VERSION}-${GITHUB_SHA:0:8}.tgz" | tee -a $GITHUB_ENV - name: Store TGZ - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v7 with: name: ${{ env.TGZ_UPLOAD }} path: ${{ env.TGZ }} tests-matrix: name: Tests Matrix runs-on: ubuntu-latest + timeout-minutes: 30 strategy: fail-fast: false matrix: @@ -52,24 +58,47 @@ jobs: "src/test/cli.exec.buildKit.2.test.ts", "src/test/cli.exec.nonBuildKit.1.test.ts", "src/test/cli.exec.nonBuildKit.2.test.ts", + "src/test/cli.podman.test.ts", "src/test/cli.test.ts", "src/test/cli.up.test.ts", "src/test/imageMetadata.test.ts", "src/test/container-features/containerFeaturesOCIPush.test.ts", # Run all except the above: - "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", + "--exclude src/test/container-features/containerFeaturesOrder.test.ts --exclude src/test/container-features/registryCompatibilityOCI.test.ts --exclude src/test/container-features/containerFeaturesOCIPush.test.ts --exclude src/test/container-features/e2e.test.ts --exclude src/test/container-features/featuresCLICommands.test.ts --exclude src/test/cli.build.test.ts --exclude src/test/cli.exec.buildKit.1.test.ts --exclude src/test/cli.exec.buildKit.2.test.ts --exclude src/test/cli.exec.nonBuildKit.1.test.ts --exclude src/test/cli.exec.nonBuildKit.2.test.ts --exclude src/test/cli.podman.test.ts --exclude src/test/cli.test.ts --exclude src/test/cli.up.test.ts --exclude src/test/imageMetadata.test.ts 'src/test/**/*.test.ts'", ] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v5 with: - node-version: '16.x' + node-version: '20.x' registry-url: 'https://npm.pkg.github.com' scope: '@microsoft' + - name: Disable containerd image store + run: | + # Workaround for https://github.com/moby/moby/issues/52050 + DAEMON_JSON="/etc/docker/daemon.json" + if [ -f "$DAEMON_JSON" ]; then + sudo jq '. + {"features": {"containerd-snapshotter": false}}' "$DAEMON_JSON" \ + | sudo tee "${DAEMON_JSON}.tmp" > /dev/null + sudo mv "${DAEMON_JSON}.tmp" "$DAEMON_JSON" + else + echo '{"features": {"containerd-snapshotter": false}}' \ + | sudo tee "$DAEMON_JSON" > /dev/null + fi + cat "$DAEMON_JSON" + sudo systemctl restart docker + - name: Tools Info + run: | + docker info + docker buildx version + podman info + podman buildx version - name: Install Dependencies - run: yarn install --frozen-lockfile + run: | + yarn install --frozen-lockfile + docker run --privileged --rm tonistiigi/binfmt --install all - name: Type-Check run: yarn type-check - name: Package @@ -84,13 +113,14 @@ jobs: # TODO: This should be expanded to run on different platforms # Most notably to test platform-specific credential helper behavior runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v5 with: - node-version: '16.x' + node-version: '20.x' registry-url: 'https://npm.pkg.github.com' scope: '@microsoft' - name: Install Dependencies @@ -111,9 +141,22 @@ jobs: FEATURES_TEST__AZURE_REGISTRY_SCOPED_CREDENTIAL: ${{ secrets.FEATURES_TEST__AZURE_REGISTRY_SCOPED_CREDENTIAL }} + install-script: + name: Install Script + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + steps: + - uses: actions/checkout@v6 + - name: Run install.sh tests + run: sh scripts/install.test.sh + tests: name: Tests - needs: [tests-matrix, features-registry-compatibility] + needs: [tests-matrix, features-registry-compatibility, install-script] runs-on: ubuntu-latest steps: - name: Done diff --git a/.github/workflows/publish-dev-containers.yml b/.github/workflows/publish-dev-containers.yml index 118641433..7dae6ca8d 100644 --- a/.github/workflows/publish-dev-containers.yml +++ b/.github/workflows/publish-dev-containers.yml @@ -5,16 +5,20 @@ on: tags: - 'v*' +permissions: + contents: read + actions: read + jobs: main: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v5 with: - node-version: '14.x' + node-version: '20.x' registry-url: 'https://registry.npmjs.org' scope: '@devcontainers' - name: Verify Versions @@ -33,7 +37,7 @@ jobs: echo "TGZ=devcontainers-cli-${VERSION}.tgz" | tee -a $GITHUB_ENV echo "TGZ_UPLOAD=devcontainers-cli-${VERSION}-${GITHUB_SHA:0:8}.tgz" | tee -a $GITHUB_ENV - name: Download TGZ - uses: dawidd6/action-download-artifact@6f8f427fb41886a66b82ea11a5a15d1454c79415 + uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20 with: workflow: dev-containers.yml workflow_conclusion: success diff --git a/.github/workflows/test-docker-v20.yml b/.github/workflows/test-docker-v20.yml new file mode 100644 index 000000000..69f8d3569 --- /dev/null +++ b/.github/workflows/test-docker-v20.yml @@ -0,0 +1,64 @@ +name: Docker v20 Tests for dockerfile frontend test + +on: + push: + branches: ['main', 'directive-syntax-further-changes'] + pull_request: + branches: ['main'] + +permissions: + contents: read + +jobs: + test-docker-v20: + name: Docker v20.10 Compatibility + runs-on: ubuntu-22.04 + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v5 + with: + node-version: '20.x' + + - name: Install Docker v20.10 + run: | + sudo apt-get remove -y docker-ce docker-ce-cli containerd.io || true + sudo apt-get update + sudo apt-get install -y \ + ca-certificates \ + curl \ + gnupg \ + lsb-release + sudo mkdir -p /etc/apt/keyrings + curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg + echo \ + "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \ + $(lsb_release -cs) stable" | sudo tee /etc/apt/sources.list.d/docker.list > /dev/null + sudo apt-get update + # Pin buildx < 0.31.0 to avoid API version 1.52 incompatibility with Docker 20.10 (max API 1.41) + # See https://github.com/docker/buildx/issues/3654 + BUILDX_VERSION=$(apt-cache madison docker-buildx-plugin | awk '{print $3}' | grep -v '^0\.3[1-9]\.' | head -1) + echo "Installing docker-buildx-plugin=$BUILDX_VERSION" + # Pin compose plugin to v2.x to avoid API incompatibility with Docker 20.10 + COMPOSE_VERSION=$(apt-cache madison docker-compose-plugin | awk '{print $3}' | grep '^2\.' | head -1) + echo "Installing docker-compose-plugin=$COMPOSE_VERSION" + sudo apt-get install -y docker-ce=5:20.10.* docker-ce-cli=5:20.10.* containerd.io docker-buildx-plugin="$BUILDX_VERSION" docker-compose-plugin="$COMPOSE_VERSION" + sudo systemctl restart docker + + - name: Verify Docker version, Install and Test + run: | + # Verify + docker version + DOCKER_VERSION=$(docker version --format '{{.Server.Version}}') + if [[ ! "$DOCKER_VERSION" =~ ^20\.10\. ]]; then + echo "ERROR: Expected Docker v20.10.x but got $DOCKER_VERSION" + exit 1 + fi + yarn install --frozen-lockfile + yarn type-check + yarn package + yarn test-matrix --forbid-only src/test/cli.up.test.ts + env: + CI: true \ No newline at end of file diff --git a/.github/workflows/test-docker-v29.yml b/.github/workflows/test-docker-v29.yml new file mode 100644 index 000000000..de475f246 --- /dev/null +++ b/.github/workflows/test-docker-v29.yml @@ -0,0 +1,47 @@ +name: Docker v29 Tests + +on: + push: + branches: ['main', 'docker-v29-issue-old'] + pull_request: + branches: ['main'] + +permissions: + contents: read + +jobs: + test-docker-v29: + name: Docker v29.0.0 Compatibility + runs-on: ubuntu-latest + timeout-minutes: 20 + + steps: + - uses: actions/checkout@v6 + + - uses: actions/setup-node@v5 + with: + node-version: '20.x' + + - name: Install Docker v29.0.0 + run: | + sudo apt-get remove -y docker-ce docker-ce-cli containerd.io || true + curl -fsSL https://get.docker.com -o get-docker.sh + sudo VERSION=29.0.0 sh get-docker.sh + sudo systemctl restart docker + + - name: Verify Docker version, Install and Test + run: | + # Verify + docker version + DOCKER_VERSION=$(docker version --format '{{.Server.Version}}') + if [[ ! "$DOCKER_VERSION" =~ ^29\. ]]; then + echo "ERROR: Expected Docker v29.x but got $DOCKER_VERSION" + exit 1 + fi + yarn install --frozen-lockfile + yarn type-check + yarn package + yarn test-matrix --forbid-only src/test/cli.up.test.ts + env: + CI: true + diff --git a/.github/workflows/test-plan-item-validator.yml b/.github/workflows/test-plan-item-validator.yml index 69370c2a8..9c9739b6f 100644 --- a/.github/workflows/test-plan-item-validator.yml +++ b/.github/workflows/test-plan-item-validator.yml @@ -3,13 +3,17 @@ on: issues: types: [edited] +permissions: + contents: read + issues: write + jobs: main: runs-on: ubuntu-latest steps: - name: Checkout Actions if: contains(github.event.issue.labels.*.name, 'testplan-item') || contains(github.event.issue.labels.*.name, 'invalid-testplan-item') - uses: actions/checkout@v2 + uses: actions/checkout@v6 with: repository: 'microsoft/vscode-github-triage-actions' ref: stable diff --git a/.github/workflows/test-windows.yml b/.github/workflows/test-windows.yml index 6add12433..5ac9743d1 100644 --- a/.github/workflows/test-windows.yml +++ b/.github/workflows/test-windows.yml @@ -11,10 +11,15 @@ on: pull_request: branches: - '**' + +permissions: + contents: read + jobs: tests-matrix: name: Tests Matrix (Windows) runs-on: windows-latest + timeout-minutes: 15 strategy: fail-fast: false matrix: @@ -46,11 +51,11 @@ jobs: ] steps: - name: Checkout - uses: actions/checkout@v2 + uses: actions/checkout@v6 - name: Setup Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v5 with: - node-version: '16.x' + node-version: '20.x' registry-url: 'https://npm.pkg.github.com' scope: '@microsoft' - name: Install Dependencies diff --git a/.gitignore b/.gitignore index 7b270dea1..aac8d0b78 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,5 @@ output *.testMarker src/test/container-features/configs/temp_lifecycle-hooks-alternative-order test-secrets-temp.json +src/test/container-*/**/src/**/README.md +!src/test/container-features/assets/*.tgz diff --git a/.mocharc.yml b/.mocharc.yml new file mode 100644 index 000000000..42bdad050 --- /dev/null +++ b/.mocharc.yml @@ -0,0 +1 @@ +timeout: 360000 # 6 minutes global safety net; individual suites override via this.timeout() diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 8b4c1e5ef..baea00e62 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,5 +1,6 @@ { "recommendations": [ "dbaeumer.vscode-eslint", + "hbenl.vscode-mocha-test-adapter" ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index a5ac98e49..cd5223cb5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,13 +3,13 @@ "search.exclude": { "dist": true }, - "typescript.tsc.autoDetect": "off", + "js/ts.tsc.autoDetect": "off", "eslint.options": { "rulePaths": [ "./build/eslint" ] }, - "mochaExplorer.files": "test/**/*.test.ts", + "mochaExplorer.files": "src/test/**/*.test.ts", "mochaExplorer.require": "ts-node/register", "mochaExplorer.env": { "TS_NODE_PROJECT": "src/test/tsconfig.json" @@ -17,12 +17,22 @@ "files.associations": { "devcontainer-features.json": "jsonc" }, - "typescript.tsdk": "node_modules/typescript/lib", + "js/ts.tsdk.path": "node_modules/typescript/lib", "git.branchProtection": [ "main", "release/*" ], "editor.formatOnSave": true, "editor.formatOnSaveMode": "modifications", - "editor.insertSpaces": false + "editor.insertSpaces": false, + "[json]": { + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.defaultFormatter": "vscode.json-language-features" + }, + "[jsonc]": { + "editor.insertSpaces": false, + "editor.tabSize": 4, + "editor.defaultFormatter": "vscode.json-language-features" + } } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index d5cd0523b..66792dd19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,285 @@ Notable changes. +## May 2026 + +### [0.87.0] +- Graduate lockfile from experimental to stable: lockfiles are now generated by default on `build` and `up`. (https://github.com/devcontainers/cli/issues/1195) + - New `--no-lockfile` flag to opt out of lockfile generation. + - New `--frozen-lockfile` flag to ensure the lockfile exists and remains unchanged. + - `--experimental-lockfile` and `--experimental-frozen-lockfile` are deprecated (still accepted with a warning). + +### [0.86.1] +- Do not write features supplied via `--additional-features` to the lockfile. (https://github.com/microsoft/vscode-remote-release/issues/11616) + +## April 2026 + +### [0.86.0] +- Bump basic-ftp from 5.2.0 to 5.2.2. (https://github.com/devcontainers/cli/pull/1201) +- Always write devcontainer.metadata label as JSON array. (https://github.com/devcontainers/cli/pull/1199) +- Normalize drive letter to lowercase on Windows to match VSCode. (https://github.com/devcontainers/cli/pull/1183) + +## March 2026 + +### [0.85.0] +- Inline buildx global build and target platform envvars when resolving base image and user. (https://github.com/devcontainers/cli/pull/1169) + +### [0.84.1] +- Bump tar from 7.5.10 to 7.5.11 due to [CVE-2026-31802](https://github.com/advisories/GHSA-9ppj-qmqm-q256). (https://github.com/devcontainers/cli/pull/1174) + +### [0.84.0] +- Dependencies update. (https://github.com/devcontainers/cli/pull/1167) + +## February 2026 + +### [0.83.3] +- Bump tar from 7.5.7 to 7.5.8. (https://github.com/devcontainers/cli/pull/1160) + +### [0.83.2] +- Improved logging for image inspect errors. (https://github.com/devcontainers/cli/pull/1152) + +### [0.83.1] +- Bump tar from 7.5.6 to 7.5.7. (https://github.com/devcontainers/cli/pull/1140) + +### [0.83.0] +- Add install script. (https://github.com/devcontainers/cli/pull/1142) +- Remove request body limit. (https://github.com/devcontainers/cli/pull/1141) +- Add BUILDKIT_INLINE_CACHE for container Feature path. (https://github.com/devcontainers/cli/pull/1135) + +## January 2026 + +### [0.82.0] +- devcontainer commands now use current directory as default workspace folder when not specified (https://github.com/devcontainers/cli/pull/1104) + +### [0.81.1] +- Update js-yaml and glob dependencies. (https://github.com/devcontainers/cli/pull/1128) + +### [0.81.0] +- Add option to mount a worktree's common folder. (https://github.com/devcontainers/cli/pull/1127) + +## December 2025 + +### [0.80.3] +- Fix: Skip download and injection of `dockerfile:1.4` syntax for Docker Engine versions [>=23.0.0](https://docs.docker.com/engine/release-notes/23.0/#2300)) - `dockerfile:1.4` or a subsequent version is already used by the docker engine package. (https://github.com/devcontainers/cli/pull/1113) + +## November 2025 + +### [0.80.2] +- Fix: Docker container event 'start' dropped deprecated fields in Docker v29.0.0 (https://github.com/devcontainers/cli/pull/1103) + +## September 2025 + +### [0.80.1] +- Fix: debian:latest dropped adduser / addgroup (https://github.com/devcontainers/cli/pull/1060) + +## July 2025 + +### [0.80.0] +- Podman: Use label=disable instead of z flag (https://github.com/microsoft/vscode-remote-release/issues/10585) + +## June 2025 + +### [0.79.0] +- Redirect devcontainers-contrib to devcontainers-extra (https://github.com/microsoft/vscode-remote-release/issues/11046) + +### [0.78.0] +- Fix: Handle missing features (https://github.com/devcontainers/cli/pull/1040) + +## May 2025 + +### [0.77.0] +- Fix: --uidmap/--gidmap conflict with --userns (https://github.com/microsoft/vscode-remote-release/10954) +- Fix: Omit --userns=keep-id for root (https://github.com/devcontainers/cli/pull/1004) + +## April 2025 + +### [0.76.0] +- Fix: Add Podman options (https://github.com/microsoft/vscode-remote-release/issues/10798) +- Fix: Restore accidental robustness towards numbers (https://github.com/microsoft/vscode-remote-release/issues/10691) + +## March 2025 + +### [0.75.0] +- Fix: add check for missing FROM instructions in Dockerfile parsing (https://github.com/devcontainers/cli/pull/950) +- Update dependencies (https://github.com/devcontainers/cli/pull/954) + +## February 2025 + +### [0.74.0] +- Ignore non-writeable HOME (https://github.com/microsoft/vscode-remote-release/issues/10707) + +## January 2025 + +### [0.73.0] +- Fix: TypeError: Cannot read properties of undefined (reading 'fsPath') (https://github.com/devcontainers/cli/issues/895) +- Fix: Log output of failing lifecycle scripts (https://github.com/devcontainers/cli/issues/845) +- Fix: Escaping of metadata in Docker Compose file (https://github.com/devcontainers/cli/issues/904) +- Fix: Re-authenticate against OCI registry after 403 error (https://github.com/devcontainers/cli/pull/945) + +## November 2024 + +### [0.72.0] +- Fix: change increment syntax in test library script (https://github.com/devcontainers/cli/pull/896) +- Increase timeout to 6 seconds (7 attempts) (https://github.com/microsoft/vscode-remote-release/issues/6509) +- Remove unnecessary log (https://github.com/devcontainers/cli/pull/925) + +## September 2024 + +### [0.71.0] +- Exit with non-zero code on unexpected errors (https://github.com/microsoft/vscode-remote-release/issues/10217) +- Add option for GPU availability (https://github.com/microsoft/vscode-remote-release/issues/9385) + +### [0.70.0] +- Add more leniency towards registries that malform WWW-Authenticate (https://github.com/devcontainers/cli/pull/884) +- Handle concurrent removal (https://github.com/microsoft/vscode-remote-release/issues/6509) + +## August 2024 + +### [0.69.0] +- Enhance Template metadata (https://github.com/devcontainers/cli/pull/875) + - Caches additional Template metadata (such as `files`) onto the manifest + - Resolves full file paths for `optionalPaths` directories that only contain one file (for better usability in upstream tools) + - Fixes bugs + +### [0.68.0] +- Supporting changes for [Template `optionalPaths` specification](https://github.com/devcontainers/spec/blob/main/docs/specs/devcontainer-templates.md#the-optionalpaths-property) (https://github.com/microsoft/vscode-remote-release/issues/10095) + - Publish metadata on Template OCI manifests (https://github.com/devcontainers/cli/pull/865) + - Add `--omit-paths` option to `templates apply` command (https://github.com/devcontainers/cli/pull/868) + - Add `templates metadata` command (https://github.com/devcontainers/cli/pull/866) + +### [0.67.0] +- Fix containerEnv substitution. (https://github.com/microsoft/vscode-remote-release/issues/10033) + +## July 2024 + +### [0.66.0] +- Wait for result to be written to stdout. (https://github.com/microsoft/vscode-remote-release/issues/10029) + +## June 2024 + +### [0.65.0] +- Fix confusing error message with local feature. (https://github.com/devcontainers/cli/issues/834) +- Add `--label` parameter to `devcontainer build` command. (https://github.com/devcontainers/cli/issues/837) +- Prefer Docker Compose v2 over v1. (https://github.com/devcontainers/cli/issues/826) + +### [0.64.0] +- Fix project name with env variable. (https://github.com/devcontainers/cli/issues/839) + +### [0.63.0] +- Surface additional information in `devcontainer up`. (https://github.com/devcontainers/cli/pull/836) +- Changes the config layer of the Feature manifest to a empty descriptor (https://github.com/devcontainers/cli/pull/815) + +## May 2024 + +### [0.62.0] +- Fix support for project name attribute. (https://github.com/devcontainers/cli/issues/831) + +### [0.61.0] +- Use --depth 1 to make dotfiles install process faster (https://github.com/devcontainers/cli/pull/830) +- Enable --cache-to and --cache-from in devcontainer up (https://github.com/devcontainers/cli/pull/813) +- Omit generated image name when `--image-name` is given (https://github.com/devcontainers/cli/pull/812) + +### [0.60.0] +- Support project name attribute. (https://github.com/microsoft/vscode-remote-release/issues/512) + +## April 2024 + +### [0.59.1] +- Check if image name has registry host. (https://github.com/microsoft/vscode-remote-release/issues/9748) + +### [0.59.0] +- Propagate --cache-from to buildx build. (https://github.com/devcontainers/cli/pull/638) +- Disable cache on feature build when `--build-no-cache` is passed. (https://github.com/devcontainers/cli/pull/790) +- Qualify local image for Podman. (https://github.com/microsoft/vscode-remote-release/issues/9748) +- Stop races docker-compose.devcontainer.containerFeatures file. (https://github.com/devcontainers/cli/issues/801) + +## March 2024 + +### [0.58.0] +- Allow empty value for remote env. (https://github.com/devcontainers/ci/issues/231) +- Add generate-docs subcommand for templates and features. (https://github.com/devcontainers/cli/pull/759) +- Only use SELinux label for Linux hosts. (https://github.com/devcontainers/cli/issues/776) + +### [0.57.0] +- Fix crash updating UID/GID when the image's platform is different from the native CPU arch (https://github.com/devcontainers/cli/pull/746) +- Add tags with build command (https://github.com/devcontainers/ci/issues/271) + +## February 2024 + +### [0.56.2] +- Remove dependency on ip package (https://github.com/devcontainers/cli/pull/750) + +## January 2024 + +### [0.56.1] +- Add hidden `--omit-syntax-directive` flag (https://github.com/devcontainers/cli/pull/728) to disable writing `#syntax` directives in intermediate Dockerfiles, even if provided by the user. This is an advanced flag meant to mitigate issues involving user namespace remapping. This flag will be removed in a future release. See https://github.com/moby/buildkit/issues/4556 for more information. +- Update dependencies (https://github.com/devcontainers/cli/pull/722) + +### [0.56.0] +- Support additional Docker build options (https://github.com/devcontainers/cli/issues/85) + +## December 2023 + +### [0.55.0] +- Adopt additional_contexts in compose (https://github.com/microsoft/vscode-remote-release/issues/7305) +- Log `docker start` output (https://github.com/microsoft/vscode-remote-release/issues/5887) + +### [0.54.2] +- Update string in `isBuildKitImagePolicyError` (https://github.com/devcontainers/cli/pull/694) +- Mount build context as shared with buildah (https://github.com/devcontainers/cli/pull/548) + +## November 2023 + +### [0.54.1] + +- Fix authentication against Artifactory (https://github.com/devcontainers/cli/pull/692) + +### [0.54.0] + +- Force deterministic order of `outdated` command (https://github.com/devcontainers/cli/pull/681) +- Remove vscode-dev-containers dependency (https://github.com/devcontainers/cli/pull/682) +- Remove additional unused code (https://github.com/devcontainers/cli/commit/2d24543380dfc4d54e76b582536b52226af133c8) +- Update dependencies including node-pty (https://github.com/devcontainers/cli/pull/685) +- Update Third-party notices (https://github.com/devcontainers/cli/pull/686) +- Edit a Feature pinned version via upgrade command behind hidden flag (https://github.com/devcontainers/cli/pull/684) + +### [0.53.0] + +- add `--dry-run` to `upgrade` command (https://github.com/devcontainers/cli/pull/679) +- Fix version sorting and report major version in `outdated` command (https://github.com/devcontainers/cli/pull/670) + - NOTE: This changes the signature of the `features info` command and the output of publishing Features/Templates. The key `publishedVersions` has been renamed to `publishedTags` to better mirror the key's values. +- Docker compose: Updates create error description to include cause for docker auth plugin errors (https://github.com/devcontainers/cli/pull/660) + +## October 2023 + +### [0.52.1] + +- Updates create error description to include cause for docker auth plugin errors (https://github.com/devcontainers/cli/pull/656) + +### [0.52.0] + +- Add `upgrade` command to generate an updated lockfile (https://github.com/devcontainers/cli/pull/645) + +## September 2023 + +### [0.51.3] + +- Update UID only if GID is in use (https://github.com/microsoft/vscode-remote-release/issues/7284) +- Empty lockfile in workspaceFolder will initialize lockfile (https://github.com/devcontainers/cli/pull/637) + +## August 2023 + +### [0.51.2] + +- Surface buildkit policy errors (https://github.com/devcontainers/cli/pull/627) + +### [0.51.1] +- Handle missing entry in /etc/passwd gracefully (https://github.com/microsoft/vscode-remote-release/issues/8875) + +### [0.51.0] +- Add `--cache-to` option to `devcontainer build` command (https://github.com/devcontainers/cli/pull/570) +- Fix: Fallback when getent is not available (https://github.com/microsoft/vscode-remote-release/issues/8811) + ## July 2023 ### [0.50.2] diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b264f4c67..e3790d260 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -29,8 +29,6 @@ The specification repo uses the following [labels](https://github.com/microsoft/ - Create a PR: - Updating the package version in the `package.json`. - - Updating the `vscode-dev-containers` version in the `package.json`'s dependencies (if there is an update). - - Run `yarn` to update `yarn.lock`. - List notable changes in the `CHANGELOG.md`. - Update ThirdPartyNotices.txt with any new dependencies. - After the PR is merged to `main` wait for the CI workflow to succeed (this builds the artifact that will be published). (TBD: Let the `publish-dev-containers` workflow wait for the CI workflow.) diff --git a/README.md b/README.md index d59523f83..39b043ba0 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,53 @@ This CLI is in active development. Current status: - [x] `devcontainer run-user-commands` - Runs lifecycle commands like `postCreateCommand` - [x] `devcontainer read-configuration` - Outputs current configuration for workspace - [x] `devcontainer exec` - Executes a command in a container with `userEnvProbe`, `remoteUser`, `remoteEnv`, and other properties applied +- [x] `devcontainer outdated` - Show outdated lockfile features +- [x] `devcontainer upgrade` - Upgrade lockfile features - [x] `devcontainer features <...>` - Tools to assist in authoring and testing [Dev Container Features](https://containers.dev/implementors/features/) - [x] `devcontainer templates <...>` - Tools to assist in authoring and testing [Dev Container Templates](https://containers.dev/implementors/templates/) - [ ] `devcontainer stop` - Stops containers - [ ] `devcontainer down` - Stops and deletes containers +Lockfiles (`.devcontainer-lock.json`) are generated by default when running `build` or `up` to pin feature versions for reproducible builds. Use `--no-lockfile` to opt out, or `--frozen-lockfile` to enforce an existing lockfile. + ## Try it out -We'd love for you to try out the dev container CLI and let us know what you think. You can quickly try it out in just a few simple steps, either by installing its npm package or building the CLI repo from sources (see "[Build from sources](#build-from-sources)"). +We'd love for you to try out the dev container CLI and let us know what you think. You can quickly try it out in just a few simple steps, either by using the install script, installing its npm package, or building the CLI repo from sources (see "[Build from sources](#build-from-sources)"). -To install the npm package you will need Python and C/C++ installed to build one of the dependencies (see, e.g., [here](https://github.com/microsoft/vscode/wiki/How-to-Contribute) for instructions). +### Install script + +You can install the CLI with a standalone script that downloads a bundled Node.js runtime, so no pre-installed Node.js is required. It works on Linux and macOS (x64 and arm64): + +```bash +curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh +``` + +Then add the install location to your PATH: + +```bash +export PATH="$HOME/.devcontainers/bin:$PATH" +``` + +You can also specify a version, a custom install directory, or update/uninstall an existing installation: + +```bash +# Install a specific version +sh install.sh --version 0.82.0 + +# Install to a custom directory +sh install.sh --prefix ~/.local/devcontainers + +# Update to latest +sh install.sh --update + +# Uninstall +sh install.sh --uninstall +``` ### npm install +To install the npm package you will need Python and C/C++ installed to build one of the dependencies (see, e.g., [here](https://github.com/microsoft/vscode/wiki/How-to-Contribute) for instructions). + ```bash npm install -g @devcontainers/cli ``` diff --git a/ThirdPartyNotices.txt b/ThirdPartyNotices.txt index ef1887e6b..33a3cd0dd 100644 --- a/ThirdPartyNotices.txt +++ b/ThirdPartyNotices.txt @@ -52,11 +52,11 @@ Copyright (c) 2013 Irakli Gozalishvili Copyright (c) 2012 Arpad Borsos Copyright (c) 2012-2014 Yusuke Suzuki Copyright (c) 2011-2012 Ariya Hidayat -Copyright (c) 2012 Yusuke Suzuki (http://github.com/Constellation) Copyright (c) 2012 Joost-Wim Boekesteijn Copyright (c) 2012 Robert Gust-Bardon -Copyright (c) 2012 Yusuke Suzuki (twitter Constellation) and other contributors. +Copyright (c) 2012 Yusuke Suzuki (twitter Constellation) and other contributors Copyright (c) 2012-2013 Michael Ficarra +Copyright (c) 2012 Yusuke Suzuki (http://github.com/Constellation) (twitter Constellation (http://twitter.com/Constellation)) and other contributors Copyright (C) 2012 Yusuke Suzuki (twitter: @Constellation) and other contributors. @@ -222,6 +222,41 @@ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +--------------------------------------------------------- + +--------------------------------------------------------- + +sprintf-js 1.1.3 - BSD-3-Clause +https://github.com/alexei/sprintf.js#readme + +Copyright (c) 2007-present, Alexandru Marasteanu + +Copyright (c) 2007-present, Alexandru Mărășteanu +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +* Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. +* Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. +* Neither the name of this software nor the names of its contributors may be + used to endorse or promote products derived from this software without + specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + --------------------------------------------------------- --------------------------------------------------------- @@ -347,34 +382,7 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------- -inherits 2.0.4 - ISC -https://github.com/isaacs/inherits#readme - -Copyright (c) Isaac Z. Schlueter - -The ISC License - -Copyright (c) Isaac Z. Schlueter - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH -REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND -FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, -INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM -LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR -OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR -PERFORMANCE OF THIS SOFTWARE. - - - ---------------------------------------------------------- - ---------------------------------------------------------- - -lru-cache 5.1.1 - ISC +lru-cache 6.0.0 - ISC https://github.com/isaacs/node-lru-cache#readme Copyright (c) Isaac Z. Schlueter and Contributors @@ -400,14 +408,15 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------- -lru-cache 6.0.0 - ISC +lru-cache 7.18.3 - ISC https://github.com/isaacs/node-lru-cache#readme -Copyright (c) Isaac Z. Schlueter and Contributors +Copyright (c) Microsoft Corporation +Copyright (c) 2010-2023 Isaac Z. Schlueter and Contributors The ISC License -Copyright (c) Isaac Z. Schlueter and Contributors +Copyright (c) 2010-2023 Isaac Z. Schlueter and Contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above @@ -478,15 +487,14 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------- -semver 7.3.8 - ISC -https://github.com/npm/node-semver#readme +minipass 5.0.0 - ISC +https://github.com/isaacs/minipass#readme -Copyright Isaac Z. Schlueter -Copyright (c) Isaac Z. Schlueter and Contributors +Copyright (c) 2017-2023 npm, Inc., Isaac Z. Schlueter, and Contributors The ISC License -Copyright (c) Isaac Z. Schlueter and Contributors +Copyright (c) 2017-2023 npm, Inc., Isaac Z. Schlueter, and Contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above @@ -505,12 +513,15 @@ IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------- -setprototypeof 1.2.0 - ISC -https://github.com/wesleytodd/setprototypeof +semver 7.6.0 - ISC +https://github.com/npm/node-semver#readme -Copyright (c) 2015, Wes Todd +Copyright Isaac Z. Schlueter +Copyright (c) Isaac Z. Schlueter and Contributors -Copyright (c) 2015, Wes Todd +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above @@ -518,19 +529,19 @@ copyright notice and this permission notice appear in all copies. THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY -SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION -OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN -CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------- --------------------------------------------------------- -tar 6.1.12 - ISC -https://github.com/npm/node-tar#readme +tar 6.2.1 - ISC +https://github.com/isaacs/node-tar#readme Copyright (c) Isaac Z. Schlueter and Contributors @@ -575,32 +586,6 @@ TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ---------------------------------------------------------- - ---------------------------------------------------------- - -yallist 3.1.1 - ISC -https://github.com/isaacs/yallist#readme - -Copyright (c) Isaac Z. Schlueter and Contributors - -The ISC License - -Copyright (c) Isaac Z. Schlueter and Contributors - -Permission to use, copy, modify, and/or distribute this software for any -purpose with or without fee is hereby granted, provided that the above -copyright notice and this permission notice appear in all copies. - -THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES -WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF -MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR -ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES -WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN -ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR -IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. - - --------------------------------------------------------- --------------------------------------------------------- @@ -656,32 +641,14 @@ ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. --------------------------------------------------------- -@tootallnate/once 1.1.2 - MIT -https://github.com/TooTallNate/once#readme +@tootallnate/quickjs-emscripten 0.23.0 - MIT +https://github.com/justjake/quickjs-emscripten#readme +copyright (c) 2019 Jake Teton-Landis MIT License -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -acorn 8.8.1 - MIT -https://github.com/acornjs/acorn - -Copyright (c) 2012-2022 by various contributors - -MIT License - -Copyright (C) 2012-2022 by various contributors (see AUTHORS) +quickjs-emscripten copyright (c) 2019 Jake Teton-Landis Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -690,56 +657,43 @@ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. --------------------------------------------------------- --------------------------------------------------------- -acorn-walk 8.2.0 - MIT -https://github.com/acornjs/acorn +agent-base 7.0.2 - MIT +https://github.com/TooTallNate/proxy-agents#readme -Copyright (c) 2012-2020 by various contributors +Copyright (c) 2013 Nathan Rajlich MIT License -Copyright (C) 2012-2020 by various contributors (see AUTHORS) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Copyright (c) -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- --------------------------------------------------------- -agent-base 6.0.2 - MIT -https://github.com/TooTallNate/node-agent-base#readme +agent-base 7.1.0 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2013 Nathan Rajlich @@ -856,6 +810,35 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +--------------------------------------------------------- + +--------------------------------------------------------- + +basic-ftp 5.0.3 - MIT +https://github.com/patrickjuchli/basic-ftp#readme + +Copyright (c) 2019 Patrick Juchli + +Copyright (c) 2019 Patrick Juchli + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + --------------------------------------------------------- --------------------------------------------------------- @@ -892,37 +875,20 @@ SOFTWARE. --------------------------------------------------------- -bytes 3.1.2 - MIT -https://github.com/visionmedia/bytes.js#readme +chalk 5.3.0 - MIT +https://github.com/chalk/chalk#readme -Copyright (c) 2015 Jed Watson -Copyright (c) 2012-2014 TJ Holowaychuk -Copyright (c) 2015 Jed Watson -Copyright (c) 2012-2014 TJ Holowaychuk +Copyright (c) Sindre Sorhus (https://sindresorhus.com) -(The MIT License) +MIT License -Copyright (c) 2012-2014 TJ Holowaychuk -Copyright (c) 2015 Jed Watson +Copyright (c) Sindre Sorhus (https://sindresorhus.com) -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- @@ -1008,38 +974,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -core-util-is 1.0.3 - MIT -https://github.com/isaacs/core-util-is#readme - -Copyright Joyent, Inc. and other Node contributors - -Copyright Node.js contributors. All rights reserved. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -data-uri-to-buffer 3.0.1 - MIT -https://github.com/TooTallNate/node-data-uri-to-buffer +data-uri-to-buffer 5.0.1 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2014 Nathan Rajlich @@ -1124,8 +1060,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -degenerator 3.0.2 - MIT -https://github.com/TooTallNate/node-degenerator#readme +degenerator 5.0.0 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2013 Nathan Rajlich @@ -1139,40 +1075,6 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---------------------------------------------------------- - ---------------------------------------------------------- - -depd 2.0.0 - MIT -https://github.com/dougwilson/nodejs-depd#readme - -Copyright (c) 2015 Douglas Christopher Wilson -Copyright (c) 2014-2018 Douglas Christopher Wilson - -(The MIT License) - -Copyright (c) 2014-2018 Douglas Christopher Wilson - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------------------------------------------- --------------------------------------------------------- @@ -1265,38 +1167,7 @@ OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -file-uri-to-path 2.0.0 - MIT -https://github.com/TooTallNate/file-uri-to-path - -Copyright (c) 2014 Nathan Rajlich - -Copyright (c) 2014 Nathan Rajlich - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -follow-redirects 1.15.2 - MIT +follow-redirects 1.15.6 - MIT https://github.com/follow-redirects/follow-redirects Copyright 2014-present Olivier Lalonde , James Talmage , Ruben Verborgh @@ -1353,27 +1224,8 @@ OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHE --------------------------------------------------------- -ftp 0.3.10 - MIT -https://github.com/mscdex/node-ftp - -Copyright Brian White. - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -get-uri 3.0.2 - MIT -https://github.com/TooTallNate/node-get-uri#readme +get-uri 6.0.1 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2014 Nathan Rajlich @@ -1391,92 +1243,21 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------- -http-errors 2.0.0 - MIT -https://github.com/jshttp/http-errors#readme - -Copyright (c) 2014 Jonathan Ong -Copyright (c) 2016 Douglas Christopher Wilson -Copyright (c) 2014 Jonathan Ong me@jongleberry.com -Copyright (c) 2016 Douglas Christopher Wilson doug@somethingdoug.com - - -The MIT License (MIT) - -Copyright (c) 2014 Jonathan Ong me@jongleberry.com -Copyright (c) 2016 Douglas Christopher Wilson doug@somethingdoug.com - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -http-proxy-agent 4.0.1 - MIT -https://github.com/TooTallNate/node-http-proxy-agent#readme - -Copyright (c) 2013 Nathan Rajlich - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -https-proxy-agent 5.0.1 - MIT -https://github.com/TooTallNate/node-https-proxy-agent#readme +http-proxy-agent 7.0.0 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2013 Nathan Rajlich -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +License +------- -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -iconv-lite 0.4.24 - MIT -https://github.com/ashtuchkin/iconv-lite - -Copyright (c) Microsoft Corporation -Copyright (c) 2011 Alexander Shtuchkin +(The MIT License) -Copyright (c) 2011 Alexander Shtuchkin +Copyright (c) 2013 Nathan Rajlich <nathan@tootallnate.net> Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including +'Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to @@ -1485,24 +1266,23 @@ the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- --------------------------------------------------------- -ip 1.1.8 - MIT -https://github.com/indutny/node-ip +https-proxy-agent 7.0.2 - MIT +https://github.com/TooTallNate/proxy-agents#readme -Copyright Fedor Indutny, 2012 +Copyright (c) 2013 Nathan Rajlich MIT License @@ -1518,33 +1298,45 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------- -ip 2.0.0 - MIT -https://github.com/indutny/node-ip +ip-address 9.0.5 - MIT +https://github.com/beaugunderson/ip-address#readme -Copyright Fedor Indutny, 2012 +Copyright (c) 2011 by Beau Gunderson -MIT License +Copyright (C) 2011 by Beau Gunderson -Copyright (c) +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- --------------------------------------------------------- -isarray 0.0.1 - MIT -https://github.com/juliangruber/isarray +is-fullwidth-code-point 3.0.0 - MIT +https://github.com/sindresorhus/is-fullwidth-code-point#readme -Copyright (c) 2013 Julian Gruber +(c) Sindre Sorhus (https://sindresorhus.com) +Copyright (c) Sindre Sorhus (sindresorhus.com) MIT License -Copyright (c) +Copyright (c) Sindre Sorhus (sindresorhus.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: @@ -1552,25 +1344,58 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + --------------------------------------------------------- --------------------------------------------------------- -is-fullwidth-code-point 3.0.0 - MIT -https://github.com/sindresorhus/is-fullwidth-code-point#readme +jsbn 1.1.0 - MIT +https://github.com/andyperlitch/jsbn#readme -(c) Sindre Sorhus (https://sindresorhus.com) -Copyright (c) Sindre Sorhus (sindresorhus.com) +Copyright (c) 2005 Tom Wu +Copyright (c) 2003-2005 Tom Wu +Copyright (c) 2005-2009 Tom Wu -MIT License +Licensing +--------- -Copyright (c) Sindre Sorhus (sindresorhus.com) +This software is covered under the following copyright: -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +/* + * Copyright (c) 2003-2005 Tom Wu + * All Rights Reserved. + * + * Permission is hereby granted, free of charge, to any person obtaining + * a copy of this software and associated documentation files (the + * "Software"), to deal in the Software without restriction, including + * without limitation the rights to use, copy, modify, merge, publish, + * distribute, sublicense, and/or sell copies of the Software, and to + * permit persons to whom the Software is furnished to do so, subject to + * the following conditions: + * + * The above copyright notice and this permission notice shall be + * included in all copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS-IS" AND WITHOUT WARRANTY OF ANY KIND, + * EXPRESS, IMPLIED OR OTHERWISE, INCLUDING WITHOUT LIMITATION, ANY + * WARRANTY OF MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE. + * + * IN NO EVENT SHALL TOM WU BE LIABLE FOR ANY SPECIAL, INCIDENTAL, + * INDIRECT OR CONSEQUENTIAL DAMAGES OF ANY KIND, OR ANY DAMAGES WHATSOEVER + * RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER OR NOT ADVISED OF + * THE POSSIBILITY OF DAMAGE, AND ON ANY THEORY OF LIABILITY, ARISING OUT + * OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + * + * In addition, the following condition applies: + * + * All redistributions must retain an intact copy of this copyright notice + * and disclaimer. + */ -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +Address all questions regarding this license to: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + Tom Wu + tjw@cs.Stanford.EDU --------------------------------------------------------- @@ -1838,7 +1663,7 @@ SOFTWARE. --------------------------------------------------------- -nan 2.17.0 - MIT +nan 2.18.0 - MIT https://github.com/nodejs/nan#readme Copyright (c) 2018 NAN WG Members @@ -1913,10 +1738,9 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------- -node-pty 0.10.1 - MIT -https://github.com/Tyriar/node-pty +node-pty 1.0.0 - MIT +https://github.com/microsoft/node-pty -Copyright This project Copyright (c) 2016, Daniel Imms Copyright (c) 2017, Daniel Imms Copyright (c) 2015 Ryan Prichard @@ -1924,9 +1748,10 @@ Copyright (c) 2016 Ryan Prichard Copyright (c) 2011-2012 Ryan Prichard Copyright (c) 2011-2015 Ryan Prichard Copyright (c) 2011-2016 Ryan Prichard -Copyright (c) 2009 Microsoft Corporation. +Copyright (c) 2009 Microsoft Corporation Copyright (c) 2018, Microsoft Corporation Copyright (c) 2019, Microsoft Corporation +Copyright (c) 2020, Microsoft Corporation Copyright (c) 2012-2015, Christopher Jeffrey Copyright (c) 2009 Todd Carson Copyright (c) 2018 - present Microsoft Corporation @@ -2068,8 +1893,8 @@ OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -pac-proxy-agent 5.0.0 - MIT -https://github.com/TooTallNate/node-pac-proxy-agent +pac-proxy-agent 7.0.1 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2014 Nathan Rajlich @@ -2087,20 +1912,33 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------- -pac-resolver 5.0.1 - MIT -https://github.com/TooTallNate/node-pac-resolver#readme +pac-resolver 7.0.1 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2013 Nathan Rajlich -MIT License +(The MIT License) -Copyright (c) +Copyright (c) 2013 Nathan Rajlich -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- @@ -2139,8 +1977,8 @@ OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -proxy-agent 5.0.0 - MIT -https://github.com/TooTallNate/node-proxy-agent +proxy-agent 6.3.1 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2013 Nathan Rajlich @@ -2189,65 +2027,34 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -raw-body 2.5.1 - MIT -https://github.com/stream-utils/raw-body#readme - -Copyright (c) 2013-2014 Jonathan Ong -Copyright (c) 2014-2022 Douglas Christopher Wilson -Copyright (c) 2013-2014 Jonathan Ong -Copyright (c) 2014-2022 Douglas Christopher Wilson - -The MIT License (MIT) - -Copyright (c) 2013-2014 Jonathan Ong -Copyright (c) 2014-2022 Douglas Christopher Wilson - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -readable-stream 1.1.14 - MIT +pull-stream 3.7.0 - MIT +https://pull-stream.github.io/ +(c) . To +Copyright (c) 2013 Dominic Tarr -Copyright Joyent, Inc. and other Node contributors +Copyright (c) 2013 Dominic Tarr -Copyright Joyent, Inc. and other Node contributors. All rights reserved. -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to -deal in the Software without restriction, including without limitation the -rights to use, copy, modify, merge, publish, distribute, sublicense, and/or -sell copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, +to any person obtaining a copy of this software and +associated documentation files (the "Software"), to +deal in the Software without restriction, including +without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom +the Software is furnished to do so, +subject to the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice +shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS -IN THE SOFTWARE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR +ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- @@ -2318,39 +2125,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -safer-buffer 2.1.2 - MIT -https://github.com/ChALkeR/safer-buffer#readme - -Copyright (c) 2018 Nikita Skovoroda - -MIT License - -Copyright (c) 2018 Nikita Skovoroda - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -shell-quote 1.7.4 - MIT +shell-quote 1.8.1 - MIT https://github.com/ljharb/shell-quote Copyright (c) 2013 James Halliday (mail@substack.net) @@ -2415,7 +2190,7 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -socks 2.7.1 - MIT +socks 2.7.3 - MIT https://github.com/JoshGlazebrook/socks/ Copyright (c) 2013 Josh Glazebrook @@ -2446,8 +2221,8 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -socks-proxy-agent 5.0.1 - MIT -https://github.com/TooTallNate/node-socks-proxy-agent#readme +socks-proxy-agent 8.0.2 - MIT +https://github.com/TooTallNate/proxy-agents#readme Copyright (c) 2013 Nathan Rajlich @@ -2461,43 +2236,6 @@ The above copyright notice and this permission notice shall be included in all c THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---------------------------------------------------------- - ---------------------------------------------------------- - -statuses 2.0.1 - MIT -https://github.com/jshttp/statuses#readme - -Copyright (c) 2014 Jonathan Ong -Copyright (c) 2016 Douglas Christopher Wilson -Copyright (c) 2014 Jonathan Ong -Copyright (c) 2016 Douglas Christopher Wilson - - -The MIT License (MIT) - -Copyright (c) 2014 Jonathan Ong -Copyright (c) 2016 Douglas Christopher Wilson - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - --------------------------------------------------------- --------------------------------------------------------- @@ -2531,37 +2269,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ---------------------------------------------------------- - ---------------------------------------------------------- - -string_decoder 0.10.31 - MIT -https://github.com/rvagg/string_decoder - -Copyright Joyent, Inc. and other Node contributors - -Copyright Joyent, Inc. and other Node contributors. - -Permission is hereby granted, free of charge, to any person obtaining a -copy of this software and associated documentation files (the -"Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to permit -persons to whom the Software is furnished to do so, subject to the -following conditions: - -The above copyright notice and this permission notice shall be included -in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS -OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN -NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, -DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR -OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE -USE OR OTHER DEALINGS IN THE SOFTWARE. - - --------------------------------------------------------- --------------------------------------------------------- @@ -2606,33 +2313,28 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------- -toidentifier 1.0.1 - MIT -https://github.com/component/toidentifier#readme +text-table 0.2.0 - MIT +https://github.com/substack/text-table -Copyright (c) 2016 Douglas Christopher Wilson -Copyright (c) 2016 Douglas Christopher Wilson - -MIT License -Copyright (c) 2016 Douglas Christopher Wilson +This software is released under the MIT license: -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- @@ -2703,70 +2405,16 @@ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. --------------------------------------------------------- -unpipe 1.0.0 - MIT -https://github.com/stream-utils/unpipe - -Copyright (c) 2015 Douglas Christopher Wilson -Copyright (c) 2015 Douglas Christopher Wilson - -(The MIT License) - -Copyright (c) 2015 Douglas Christopher Wilson - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ---------------------------------------------------------- - ---------------------------------------------------------- - -vscode-uri 3.0.6 - MIT -https://github.com/microsoft/vscode-uri#readme - -Copyright (c) Microsoft -Copyright (c) Microsoft Corporation -Copyright Joyent, Inc. and other Node contributors - -The MIT License (MIT) - -Copyright (c) Microsoft - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -word-wrap 1.2.3 - MIT +word-wrap 1.2.5 - MIT https://github.com/jonschlinkert/word-wrap -Copyright (c) 2014-2017, Jon Schlinkert -Copyright (c) 2017, Jon Schlinkert (https://github.com/jonschlinkert) +Copyright (c) 2014-2016, Jon Schlinkert +Copyright (c) 2014-2023, Jon Schlinkert +Copyright (c) 2023, Jon Schlinkert (https://github.com/jonschlinkert) The MIT License (MIT) -Copyright (c) 2014-2017, Jon Schlinkert +Copyright (c) 2014-2016, Jon Schlinkert Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -2811,32 +2459,7 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI --------------------------------------------------------- -xregexp 2.0.0 - MIT -http://xregexp.com/ - -(c) 2012 Steven Levithan -(c) 2007-2012 Steven Levithan -(c) 2008-2012 Steven Levithan -(c) 2009-2012 Steven Levithan -(c) 2010-2012 Steven Levithan -Copyright (c) 2007-2012 Steven Levithan -copyright 2007-2012 by Steven Levithan (http://stevenlevithan.com/). - -MIT License - -Copyright (c) - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ---------------------------------------------------------- - ---------------------------------------------------------- - -yargs 17.6.2 - MIT +yargs 17.7.2 - MIT https://yargs.js.org/ Copyright 2014 Contributors (ben@npmjs.com) diff --git a/azure-pipelines.yml b/azure-pipelines.yml index 81b996306..f5e6a89dc 100644 --- a/azure-pipelines.yml +++ b/azure-pipelines.yml @@ -6,11 +6,11 @@ trigger: include: - 'main' - 'release/*' - - 'spec-main' - - 'spec-release/*' pr: none steps: +- checkout: self + persistCredentials: true - task: ComponentGovernanceComponentDetection@0 - task: notice@0 displayName: 'NOTICE File Generator' @@ -24,18 +24,30 @@ steps: echo "3rd-party notices unchanged." else echo "3rd-party notices changed." + MESSAGE="Auto-update ThirdPartyNotices.txt" + if [ "$(git log -1 --pretty=%B | head -n 1)" = "$MESSAGE" ] + then + echo "Triggered by own commit, exiting." + exit 0 + fi + git config --get 'http.https://github.com/devcontainers/cli.extraheader' | cut -d ' ' -f 3 | base64 -d | cut -d : -f 2 | gh auth login --with-token + SOURCE_BRANCH="$(echo "$(Build.SourceBranch)" | cut -d / -f 3-)" + echo "Source branch: $SOURCE_BRANCH" + PR_LIST="$(gh pr list --base "$SOURCE_BRANCH" --jq ".[] | select(.title == \"$MESSAGE\")" --json headRefName,title,url | cat)" + echo "$PR_LIST" + if [ ! -z "$PR_LIST" ] + then + echo "PR exists, exiting." + exit 0 + fi + LOCAL_BRANCH="chrmarti/update-third-party-notices-$(date +%s)" + git checkout -b "$LOCAL_BRANCH" cp "$PIPELINE_WORKSPACE/NOTICE.txt/NOTICE.txt" ThirdPartyNotices.txt git status git add ThirdPartyNotices.txt git config --global user.email "chrmarti@microsoft.com" git config --global user.name "Christof Marti" - git commit -m "Auto-update ThirdPartyNotices.txt" - if [ "$(git log -1 --pretty=%B | head -n 1)" != "$(git log HEAD~2..HEAD~1 --pretty=%B | head -n 1)" ] - then - GIT_ASKPASS=scripts/gitAskPass.sh git push origin HEAD:$(Build.SourceBranch) - else - echo "Triggered by own commit, not pushing." - fi + git commit -m "$MESSAGE" + git push -u origin "$LOCAL_BRANCH" + gh pr create --title "$MESSAGE" --body "Auto-generated PR to update ThirdPartyNotices.txt" --base "$SOURCE_BRANCH" fi - env: - GIT_TOKEN: $(GIT_TOKEN) diff --git a/build/eslint/code-no-unexternalized-strings.js b/build/eslint/code-no-unexternalized-strings.js deleted file mode 100644 index 28fce5b9a..000000000 --- a/build/eslint/code-no-unexternalized-strings.js +++ /dev/null @@ -1,111 +0,0 @@ -"use strict"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -var _a; -const experimental_utils_1 = require("@typescript-eslint/experimental-utils"); -function isStringLiteral(node) { - return !!node && node.type === experimental_utils_1.AST_NODE_TYPES.Literal && typeof node.value === 'string'; -} -function isDoubleQuoted(node) { - return node.raw[0] === '"' && node.raw[node.raw.length - 1] === '"'; -} -module.exports = new (_a = class NoUnexternalizedStrings { - constructor() { - this.meta = { - messages: { - doubleQuoted: 'Only use double-quoted strings for externalized strings.', - badKey: 'The key \'{{key}}\' doesn\'t conform to a valid localize identifier.', - duplicateKey: 'Duplicate key \'{{key}}\' with different message value.', - badMessage: 'Message argument to \'{{message}}\' must be a string literal.' - } - }; - } - create(context) { - const externalizedStringLiterals = new Map(); - const doubleQuotedStringLiterals = new Set(); - function collectDoubleQuotedStrings(node) { - if (isStringLiteral(node) && isDoubleQuoted(node)) { - doubleQuotedStringLiterals.add(node); - } - } - function visitLocalizeCall(node) { - // localize(key, message) - const [keyNode, messageNode] = node.arguments; - // (1) - // extract key so that it can be checked later - let key; - if (isStringLiteral(keyNode)) { - doubleQuotedStringLiterals.delete(keyNode); //todo@joh reconsider - key = keyNode.value; - } - else if (keyNode.type === experimental_utils_1.AST_NODE_TYPES.ObjectExpression) { - for (let property of keyNode.properties) { - if (property.type === experimental_utils_1.AST_NODE_TYPES.Property && !property.computed) { - if (property.key.type === experimental_utils_1.AST_NODE_TYPES.Identifier && property.key.name === 'key') { - if (isStringLiteral(property.value)) { - doubleQuotedStringLiterals.delete(property.value); //todo@joh reconsider - key = property.value.value; - break; - } - } - } - } - } - if (typeof key === 'string') { - let array = externalizedStringLiterals.get(key); - if (!array) { - array = []; - externalizedStringLiterals.set(key, array); - } - array.push({ call: node, message: messageNode }); - } - // (2) - // remove message-argument from doubleQuoted list and make - // sure it is a string-literal - doubleQuotedStringLiterals.delete(messageNode); - if (!isStringLiteral(messageNode)) { - context.report({ - loc: messageNode.loc, - messageId: 'badMessage', - data: { message: context.getSourceCode().getText(node) } - }); - } - } - function reportBadStringsAndBadKeys() { - // (1) - // report all strings that are in double quotes - for (const node of doubleQuotedStringLiterals) { - context.report({ loc: node.loc, messageId: 'doubleQuoted' }); - } - for (const [key, values] of externalizedStringLiterals) { - // (2) - // report all invalid NLS keys - if (!key.match(NoUnexternalizedStrings._rNlsKeys)) { - for (let value of values) { - context.report({ loc: value.call.loc, messageId: 'badKey', data: { key } }); - } - } - // (2) - // report all invalid duplicates (same key, different message) - if (values.length > 1) { - for (let i = 1; i < values.length; i++) { - if (context.getSourceCode().getText(values[i - 1].message) !== context.getSourceCode().getText(values[i].message)) { - context.report({ loc: values[i].call.loc, messageId: 'duplicateKey', data: { key } }); - } - } - } - } - } - return { - ['Literal']: (node) => collectDoubleQuotedStrings(node), - ['ExpressionStatement[directive] Literal:exit']: (node) => doubleQuotedStringLiterals.delete(node), - ['CallExpression[callee.type="MemberExpression"][callee.object.name="nls"][callee.property.name="localize"]:exit']: (node) => visitLocalizeCall(node), - ['CallExpression[callee.name="localize"][arguments.length>=2]:exit']: (node) => visitLocalizeCall(node), - ['Program:exit']: reportBadStringsAndBadKeys, - }; - } - }, - _a._rNlsKeys = /^[_a-zA-Z0-9][ .\-_a-zA-Z0-9]*$/, - _a); diff --git a/build/eslint/code-no-unexternalized-strings.ts b/build/eslint/code-no-unexternalized-strings.ts deleted file mode 100644 index 29db884cd..000000000 --- a/build/eslint/code-no-unexternalized-strings.ts +++ /dev/null @@ -1,126 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as eslint from 'eslint'; -import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; - -function isStringLiteral(node: TSESTree.Node | null | undefined): node is TSESTree.StringLiteral { - return !!node && node.type === AST_NODE_TYPES.Literal && typeof node.value === 'string'; -} - -function isDoubleQuoted(node: TSESTree.StringLiteral): boolean { - return node.raw[0] === '"' && node.raw[node.raw.length - 1] === '"'; -} - -export = new class NoUnexternalizedStrings implements eslint.Rule.RuleModule { - - private static _rNlsKeys = /^[_a-zA-Z0-9][ .\-_a-zA-Z0-9]*$/; - - readonly meta: eslint.Rule.RuleMetaData = { - messages: { - doubleQuoted: 'Only use double-quoted strings for externalized strings.', - badKey: 'The key \'{{key}}\' doesn\'t conform to a valid localize identifier.', - duplicateKey: 'Duplicate key \'{{key}}\' with different message value.', - badMessage: 'Message argument to \'{{message}}\' must be a string literal.' - } - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - - const externalizedStringLiterals = new Map(); - const doubleQuotedStringLiterals = new Set(); - - function collectDoubleQuotedStrings(node: TSESTree.Literal) { - if (isStringLiteral(node) && isDoubleQuoted(node)) { - doubleQuotedStringLiterals.add(node); - } - } - - function visitLocalizeCall(node: TSESTree.CallExpression) { - - // localize(key, message) - const [keyNode, messageNode] = (node).arguments; - - // (1) - // extract key so that it can be checked later - let key: string | undefined; - if (isStringLiteral(keyNode)) { - doubleQuotedStringLiterals.delete(keyNode); //todo@joh reconsider - key = keyNode.value; - - } else if (keyNode.type === AST_NODE_TYPES.ObjectExpression) { - for (let property of keyNode.properties) { - if (property.type === AST_NODE_TYPES.Property && !property.computed) { - if (property.key.type === AST_NODE_TYPES.Identifier && property.key.name === 'key') { - if (isStringLiteral(property.value)) { - doubleQuotedStringLiterals.delete(property.value); //todo@joh reconsider - key = property.value.value; - break; - } - } - } - } - } - if (typeof key === 'string') { - let array = externalizedStringLiterals.get(key); - if (!array) { - array = []; - externalizedStringLiterals.set(key, array); - } - array.push({ call: node, message: messageNode }); - } - - // (2) - // remove message-argument from doubleQuoted list and make - // sure it is a string-literal - doubleQuotedStringLiterals.delete(messageNode); - if (!isStringLiteral(messageNode)) { - context.report({ - loc: messageNode.loc, - messageId: 'badMessage', - data: { message: context.getSourceCode().getText(node) } - }); - } - } - - function reportBadStringsAndBadKeys() { - // (1) - // report all strings that are in double quotes - for (const node of doubleQuotedStringLiterals) { - context.report({ loc: node.loc, messageId: 'doubleQuoted' }); - } - - for (const [key, values] of externalizedStringLiterals) { - - // (2) - // report all invalid NLS keys - if (!key.match(NoUnexternalizedStrings._rNlsKeys)) { - for (let value of values) { - context.report({ loc: value.call.loc, messageId: 'badKey', data: { key } }); - } - } - - // (2) - // report all invalid duplicates (same key, different message) - if (values.length > 1) { - for (let i = 1; i < values.length; i++) { - if (context.getSourceCode().getText(values[i - 1].message) !== context.getSourceCode().getText(values[i].message)) { - context.report({ loc: values[i].call.loc, messageId: 'duplicateKey', data: { key } }); - } - } - } - } - } - - return { - ['Literal']: (node: any) => collectDoubleQuotedStrings(node), - ['ExpressionStatement[directive] Literal:exit']: (node: any) => doubleQuotedStringLiterals.delete(node), - ['CallExpression[callee.type="MemberExpression"][callee.object.name="nls"][callee.property.name="localize"]:exit']: (node: any) => visitLocalizeCall(node), - ['CallExpression[callee.name="localize"][arguments.length>=2]:exit']: (node: any) => visitLocalizeCall(node), - ['Program:exit']: reportBadStringsAndBadKeys, - }; - } -}; - diff --git a/build/eslint/code-no-unused-expressions.js b/build/eslint/code-no-unused-expressions.js deleted file mode 100644 index 80ae9a757..000000000 --- a/build/eslint/code-no-unused-expressions.js +++ /dev/null @@ -1,141 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -// FORKED FROM https://github.com/eslint/eslint/blob/b23ad0d789a909baf8d7c41a35bc53df932eaf30/lib/rules/no-unused-expressions.js -// and added support for `OptionalCallExpression`, see https://github.com/facebook/create-react-app/issues/8107 and https://github.com/eslint/eslint/issues/12642 - -/** - * @fileoverview Flag expressions in statement position that do not side effect - * @author Michael Ficarra - */ - -'use strict'; - -//------------------------------------------------------------------------------ -// Rule Definition -//------------------------------------------------------------------------------ - -module.exports = { - meta: { - type: 'suggestion', - - docs: { - description: 'disallow unused expressions', - category: 'Best Practices', - recommended: false, - url: 'https://eslint.org/docs/rules/no-unused-expressions' - }, - - schema: [ - { - type: 'object', - properties: { - allowShortCircuit: { - type: 'boolean', - default: false - }, - allowTernary: { - type: 'boolean', - default: false - }, - allowTaggedTemplates: { - type: 'boolean', - default: false - } - }, - additionalProperties: false - } - ] - }, - - create(context) { - const config = context.options[0] || {}, - allowShortCircuit = config.allowShortCircuit || false, - allowTernary = config.allowTernary || false, - allowTaggedTemplates = config.allowTaggedTemplates || false; - - /** - * @param {ASTNode} node any node - * @returns {boolean} whether the given node structurally represents a directive - */ - function looksLikeDirective(node) { - return node.type === 'ExpressionStatement' && - node.expression.type === 'Literal' && typeof node.expression.value === 'string'; - } - - /** - * @param {Function} predicate ([a] -> Boolean) the function used to make the determination - * @param {a[]} list the input list - * @returns {a[]} the leading sequence of members in the given list that pass the given predicate - */ - function takeWhile(predicate, list) { - for (let i = 0; i < list.length; ++i) { - if (!predicate(list[i])) { - return list.slice(0, i); - } - } - return list.slice(); - } - - /** - * @param {ASTNode} node a Program or BlockStatement node - * @returns {ASTNode[]} the leading sequence of directive nodes in the given node's body - */ - function directives(node) { - return takeWhile(looksLikeDirective, node.body); - } - - /** - * @param {ASTNode} node any node - * @param {ASTNode[]} ancestors the given node's ancestors - * @returns {boolean} whether the given node is considered a directive in its current position - */ - function isDirective(node, ancestors) { - const parent = ancestors[ancestors.length - 1], - grandparent = ancestors[ancestors.length - 2]; - - return (parent.type === 'Program' || parent.type === 'BlockStatement' && - (/Function/u.test(grandparent.type))) && - directives(parent).indexOf(node) >= 0; - } - - /** - * Determines whether or not a given node is a valid expression. Recurses on short circuit eval and ternary nodes if enabled by flags. - * @param {ASTNode} node any node - * @returns {boolean} whether the given node is a valid expression - */ - function isValidExpression(node) { - if (allowTernary) { - - // Recursive check for ternary and logical expressions - if (node.type === 'ConditionalExpression') { - return isValidExpression(node.consequent) && isValidExpression(node.alternate); - } - } - - if (allowShortCircuit) { - if (node.type === 'LogicalExpression') { - return isValidExpression(node.right); - } - } - - if (allowTaggedTemplates && node.type === 'TaggedTemplateExpression') { - return true; - } - - return /^(?:Assignment|OptionalCall|Call|New|Update|Yield|Await)Expression$/u.test(node.type) || - (node.type === 'UnaryExpression' && ['delete', 'void'].indexOf(node.operator) >= 0); - } - - return { - ExpressionStatement(node) { - if (!isValidExpression(node.expression) && !isDirective(node, context.getAncestors())) { - context.report({ node, message: 'Expected an assignment or function call and instead saw an expression.' }); - } - } - }; - - } -}; diff --git a/build/eslint/utils.js b/build/eslint/utils.js deleted file mode 100644 index c58e4e24b..000000000 --- a/build/eslint/utils.js +++ /dev/null @@ -1,37 +0,0 @@ -"use strict"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -Object.defineProperty(exports, "__esModule", { value: true }); -exports.createImportRuleListener = void 0; -function createImportRuleListener(validateImport) { - function _checkImport(node) { - if (node && node.type === 'Literal' && typeof node.value === 'string') { - validateImport(node, node.value); - } - } - return { - // import ??? from 'module' - ImportDeclaration: (node) => { - _checkImport(node.source); - }, - // import('module').then(...) OR await import('module') - ['CallExpression[callee.type="Import"][arguments.length=1] > Literal']: (node) => { - _checkImport(node); - }, - // import foo = ... - ['TSImportEqualsDeclaration > TSExternalModuleReference > Literal']: (node) => { - _checkImport(node); - }, - // export ?? from 'module' - ExportAllDeclaration: (node) => { - _checkImport(node.source); - }, - // export {foo} from 'module' - ExportNamedDeclaration: (node) => { - _checkImport(node.source); - }, - }; -} -exports.createImportRuleListener = createImportRuleListener; diff --git a/build/eslint/utils.ts b/build/eslint/utils.ts deleted file mode 100644 index 428832e9c..000000000 --- a/build/eslint/utils.ts +++ /dev/null @@ -1,40 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as eslint from 'eslint'; -import { TSESTree } from '@typescript-eslint/experimental-utils'; - -export function createImportRuleListener(validateImport: (node: TSESTree.Literal, value: string) => any): eslint.Rule.RuleListener { - - function _checkImport(node: TSESTree.Node | null) { - if (node && node.type === 'Literal' && typeof node.value === 'string') { - validateImport(node, node.value); - } - } - - return { - // import ??? from 'module' - ImportDeclaration: (node: any) => { - _checkImport((node).source); - }, - // import('module').then(...) OR await import('module') - ['CallExpression[callee.type="Import"][arguments.length=1] > Literal']: (node: any) => { - _checkImport(node); - }, - // import foo = ... - ['TSImportEqualsDeclaration > TSExternalModuleReference > Literal']: (node: any) => { - _checkImport(node); - }, - // export ?? from 'module' - ExportAllDeclaration: (node: any) => { - _checkImport((node).source); - }, - // export {foo} from 'module' - ExportNamedDeclaration: (node: any) => { - _checkImport((node).source); - }, - - }; -} diff --git a/build/eslint/vscode-dts-create-func.js b/build/eslint/vscode-dts-create-func.js deleted file mode 100644 index 5a27bf51c..000000000 --- a/build/eslint/vscode-dts-create-func.js +++ /dev/null @@ -1,35 +0,0 @@ -"use strict"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -const experimental_utils_1 = require("@typescript-eslint/experimental-utils"); -module.exports = new class ApiLiteralOrTypes { - constructor() { - this.meta = { - docs: { url: 'https://github.com/microsoft/vscode/wiki/Extension-API-guidelines#creating-objects' }, - messages: { sync: '`createXYZ`-functions are constructor-replacements and therefore must return sync', } - }; - } - create(context) { - return { - ['TSDeclareFunction Identifier[name=/create.*/]']: (node) => { - var _a; - const decl = node.parent; - if (((_a = decl.returnType) === null || _a === void 0 ? void 0 : _a.typeAnnotation.type) !== experimental_utils_1.AST_NODE_TYPES.TSTypeReference) { - return; - } - if (decl.returnType.typeAnnotation.typeName.type !== experimental_utils_1.AST_NODE_TYPES.Identifier) { - return; - } - const ident = decl.returnType.typeAnnotation.typeName.name; - if (ident === 'Promise' || ident === 'Thenable') { - context.report({ - node, - messageId: 'sync' - }); - } - } - }; - } -}; diff --git a/build/eslint/vscode-dts-create-func.ts b/build/eslint/vscode-dts-create-func.ts deleted file mode 100644 index 295d099da..000000000 --- a/build/eslint/vscode-dts-create-func.ts +++ /dev/null @@ -1,40 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as eslint from 'eslint'; -import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; - -export = new class ApiLiteralOrTypes implements eslint.Rule.RuleModule { - - readonly meta: eslint.Rule.RuleMetaData = { - docs: { url: 'https://github.com/microsoft/vscode/wiki/Extension-API-guidelines#creating-objects' }, - messages: { sync: '`createXYZ`-functions are constructor-replacements and therefore must return sync', } - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - - return { - ['TSDeclareFunction Identifier[name=/create.*/]']: (node: any) => { - - const decl = (node).parent; - - if (decl.returnType?.typeAnnotation.type !== AST_NODE_TYPES.TSTypeReference) { - return; - } - if (decl.returnType.typeAnnotation.typeName.type !== AST_NODE_TYPES.Identifier) { - return; - } - - const ident = decl.returnType.typeAnnotation.typeName.name; - if (ident === 'Promise' || ident === 'Thenable') { - context.report({ - node, - messageId: 'sync' - }); - } - } - }; - } -}; diff --git a/build/eslint/vscode-dts-event-naming.js b/build/eslint/vscode-dts-event-naming.js deleted file mode 100644 index c93c18183..000000000 --- a/build/eslint/vscode-dts-event-naming.js +++ /dev/null @@ -1,81 +0,0 @@ -"use strict"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -var _a; -const experimental_utils_1 = require("@typescript-eslint/experimental-utils"); -module.exports = new (_a = class ApiEventNaming { - constructor() { - this.meta = { - docs: { - url: 'https://github.com/microsoft/vscode/wiki/Extension-API-guidelines#event-naming' - }, - messages: { - naming: 'Event names must follow this patten: `on[Did|Will]`', - verb: 'Unknown verb \'{{verb}}\' - is this really a verb? Iff so, then add this verb to the configuration', - subject: 'Unknown subject \'{{subject}}\' - This subject has not been used before but it should refer to something in the API', - unknown: 'UNKNOWN event declaration, lint-rule needs tweaking' - } - }; - } - create(context) { - const config = context.options[0]; - const allowed = new Set(config.allowed); - const verbs = new Set(config.verbs); - return { - ['TSTypeAnnotation TSTypeReference Identifier[name="Event"]']: (node) => { - var _a, _b; - const def = (_b = (_a = node.parent) === null || _a === void 0 ? void 0 : _a.parent) === null || _b === void 0 ? void 0 : _b.parent; - let ident; - if ((def === null || def === void 0 ? void 0 : def.type) === experimental_utils_1.AST_NODE_TYPES.Identifier) { - ident = def; - } - else if (((def === null || def === void 0 ? void 0 : def.type) === experimental_utils_1.AST_NODE_TYPES.TSPropertySignature || (def === null || def === void 0 ? void 0 : def.type) === experimental_utils_1.AST_NODE_TYPES.ClassProperty) && def.key.type === experimental_utils_1.AST_NODE_TYPES.Identifier) { - ident = def.key; - } - if (!ident) { - // event on unknown structure... - return context.report({ - node, - message: 'unknown' - }); - } - if (allowed.has(ident.name)) { - // configured exception - return; - } - const match = ApiEventNaming._nameRegExp.exec(ident.name); - if (!match) { - context.report({ - node: ident, - messageId: 'naming' - }); - return; - } - // check that is spelled out (configured) as verb - if (!verbs.has(match[2].toLowerCase())) { - context.report({ - node: ident, - messageId: 'verb', - data: { verb: match[2] } - }); - } - // check that a subject (if present) has occurred - if (match[3]) { - const regex = new RegExp(match[3], 'ig'); - const parts = context.getSourceCode().getText().split(regex); - if (parts.length < 3) { - context.report({ - node: ident, - messageId: 'subject', - data: { subject: match[3] } - }); - } - } - } - }; - } - }, - _a._nameRegExp = /on(Did|Will)([A-Z][a-z]+)([A-Z][a-z]+)?/, - _a); diff --git a/build/eslint/vscode-dts-event-naming.ts b/build/eslint/vscode-dts-event-naming.ts deleted file mode 100644 index 6543c4586..000000000 --- a/build/eslint/vscode-dts-event-naming.ts +++ /dev/null @@ -1,91 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as eslint from 'eslint'; -import { TSESTree, AST_NODE_TYPES } from '@typescript-eslint/experimental-utils'; - -export = new class ApiEventNaming implements eslint.Rule.RuleModule { - - private static _nameRegExp = /on(Did|Will)([A-Z][a-z]+)([A-Z][a-z]+)?/; - - readonly meta: eslint.Rule.RuleMetaData = { - docs: { - url: 'https://github.com/microsoft/vscode/wiki/Extension-API-guidelines#event-naming' - }, - messages: { - naming: 'Event names must follow this patten: `on[Did|Will]`', - verb: 'Unknown verb \'{{verb}}\' - is this really a verb? Iff so, then add this verb to the configuration', - subject: 'Unknown subject \'{{subject}}\' - This subject has not been used before but it should refer to something in the API', - unknown: 'UNKNOWN event declaration, lint-rule needs tweaking' - } - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - - const config = <{ allowed: string[], verbs: string[] }>context.options[0]; - const allowed = new Set(config.allowed); - const verbs = new Set(config.verbs); - - return { - ['TSTypeAnnotation TSTypeReference Identifier[name="Event"]']: (node: any) => { - - const def = (node).parent?.parent?.parent; - let ident: TSESTree.Identifier | undefined; - - if (def?.type === AST_NODE_TYPES.Identifier) { - ident = def; - - } else if ((def?.type === AST_NODE_TYPES.TSPropertySignature || def?.type === AST_NODE_TYPES.ClassProperty) && def.key.type === AST_NODE_TYPES.Identifier) { - ident = def.key; - } - - if (!ident) { - // event on unknown structure... - return context.report({ - node, - message: 'unknown' - }); - } - - if (allowed.has(ident.name)) { - // configured exception - return; - } - - const match = ApiEventNaming._nameRegExp.exec(ident.name); - if (!match) { - context.report({ - node: ident, - messageId: 'naming' - }); - return; - } - - // check that is spelled out (configured) as verb - if (!verbs.has(match[2].toLowerCase())) { - context.report({ - node: ident, - messageId: 'verb', - data: { verb: match[2] } - }); - } - - // check that a subject (if present) has occurred - if (match[3]) { - const regex = new RegExp(match[3], 'ig'); - const parts = context.getSourceCode().getText().split(regex); - if (parts.length < 3) { - context.report({ - node: ident, - messageId: 'subject', - data: { subject: match[3] } - }); - } - } - } - }; - } -}; - diff --git a/build/eslint/vscode-dts-interface-naming.js b/build/eslint/vscode-dts-interface-naming.js deleted file mode 100644 index 70ca81082..000000000 --- a/build/eslint/vscode-dts-interface-naming.js +++ /dev/null @@ -1,30 +0,0 @@ -"use strict"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -var _a; -module.exports = new (_a = class ApiInterfaceNaming { - constructor() { - this.meta = { - messages: { - naming: 'Interfaces must not be prefixed with uppercase `I`', - } - }; - } - create(context) { - return { - ['TSInterfaceDeclaration Identifier']: (node) => { - const name = node.name; - if (ApiInterfaceNaming._nameRegExp.test(name)) { - context.report({ - node, - messageId: 'naming' - }); - } - } - }; - } - }, - _a._nameRegExp = /I[A-Z]/, - _a); diff --git a/build/eslint/vscode-dts-interface-naming.ts b/build/eslint/vscode-dts-interface-naming.ts deleted file mode 100644 index d9ec4e8c3..000000000 --- a/build/eslint/vscode-dts-interface-naming.ts +++ /dev/null @@ -1,35 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as eslint from 'eslint'; -import { TSESTree } from '@typescript-eslint/experimental-utils'; - -export = new class ApiInterfaceNaming implements eslint.Rule.RuleModule { - - private static _nameRegExp = /I[A-Z]/; - - readonly meta: eslint.Rule.RuleMetaData = { - messages: { - naming: 'Interfaces must not be prefixed with uppercase `I`', - } - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - - return { - ['TSInterfaceDeclaration Identifier']: (node: any) => { - - const name = (node).name; - if (ApiInterfaceNaming._nameRegExp.test(name)) { - context.report({ - node, - messageId: 'naming' - }); - } - } - }; - } -}; - diff --git a/build/eslint/vscode-dts-literal-or-types.js b/build/eslint/vscode-dts-literal-or-types.js deleted file mode 100644 index 02e6de876..000000000 --- a/build/eslint/vscode-dts-literal-or-types.js +++ /dev/null @@ -1,23 +0,0 @@ -"use strict"; -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ -module.exports = new class ApiLiteralOrTypes { - constructor() { - this.meta = { - docs: { url: 'https://github.com/microsoft/vscode/wiki/Extension-API-guidelines#enums' }, - messages: { useEnum: 'Use enums, not literal-or-types', } - }; - } - create(context) { - return { - ['TSTypeAnnotation TSUnionType TSLiteralType']: (node) => { - context.report({ - node: node, - messageId: 'useEnum' - }); - } - }; - } -}; diff --git a/build/eslint/vscode-dts-literal-or-types.ts b/build/eslint/vscode-dts-literal-or-types.ts deleted file mode 100644 index 01a3eb215..000000000 --- a/build/eslint/vscode-dts-literal-or-types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the MIT License. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -import * as eslint from 'eslint'; - -export = new class ApiLiteralOrTypes implements eslint.Rule.RuleModule { - - readonly meta: eslint.Rule.RuleMetaData = { - docs: { url: 'https://github.com/microsoft/vscode/wiki/Extension-API-guidelines#enums' }, - messages: { useEnum: 'Use enums, not literal-or-types', } - }; - - create(context: eslint.Rule.RuleContext): eslint.Rule.RuleListener { - return { - ['TSTypeAnnotation TSUnionType TSLiteralType']: (node: any) => { - context.report({ - node: node, - messageId: 'useEnum' - }); - } - }; - } -}; diff --git a/build/hygiene.js b/build/hygiene.js index e3326f555..4959e988e 100644 --- a/build/hygiene.js +++ b/build/hygiene.js @@ -5,15 +5,30 @@ 'use strict'; -const filter = require('gulp-filter'); const es = require('event-stream'); const tsfmt = require('typescript-formatter'); -const gulpeslint = require('gulp-eslint'); +const { ESLint } = require('eslint'); const VinylFile = require('vinyl'); const vfs = require('vinyl-fs'); const path = require('path'); const fs = require('fs'); -const pall = require('p-all'); +const pall = require('p-all').default; +const { minimatch } = require('minimatch'); + +function fileFilter(patterns) { + return es.through(function (file) { + const rel = file.relative; + const match = patterns.every(p => { + if (p.startsWith('!')) { + return !minimatch(rel, p.slice(1), { dot: true }); + } + return true; + }) && patterns.some(p => !p.startsWith('!') && minimatch(rel, p, { dot: true })); + if (match) { + this.emit('data', file); + } + }); +} /** * Hygiene works by creating cascading subsets of all our files and @@ -175,21 +190,26 @@ function hygiene(some) { } const result = input - .pipe(filter(f => !f.stat.isDirectory())) - .pipe(filter(indentationFilter)) + .pipe(es.through(function (f) { if (!f.stat.isDirectory()) this.emit('data', f); })) + .pipe(fileFilter(indentationFilter)) .pipe(indentation) - .pipe(filter(copyrightFilter)) + .pipe(fileFilter(copyrightFilter)) .pipe(copyrights) - .pipe(filter(tsHygieneFilter)) + .pipe(fileFilter(tsHygieneFilter)) .pipe(formatting) - .pipe(gulpeslint({ - configFile: '.eslintrc.js', - rulePaths: ['./build/eslint'] - })) - .pipe(gulpeslint.formatEach('compact')) - .pipe(gulpeslint.result(result => { - errorCount += result.warningCount; - errorCount += result.errorCount; + .pipe(es.map(function (file, cb) { + const eslint = new ESLint(); + eslint.lintText(file.contents.toString('utf8'), { + filePath: file.path, + }).then(results => { + for (const result of results) { + errorCount += result.warningCount + result.errorCount; + for (const message of result.messages) { + console.error(`${file.relative}:${message.line}:${message.column}: ${message.message} [${message.ruleId}]`); + } + } + cb(null, file); + }).catch(err => cb(err)); })); let count = 0; diff --git a/docs/contributing-code.md b/docs/contributing-code.md new file mode 100644 index 000000000..abfde9c5c --- /dev/null +++ b/docs/contributing-code.md @@ -0,0 +1,176 @@ +# Contributing Code + +This guide covers everything you need to set up a development environment, build, test, and submit code changes to the Dev Containers CLI. For the proposal and specification process, see [CONTRIBUTING.md](../CONTRIBUTING.md). + +## Prerequisites + +- [Node.js](https://nodejs.org/) >= 20 +- [Docker](https://www.docker.com/) (required for running integration tests — they create real containers) +- [Git](https://git-scm.com/) +- [yarn](https://yarnpkg.com/) (used for dependency installation) + +## Setting up your development environment + +Fork and clone the repository: + +```sh +git clone https://github.com//cli.git +cd cli +``` + +### Option A: Dev Container (recommended) + +The repository includes a [dev container configuration](../.devcontainer/devcontainer.json) that provides a ready-to-go environment with Node.js, TypeScript, and Docker-in-Docker pre-configured. + +1. Open the cloned repository in VS Code. +2. When prompted, select **Reopen in Container** (requires the [Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)). Alternatively, open the repository in [GitHub Codespaces](https://github.com/features/codespaces). +3. The `postCreateCommand` automatically runs `yarn install` to install all dependencies. + +You are ready to build and test. + +### Option B: Local setup + +1. Install Node.js >= 20 and Docker. +2. Install dependencies: + + ```sh + yarn install + ``` + + Ensure Docker is running — it is needed for the integration test suite. + + Some tests build containers for non-native architectures (e.g., `linux/arm64` on an x64 host, or vice versa). To run these locally, register QEMU emulators: + + ```sh + docker run --privileged --rm tonistiigi/binfmt --install all + ``` + + This is needed once per boot (or per WSL session on Windows). On macOS with Docker Desktop, cross-architecture emulation is built in and this step is not required. + +3. *(Optional)* Install [Podman](https://podman.io/) if you want to run the Podman-specific tests. The CLI supports both Docker and Podman as container engines, and the test suite includes a separate set of tests (`cli.podman.test.ts`) that verify Podman compatibility using `--docker-path podman`. These tests will fail with `spawn podman ENOENT` if Podman is not installed — this is expected and does not indicate a code problem. The CI GitHub workflow runs these tests on `ubuntu-latest` where Podman is pre-installed. + +## Project structure + +The CLI is written in TypeScript and organized as multiple sub-projects using [TypeScript project references](https://www.typescriptlang.org/docs/handbook/project-references.html): + +| Sub-project | Path | Purpose | +| --- | --- | --- | +| `spec-common` | `src/spec-common/` | Shared utilities (async helpers, CLI host, process management, shell server) | +| `spec-configuration` | `src/spec-configuration/` | Configuration parsing, OCI registry interactions, Features/Templates configuration | +| `spec-node` | `src/spec-node/` | Core CLI logic — container lifecycle, Docker/Compose integration, Feature utilities | +| `spec-shutdown` | `src/spec-shutdown/` | Docker CLI wrapper utilities (container inspection, execution, lifecycle management) | +| `spec-utils` | `src/spec-utils/` | General utilities (logging, HTTP requests, filesystem helpers) | + +Key files: + +- `devcontainer.js` — Entry point that loads the bundled CLI from `dist/spec-node/devContainersSpecCLI.js`. +- `esbuild.js` — Build script that bundles the TypeScript output with esbuild. +- `src/test/` — Test files and fixture configurations under `src/test/configs/`. + +## Development workflow + +### 1. Build + +Start the dev build watchers — run these in separate terminals (or use the [VS Code build task](#vs-code-integration)): + +```sh +npm run watch # incremental esbuild (rebuilds on save) +npm run type-check-watch # tsc in watch mode (reports type errors) +``` + +For a one-shot build instead, run `npm run compile`. To remove all build output, run `npm run clean`. + +### 2. Run + +After building, invoke the CLI directly: + +```sh +node devcontainer.js --help +node devcontainer.js up --workspace-folder +node devcontainer.js build --workspace-folder +node devcontainer.js run-user-commands --workspace-folder +``` + +### 3. Test + +Tests use [Mocha](https://mochajs.org/) and [Chai](https://www.chaijs.com/) and require Docker because they create and tear down real containers. + +Before running tests, package the CLI into a tarball: + +```sh +npm run package +``` + +Tests install the CLI from the generated `devcontainers-cli-.tgz` and shell out to it as a subprocess. You must re-run `npm run package` after any code change so that the tarball reflects your latest changes. Running `npm run compile` alone is **not** sufficient — it builds the JavaScript output but does not create the tarball that the tests depend on. + +```sh +npm test # all tests +npm run test-container-features # Features tests only +npm run test-container-templates # Templates tests only +``` + +#### Adding tests + +- Place new test files in `src/test/` with a `.test.ts` suffix. +- Place test fixture `devcontainer.json` configurations under `src/test/configs//`. +- Use the helpers in `src/test/testUtils.ts` (`shellExec`, `devContainerUp`, `devContainerDown`) for container lifecycle management in tests. + +### 4. Validate and submit + +Before committing, run the same checks CI runs: + +```sh +npm run type-check # full type-check +npm run package # production build (minified) + pack into .tgz +npm run precommit # lint, formatting, copyright headers +npm test # full test suite (may take a very long time to run, consider running a subset of tests during development) +``` + +Then push your branch and open a pull request against `main`. Link any related [repo issues](https://github.com/devcontainers/cli/issues) or [specification issues](https://github.com/microsoft/dev-container-spec/issues) in the PR description. + +## VS Code integration + +The repository includes VS Code configuration in `.vscode/` for building, debugging, and testing. + +### Build task + +The default build task (**Ctrl+Shift+B** / **Cmd+Shift+B**) is **Build Dev Containers CLI**. It runs `npm run watch` and `npm run type-check-watch` in parallel so you get both bundled output and type errors as you edit. + +### Debug configurations + +Two launch configurations are provided in `.vscode/launch.json`: + +- **Launch CLI - up** — Runs the CLI's `up` command against `src/test/configs/example/`. Edit the `args` array to point at a different config or subcommand. +- **Launch Tests** — Runs the full Mocha test suite under the debugger. + +### Editor settings + +The workspace recommends the [ESLint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) for inline lint feedback. The workspace settings (`.vscode/settings.json`) configure format-on-save, tab indentation, and the workspace TypeScript SDK. + +## Troubleshooting + +### Docker not available + +Tests will fail if Docker is not running. Make sure the Docker daemon is started. If using the dev container, Docker-in-Docker is configured automatically. + +### `node-pty` native module build failures + +The `node-pty` dependency includes native code. If you see build errors during `yarn install`, ensure you have the required build tools for your platform (e.g., `build-essential` on Debian/Ubuntu, Xcode Command Line Tools on macOS). + +### Leftover test containers + +If tests are interrupted, containers may be left running. Single-container tests label their containers with `devcontainer.local_folder`: + +```sh +docker rm -f $(docker ps -aq --filter "label=devcontainer.local_folder") +``` + +Compose-based tests also create sidecar containers (e.g., `db` services) that don't carry that label. To remove those, filter by the compose config path: + +```sh +docker rm -f $(docker ps -a --format '{{.ID}} {{.Label "com.docker.compose.project.config_files"}}' | grep src/test/configs | awk '{print $1}') +``` + +### Podman test failures + +If you don't have Podman installed, `cli.podman.test.ts` will fail with `spawn podman ENOENT`. This is safe to ignore — CI will run them. See [Local setup](#option-b-local-setup) for details on installing Podman or skipping these tests. diff --git a/esbuild.js b/esbuild.js index 9a85ccea3..2e386ea74 100644 --- a/esbuild.js +++ b/esbuild.js @@ -65,7 +65,6 @@ const watch = process.argv.indexOf('--watch') !== -1; minify, platform: 'node', target: 'node14.17.0', - external: ['vscode-dev-containers'], mainFields: ['module', 'main'], outdir: 'dist', plugins: [plugin], diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 000000000..e44a85adf --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,61 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import typescriptParser from '@typescript-eslint/parser'; +import typescriptPlugin from '@typescript-eslint/eslint-plugin'; +import stylisticPlugin from '@stylistic/eslint-plugin'; + +export default [ + { + ignores: ['**/node_modules/**'], + }, + { + files: ['src/**/*.ts'], + languageOptions: { + parser: typescriptParser, + sourceType: 'module', + }, + plugins: { + '@typescript-eslint': typescriptPlugin, + '@stylistic': stylisticPlugin, + }, + rules: { + '@stylistic/member-delimiter-style': [ + 'warn', + { + multiline: { + delimiter: 'semi', + requireLast: true, + }, + singleline: { + delimiter: 'semi', + requireLast: false, + }, + }, + ], + 'semi': ['warn', 'always'], + 'constructor-super': 'warn', + 'curly': 'warn', + 'eqeqeq': ['warn', 'always'], + 'no-async-promise-executor': 'warn', + 'no-buffer-constructor': 'warn', + 'no-caller': 'warn', + 'no-debugger': 'warn', + 'no-duplicate-case': 'warn', + 'no-duplicate-imports': 'warn', + 'no-eval': 'warn', + 'no-extra-semi': 'warn', + 'no-new-wrappers': 'warn', + 'no-redeclare': 'off', + 'no-sparse-arrays': 'warn', + 'no-throw-literal': 'warn', + 'no-unsafe-finally': 'warn', + 'no-unused-labels': 'warn', + '@typescript-eslint/no-redeclare': 'warn', + 'no-var': 'warn', + 'no-unused-expressions': ['warn', { allowTernary: true }], + }, + }, +]; diff --git a/example-usage/workspace/.devcontainer/Dockerfile b/example-usage/workspace/.devcontainer/Dockerfile index 2f36f62bc..c2e6417af 100644 --- a/example-usage/workspace/.devcontainer/Dockerfile +++ b/example-usage/workspace/.devcontainer/Dockerfile @@ -1 +1 @@ -FROM mcr.microsoft.com/vscode/devcontainers/base:0-bullseye +FROM mcr.microsoft.com/devcontainers/base:1-bookworm diff --git a/example-usage/workspace/.devcontainer/devcontainer.json b/example-usage/workspace/.devcontainer/devcontainer.json index a38c00a9b..f066e3d1e 100644 --- a/example-usage/workspace/.devcontainer/devcontainer.json +++ b/example-usage/workspace/.devcontainer/devcontainer.json @@ -35,7 +35,7 @@ "nodeGypDependencies": false }, "ghcr.io/devcontainers/features/desktop-lite:1": { }, - "ghcr.io/devcontainers/features/docker-in-docker:1": { }, + "ghcr.io/devcontainers/features/docker-in-docker:2": { }, // Optional - For tools that require SSH "ghcr.io/devcontainers/features/sshd:1": { } }, diff --git a/package.json b/package.json index 127586195..b62f6402e 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@devcontainers/cli", "description": "Dev Containers CLI", - "version": "0.50.2", + "version": "0.87.0", "bin": { "devcontainer": "devcontainer.js" }, @@ -15,12 +15,12 @@ }, "license": "MIT", "engines": { - "node": "^16.13.0 || >=18.0.0" + "node": ">=20.0.0" }, "scripts": { - "compile": "npm-run-all clean-dist definitions compile-dev", - "watch": "npm-run-all clean-dist definitions compile-watch", - "package": "npm-run-all clean-dist definitions compile-prod store-packagejson patch-packagejson npm-pack restore-packagejson", + "compile": "npm-run-all clean-dist compile-dev", + "watch": "npm-run-all clean-dist compile-watch", + "package": "npm-run-all clean-dist compile-prod store-packagejson patch-packagejson npm-pack restore-packagejson", "store-packagejson": "copyfiles package.json build-tmp/", "patch-packagejson": "node build/patch-packagejson.js", "restore-packagejson": "copyfiles --up 1 build-tmp/package.json .", @@ -32,17 +32,15 @@ "tsc-b": "tsc -b", "tsc-b-w": "tsc -b -w", "precommit": "node build/hygiene.js", - "definitions": "npm-run-all definitions-clean definitions-copy", - "lint": "eslint -c .eslintrc.js --rulesdir ./build/eslint --max-warnings 0 --ext .ts ./src", - "definitions-clean": "rimraf dist/node_modules/vscode-dev-containers", - "definitions-copy": "copyfiles \"node_modules/vscode-dev-containers/container-features/{devcontainer-features.json,feature-scripts.env,fish-debian.sh,homebrew-debian.sh,install.sh}\" dist", + "lint": "eslint --max-warnings 0 ./src", "npm-pack": "npm pack", "clean": "npm-run-all clean-dist clean-built", "clean-dist": "rimraf dist", "clean-built": "rimraf built", "test": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/*.test.ts", - "test-matrix": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit", + "test-matrix": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit --retries 1", "test-container-features": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-features/*.test.ts", + "test-container-features-cli": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-features/featuresCLICommands.test.ts", "test-container-templates": "env TS_NODE_PROJECT=src/test/tsconfig.json mocha -r ts-node/register --exit src/test/container-templates/*.test.ts" }, "files": [ @@ -52,62 +50,60 @@ "ThirdPartyNotices.txt", "devcontainer.js", "dist/spec-node/devContainersSpecCLI.js", - "dist/node_modules/vscode-dev-containers", "package.json", "scripts/updateUID.Dockerfile" ], "devDependencies": { - "@types/chai": "^4.3.4", - "@types/chalk": "^2.2.0", - "@types/follow-redirects": "^1.14.1", - "@types/js-yaml": "^4.0.5", - "@types/mocha": "^10.0.1", - "@types/ncp": "^2.0.5", - "@types/node": "^18.15.3", - "@types/pull-stream": "^3.6.2", - "@types/recursive-readdir": "^2.2.1", - "@types/semver": "^7.3.13", - "@types/shell-quote": "^1.7.1", - "@types/tar": "^6.1.4", - "@types/text-table": "^0.2.2", - "@types/yargs": "^17.0.22", - "@typescript-eslint/eslint-plugin": "^5.55.0", - "@typescript-eslint/experimental-utils": "^5.55.0", - "@typescript-eslint/parser": "^5.55.0", - "chai": "^4.3.7", + "@stylistic/eslint-plugin": "^5.10.0", + "@types/chai": "^4.3.20", + "@types/follow-redirects": "^1.14.4", + "@types/js-yaml": "^4.0.9", + "@types/mocha": "^10.0.10", + "@types/ncp": "^2.0.8", + "@types/node": "^20.19.37", + "@types/pull-stream": "^3.6.7", + "@types/recursive-readdir": "^2.2.4", + "@types/semver": "^7.7.1", + "@types/shell-quote": "^1.7.5", + "@types/text-table": "^0.2.5", + "@types/yargs": "^17.0.35", + "@typescript-eslint/eslint-plugin": "^8.56.1", + "@typescript-eslint/parser": "^8.56.1", + "chai": "^4.5.0", "copyfiles": "^2.4.1", - "esbuild": "^0.17.12", - "eslint": "^8.36.0", + "esbuild": "^0.27.3", + "eslint": "^10.0.2", "event-stream": "^4.0.1", - "gulp-eslint": "^6.0.0", - "gulp-filter": "^7.0.0", - "mocha": "^10.2.0", + "minimatch": "^10.2.4", + "mocha": "^11.7.5", "npm-run-all": "^4.1.5", - "p-all": "^4.0.0", - "rimraf": "^4.4.0", - "ts-node": "^10.9.1", - "typescript": "^5.0.2", + "p-all": "^5.0.1", + "rimraf": "^6.1.3", + "ts-node": "^10.9.2", + "typescript": "^5.9.3", "typescript-formatter": "^7.2.2", - "vinyl": "^3.0.0", - "vinyl-fs": "^3.0.3" + "vinyl": "^3.0.1", + "vinyl-fs": "^4.0.2" }, "dependencies": { - "chalk": "^5.2.0", - "follow-redirects": "^1.15.2", - "js-yaml": "^4.1.0", - "jsonc-parser": "^3.2.0", + "chalk": "^5.6.2", + "follow-redirects": "^1.15.11", + "js-yaml": "^4.1.1", + "jsonc-parser": "^3.3.1", "ncp": "^2.0.0", - "node-pty": "^0.10.1", - "proxy-agent": "^6.3.0", + "node-pty": "~1.0.0", + "proxy-agent": "^6.5.0", "pull-stream": "^3.7.0", "recursive-readdir": "^2.2.3", - "semver": "^7.3.8", - "shell-quote": "^1.8.0", + "semver": "^7.7.4", + "shell-quote": "^1.8.3", "stream-to-pull-stream": "^1.7.3", - "tar": "^6.1.13", + "tar": "^7.5.10", "text-table": "^0.2.0", - "vscode-dev-containers": "https://github.com/microsoft/vscode-dev-containers/releases/download/v0.245.2/vscode-dev-containers-0.245.2.tgz", - "vscode-uri": "^3.0.7", - "yargs": "~17.7.1" + "vscode-uri": "^3.1.0", + "yargs": "~17.7.2" + }, + "resolutions": { + "serialize-javascript": "^7.0.5" } } diff --git a/scripts/install.sh b/scripts/install.sh new file mode 100755 index 000000000..e44a36712 --- /dev/null +++ b/scripts/install.sh @@ -0,0 +1,672 @@ +#!/bin/sh +# install.sh - Install @devcontainers/cli with bundled Node.js +# +# Usage: +# curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh +# wget -qO- https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh +# +# Options: +# --prefix DIR Installation directory (default: ~/.devcontainers) +# --version VER Dev Containers CLI version to install (default: latest) +# --node-version VER Node.js major version (default: 20) +# --update Update existing installation to latest versions +# --uninstall Remove the installation +# --help Show this help message +# +# Environment: +# DEVCONTAINERS_INSTALL_DIR Override default installation directory + +set -e + +# Default configuration +INSTALL_PREFIX="${DEVCONTAINERS_INSTALL_DIR:-$HOME/.devcontainers}" +CLI_VERSION="latest" +NODE_MAJOR_VERSION="20" +UPDATE_MODE=false +UNINSTALL_MODE=false + +# Terminal colors (disabled if not a tty) +setup_colors() { + if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + RED='\033[0;31m' + GREEN='\033[0;32m' + YELLOW='\033[0;33m' + BLUE='\033[0;34m' + BOLD='\033[1m' + RESET='\033[0m' + else + RED='' + GREEN='' + YELLOW='' + BLUE='' + BOLD='' + RESET='' + fi +} + +say() { + printf '%b\n' "${GREEN}>${RESET} $1" +} + +warn() { + printf '%b\n' "${YELLOW}warning${RESET}: $1" >&2 +} + +error() { + printf '%b\n' "${RED}error${RESET}: $1" >&2 +} + +# Print usage information +usage() { + cat << 'EOF' +Install the Dev Containers CLI with bundled Node.js + +Usage: + curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh + sh install.sh [OPTIONS] + +Options: + --prefix DIR Installation directory (default: ~/.devcontainers) + --version VER Dev Containers CLI version to install (default: latest) + --node-version VER Node.js major version (default: 20) + --update Update existing installation to latest versions + --uninstall Remove the installation + --help Show this help message + +Environment: + DEVCONTAINERS_INSTALL_DIR Override default installation directory + +Examples: + # Install latest version + curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh + + # Install specific version + sh install.sh --version 0.82.0 + + # Install to custom directory + sh install.sh --prefix ~/.local/devcontainers + + # Update existing installation + sh install.sh --update + + # Uninstall + sh install.sh --uninstall + +After installation, add to your shell profile: + export PATH="$HOME/.devcontainers/bin:$PATH" +EOF +} + +# Parse command-line arguments +parse_args() { + while [ $# -gt 0 ]; do + case "$1" in + --prefix) + INSTALL_PREFIX="$2" + shift 2 + ;; + --prefix=*) + INSTALL_PREFIX="${1#*=}" + shift + ;; + --version) + CLI_VERSION="$2" + shift 2 + ;; + --version=*) + CLI_VERSION="${1#*=}" + shift + ;; + --node-version) + NODE_MAJOR_VERSION="$2" + shift 2 + ;; + --node-version=*) + NODE_MAJOR_VERSION="${1#*=}" + shift + ;; + --update) + UPDATE_MODE=true + shift + ;; + --uninstall) + UNINSTALL_MODE=true + shift + ;; + --help|-h) + usage + exit 0 + ;; + *) + error "Unknown option: $1" + usage + exit 1 + ;; + esac + done +} + +# Detect platform (OS and architecture) +detect_platform() { + # OS detection + case "$(uname -s)" in + Linux*) + PLATFORM="linux" + ;; + Darwin*) + PLATFORM="darwin" + ;; + CYGWIN*|MINGW*|MSYS*) + error "Windows is not supported by this installer." + error "Please use WSL (Windows Subsystem for Linux) or install via npm:" + error " npm install -g @devcontainers/cli" + exit 1 + ;; + *) + error "Unsupported operating system: $(uname -s)" + exit 1 + ;; + esac + + # Architecture detection + case "$(uname -m)" in + x86_64|amd64) + ARCH="x64" + ;; + aarch64|arm64) + ARCH="arm64" + ;; + armv7l|armv6l) + error "32-bit ARM is not supported." + exit 1 + ;; + *) + error "Unsupported architecture: $(uname -m)" + exit 1 + ;; + esac + + # macOS: Detect if running under Rosetta 2 and prefer native arm64 + if [ "$PLATFORM" = "darwin" ] && [ "$ARCH" = "x64" ]; then + if sysctl -n sysctl.proc_translated 2>/dev/null | grep -q 1; then + say "Detected Rosetta 2 translation, using native arm64 binary" + ARCH="arm64" + fi + fi +} + +# Check for required tools +check_prerequisites() { + # Check for curl or wget + if command -v curl >/dev/null 2>&1; then + DOWNLOADER="curl" + elif command -v wget >/dev/null 2>&1; then + DOWNLOADER="wget" + else + error "Either 'curl' or 'wget' is required but neither was found." + exit 1 + fi + + # Check for tar + if ! command -v tar >/dev/null 2>&1; then + error "'tar' is required but not found." + exit 1 + fi + + # Check if we can write to the install directory + if [ -e "$INSTALL_PREFIX" ]; then + if [ ! -d "$INSTALL_PREFIX" ]; then + error "Installation path exists but is not a directory: $INSTALL_PREFIX" + exit 1 + fi + if [ ! -w "$INSTALL_PREFIX" ]; then + error "No write permission for installation directory: $INSTALL_PREFIX" + exit 1 + fi + else + # Check if we can create the directory + PARENT_DIR="$(dirname "$INSTALL_PREFIX")" + if [ ! -w "$PARENT_DIR" ]; then + error "No write permission to create installation directory: $INSTALL_PREFIX" + exit 1 + fi + fi +} + +# Download a file using curl or wget +download() { + url="$1" + output="$2" + + if [ "$DOWNLOADER" = "curl" ]; then + curl -fSL --retry 3 --retry-delay 2 -o "$output" "$url" + else + wget --tries=3 --waitretry=2 -q -O "$output" "$url" + fi +} + +# Fetch content from a URL (for API calls) +fetch() { + url="$1" + + if [ "$DOWNLOADER" = "curl" ]; then + curl -fsSL "$url" + else + wget -qO- "$url" + fi +} + +# Resolve "latest" CLI version from npm registry +resolve_cli_version() { + if [ "$CLI_VERSION" = "latest" ]; then + say "Resolving latest Dev Containers CLI version..." + version=$(fetch "https://registry.npmjs.org/@devcontainers/cli/latest" | \ + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | head -1) + if [ -z "$version" ]; then + error "Failed to resolve latest Dev Containers CLI version from npm registry" + exit 1 + fi + CLI_VERSION="$version" + fi + say "Dev Containers CLI version: $CLI_VERSION" +} + +# Resolve full Node.js version from major version +resolve_node_version() { + say "Resolving Node.js v$NODE_MAJOR_VERSION LTS version..." + + # Get the latest version for the major version + index_url="https://nodejs.org/dist/index.json" + version=$(fetch "$index_url" | \ + sed -n 's/.*"version"[[:space:]]*:[[:space:]]*"v\('"$NODE_MAJOR_VERSION"'\.[^"]*\)".*/\1/p' | head -1) + + if [ -z "$version" ]; then + error "Failed to resolve Node.js v$NODE_MAJOR_VERSION version" + exit 1 + fi + + NODE_VERSION="$version" + say "Node.js version: v$NODE_VERSION" +} + +# Get Node.js download URL +get_node_url() { + # Prefer .tar.xz if available, fall back to .tar.gz + echo "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-${PLATFORM}-${ARCH}.tar.xz" +} + +# Get CLI download URL from npm registry +get_cli_url() { + echo "https://registry.npmjs.org/@devcontainers/cli/-/cli-${CLI_VERSION}.tgz" +} + +# Install Node.js +install_node() { + node_dir="$INSTALL_PREFIX/node" + version_dir="$node_dir/v$NODE_VERSION" + + # Check if already installed + if [ -d "$version_dir" ] && [ -x "$version_dir/bin/node" ]; then + say "Node.js v$NODE_VERSION is already installed" + else + say "Downloading Node.js v$NODE_VERSION..." + + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' EXIT + + node_url=$(get_node_url) + tarball="$tmp_dir/node.tar.xz" + + if ! download "$node_url" "$tarball"; then + # Try .tar.gz if .tar.xz failed + node_url="${node_url%.xz}.gz" + tarball="$tmp_dir/node.tar.gz" + say "Trying .tar.gz format..." + download "$node_url" "$tarball" + fi + + say "Extracting Node.js..." + mkdir -p "$node_dir" + + # Extract to temp first, then move + extract_dir="$tmp_dir/extracted" + mkdir -p "$extract_dir" + + case "$tarball" in + *.xz) + # Try xz decompression + if command -v xz >/dev/null 2>&1; then + xz -d -c "$tarball" | tar -xf - -C "$extract_dir" + else + # Some tar implementations support -J for xz + tar -xJf "$tarball" -C "$extract_dir" 2>/dev/null || { + error "xz decompression not available. Please install xz-utils." + exit 1 + } + fi + ;; + *.gz) + tar -xzf "$tarball" -C "$extract_dir" + ;; + esac + + # Move extracted directory to version directory + mv "$extract_dir"/node-v*/* "$extract_dir"/ + rmdir "$extract_dir"/node-v* 2>/dev/null || true + mkdir -p "$version_dir" + mv "$extract_dir"/* "$version_dir"/ + + trap - EXIT + rm -rf "$tmp_dir" + fi + + # Update current symlink + say "Activating Node.js v$NODE_VERSION..." + ln -sfn "v$NODE_VERSION" "$node_dir/current" + + # Save metadata + mkdir -p "$INSTALL_PREFIX/.metadata" + echo "$NODE_VERSION" > "$INSTALL_PREFIX/.metadata/node-version" +} + +# Install CLI +install_cli() { + cli_dir="$INSTALL_PREFIX/cli" + version_dir="$cli_dir/$CLI_VERSION" + + # Check if already installed + if [ -d "$version_dir/package" ] && [ -f "$version_dir/package/devcontainer.js" ]; then + say "Dev Containers CLI v$CLI_VERSION is already installed" + else + say "Downloading Dev Containers CLI v$CLI_VERSION..." + + tmp_dir=$(mktemp -d) + trap 'rm -rf "$tmp_dir"' EXIT + + cli_url=$(get_cli_url) + tarball="$tmp_dir/cli.tgz" + + download "$cli_url" "$tarball" + + say "Extracting Dev Containers CLI..." + mkdir -p "$version_dir" + tar -xzf "$tarball" -C "$version_dir" + + trap - EXIT + rm -rf "$tmp_dir" + fi + + # Update current symlink + say "Activating Dev Containers CLI v$CLI_VERSION..." + ln -sfn "$CLI_VERSION" "$cli_dir/current" + + # Save metadata + mkdir -p "$INSTALL_PREFIX/.metadata" + echo "$CLI_VERSION" > "$INSTALL_PREFIX/.metadata/installed-version" +} + +# Create wrapper script +create_wrapper() { + bin_dir="$INSTALL_PREFIX/bin" + wrapper="$bin_dir/devcontainer" + + say "Creating wrapper script..." + mkdir -p "$bin_dir" + + cat > "$wrapper" << 'WRAPPER_EOF' +#!/bin/sh +# Dev Containers CLI wrapper - generated by install.sh +# https://github.com/devcontainers/cli + +set -e + +# Resolve the installation directory +# Handle both direct execution and symlinked scenarios +if [ -L "$0" ]; then + # Follow symlink + SCRIPT_PATH="$(readlink "$0" 2>/dev/null || readlink -f "$0" 2>/dev/null || echo "$0")" +else + SCRIPT_PATH="$0" +fi + +# Get absolute path to script directory +SCRIPT_DIR="$(cd "$(dirname "$SCRIPT_PATH")" && pwd)" +INSTALL_DIR="$(dirname "$SCRIPT_DIR")" + +# Paths to bundled components +NODE_BIN="$INSTALL_DIR/node/current/bin/node" +CLI_ENTRY="$INSTALL_DIR/cli/current/package/devcontainer.js" + +# Verify Node.js exists +if [ ! -x "$NODE_BIN" ]; then + echo "Error: Node.js not found at $NODE_BIN" >&2 + echo "Installation may be corrupted. Please reinstall:" >&2 + echo " curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh" >&2 + exit 1 +fi + +# Verify CLI exists +if [ ! -f "$CLI_ENTRY" ]; then + echo "Error: Dev Containers CLI not found at $CLI_ENTRY" >&2 + echo "Installation may be corrupted. Please reinstall:" >&2 + echo " curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh" >&2 + exit 1 +fi + +# Execute the CLI with bundled Node.js +exec "$NODE_BIN" "$CLI_ENTRY" "$@" +WRAPPER_EOF + + chmod +x "$wrapper" +} + +# Verify installation +verify_installation() { + say "Verifying installation..." + + node_bin="$INSTALL_PREFIX/node/current/bin/node" + cli_entry="$INSTALL_PREFIX/cli/current/package/devcontainer.js" + wrapper="$INSTALL_PREFIX/bin/devcontainer" + + if [ ! -x "$node_bin" ]; then + error "Node.js binary not found or not executable" + exit 1 + fi + + if [ ! -f "$cli_entry" ]; then + error "Dev Containers CLI entry point not found" + exit 1 + fi + + if [ ! -x "$wrapper" ]; then + error "Wrapper script not found or not executable" + exit 1 + fi + + # Try to get version + version=$("$wrapper" --version 2>/dev/null || true) + if [ -n "$version" ]; then + say "Installed: devcontainer $version" + else + warn "Could not verify Dev Containers CLI version, but files are in place" + fi +} + +# Check for existing installation and warn about conflicts +check_existing() { + # Check for existing devcontainer in PATH + existing=$(command -v devcontainer 2>/dev/null || true) + if [ -n "$existing" ]; then + # Check if it's our installation + case "$existing" in + "$INSTALL_PREFIX"*) + # It's our installation, that's fine + ;; + *) + warn "Found existing devcontainer at: $existing" + warn "After installation, ensure $INSTALL_PREFIX/bin is first in your PATH" + ;; + esac + fi + + # Check for existing installation directory + if [ -d "$INSTALL_PREFIX" ] && [ ! "$UPDATE_MODE" = true ]; then + if [ -f "$INSTALL_PREFIX/.metadata/installed-version" ]; then + current_version=$(cat "$INSTALL_PREFIX/.metadata/installed-version") + say "Found existing installation: v$current_version" + say "Use --update to update, or --uninstall to remove first" + fi + fi +} + +# Update existing installation +do_update() { + if [ ! -d "$INSTALL_PREFIX" ] || [ ! -f "$INSTALL_PREFIX/.metadata/installed-version" ]; then + error "No existing installation found at $INSTALL_PREFIX" + error "Run without --update to perform a fresh installation" + exit 1 + fi + + current_cli=$(cat "$INSTALL_PREFIX/.metadata/installed-version" 2>/dev/null || echo "unknown") + current_node=$(cat "$INSTALL_PREFIX/.metadata/node-version" 2>/dev/null || echo "unknown") + + say "Current installation:" + say " Dev Containers CLI: v$current_cli" + say " Node.js: v$current_node" + + # Resolve latest versions + CLI_VERSION="latest" + resolve_cli_version + resolve_node_version + + # Update components + if [ "$current_cli" = "$CLI_VERSION" ]; then + say "Dev Containers CLI is already up to date" + else + say "Updating Dev Containers CLI: v$current_cli -> v$CLI_VERSION" + install_cli + fi + + if [ "$current_node" = "$NODE_VERSION" ]; then + say "Node.js is already up to date" + else + say "Updating Node.js: v$current_node -> v$NODE_VERSION" + install_node + fi + + # Recreate wrapper in case it changed + create_wrapper + verify_installation +} + +# Uninstall +do_uninstall() { + if [ ! -d "$INSTALL_PREFIX" ]; then + say "Nothing to uninstall at $INSTALL_PREFIX" + exit 0 + fi + + say "Uninstalling from $INSTALL_PREFIX..." + rm -rf "$INSTALL_PREFIX" + say "Uninstallation complete" + say "" + say "Don't forget to remove the PATH entry from your shell profile:" + say " export PATH=\"$INSTALL_PREFIX/bin:\$PATH\"" +} + +# Print post-installation instructions +print_instructions() { + bin_path="$INSTALL_PREFIX/bin" + + echo "" + say "${BOLD}Installation complete!${RESET}" + echo "" + + # Check if already in PATH + case ":$PATH:" in + *":$bin_path:"*) + say "The installation directory is already in your PATH." + say "You can now use: devcontainer --help" + ;; + *) + say "Add the following to your shell profile to use devcontainer:" + echo "" + echo " export PATH=\"$bin_path:\$PATH\"" + echo "" + + # Detect shell and suggest profile file + shell_name=$(basename "${SHELL:-/bin/sh}") + case "$shell_name" in + bash) + if [ -f "$HOME/.bash_profile" ]; then + say "For bash, add to: ~/.bash_profile" + else + say "For bash, add to: ~/.bashrc" + fi + ;; + zsh) + say "For zsh, add to: ~/.zshrc" + ;; + fish) + say "For fish, run:" + echo " fish_add_path $bin_path" + ;; + *) + say "Add to your shell's profile file" + ;; + esac + echo "" + say "Then restart your shell or run:" + echo " export PATH=\"$bin_path:\$PATH\"" + ;; + esac + + echo "" + say "To update:" + echo " curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh -s -- --update" + say "To uninstall:" + echo " curl -fsSL https://raw.githubusercontent.com/devcontainers/cli/main/scripts/install.sh | sh -s -- --uninstall" + say "Or simply: rm -rf $INSTALL_PREFIX" +} + +# Main function +main() { + setup_colors + parse_args "$@" + + echo "" + say "${BOLD}Dev Containers CLI installer${RESET}" + echo "" + + # Handle uninstall + if [ "$UNINSTALL_MODE" = true ]; then + do_uninstall + exit 0 + fi + + detect_platform + say "Platform: $PLATFORM-$ARCH" + say "Install directory: $INSTALL_PREFIX" + + check_prerequisites + check_existing + + # Handle update + if [ "$UPDATE_MODE" = true ]; then + do_update + print_instructions + exit 0 + fi + + # Fresh installation + resolve_cli_version + resolve_node_version + + install_node + install_cli + create_wrapper + verify_installation + print_instructions +} + +main "$@" diff --git a/scripts/install.test.sh b/scripts/install.test.sh new file mode 100755 index 000000000..2a73b883e --- /dev/null +++ b/scripts/install.test.sh @@ -0,0 +1,580 @@ +#!/bin/sh +# install.test.sh - Tests for install.sh +# +# Usage: +# sh scripts/install.test.sh +# +# Can be run in CI or locally. Uses a temp directory for all installs. +# Requires network access to download Node.js and the CLI package. + +set -e + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +INSTALL_SCRIPT="$SCRIPT_DIR/install.sh" + +# ── Test framework ──────────────────────────────────────────────── + +TESTS_RUN=0 +TESTS_PASSED=0 +TESTS_FAILED=0 +FAILED_NAMES="" + +# Colors (disabled in non-tty / CI) +if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then + C_RED='\033[0;31m' + C_GREEN='\033[0;32m' + C_YELLOW='\033[0;33m' + C_BOLD='\033[1m' + C_RESET='\033[0m' +else + C_RED='' + C_GREEN='' + C_YELLOW='' + C_BOLD='' + C_RESET='' +fi + +pass() { + TESTS_PASSED=$((TESTS_PASSED + 1)) + printf '%b\n' " ${C_GREEN}✓${C_RESET} $1" +} + +fail() { + TESTS_FAILED=$((TESTS_FAILED + 1)) + FAILED_NAMES="$FAILED_NAMES\n - $1" + printf '%b\n' " ${C_RED}✗${C_RESET} $1" + if [ -n "${2:-}" ]; then + printf ' %s\n' "$2" + fi +} + +assert_eq() { + expected="$1" + actual="$2" + msg="$3" + TESTS_RUN=$((TESTS_RUN + 1)) + if [ "$expected" = "$actual" ]; then + pass "$msg" + else + fail "$msg" "expected: '$expected', got: '$actual'" + fi +} + +assert_contains() { + haystack="$1" + needle="$2" + msg="$3" + TESTS_RUN=$((TESTS_RUN + 1)) + case "$haystack" in + *"$needle"*) + pass "$msg" + ;; + *) + fail "$msg" "expected output to contain: '$needle'" + ;; + esac +} + +assert_file_exists() { + path="$1" + msg="$2" + TESTS_RUN=$((TESTS_RUN + 1)) + if [ -f "$path" ]; then + pass "$msg" + else + fail "$msg" "file not found: $path" + fi +} + +assert_dir_exists() { + path="$1" + msg="$2" + TESTS_RUN=$((TESTS_RUN + 1)) + if [ -d "$path" ]; then + pass "$msg" + else + fail "$msg" "directory not found: $path" + fi +} + +assert_executable() { + path="$1" + msg="$2" + TESTS_RUN=$((TESTS_RUN + 1)) + if [ -x "$path" ]; then + pass "$msg" + else + fail "$msg" "not executable: $path" + fi +} + +assert_symlink() { + path="$1" + msg="$2" + TESTS_RUN=$((TESTS_RUN + 1)) + if [ -L "$path" ]; then + pass "$msg" + else + fail "$msg" "not a symlink: $path" + fi +} + +assert_exit_code() { + expected="$1" + actual="$2" + msg="$3" + TESTS_RUN=$((TESTS_RUN + 1)) + if [ "$expected" = "$actual" ]; then + pass "$msg" + else + fail "$msg" "expected exit code $expected, got $actual" + fi +} + +# ── Setup / teardown ───────────────────────────────────────────── + +TEST_TMPDIR="" +setup() { + TEST_TMPDIR="$(mktemp -d)" +} + +teardown() { + if [ -n "$TEST_TMPDIR" ] && [ -d "$TEST_TMPDIR" ]; then + rm -rf "$TEST_TMPDIR" + fi +} + +# ── Tests: --help ───────────────────────────────────────────────── + +test_help_flag() { + printf '%b\n' "${C_BOLD}--help flag${C_RESET}" + setup + + output=$(sh "$INSTALL_SCRIPT" --help 2>&1) || true + + assert_contains "$output" "Install the Dev Containers CLI" "--help shows description" + assert_contains "$output" "--prefix" "--help shows --prefix option" + assert_contains "$output" "--version" "--help shows --version option" + assert_contains "$output" "--node-version" "--help shows --node-version option" + assert_contains "$output" "--update" "--help shows --update option" + assert_contains "$output" "--uninstall" "--help shows --uninstall option" + assert_contains "$output" "DEVCONTAINERS_INSTALL_DIR" "--help shows env var" + + teardown +} + +test_help_short_flag() { + printf '%b\n' "${C_BOLD}-h flag${C_RESET}" + setup + + output=$(sh "$INSTALL_SCRIPT" -h 2>&1) || true + assert_contains "$output" "Install the Dev Containers CLI" "-h shows help" + + teardown +} + +# ── Tests: argument parsing errors ──────────────────────────────── + +test_unknown_option() { + printf '%b\n' "${C_BOLD}Unknown option${C_RESET}" + setup + + output=$(sh "$INSTALL_SCRIPT" --bogus 2>&1) && rc=0 || rc=$? + assert_exit_code "1" "$rc" "exits with code 1 on unknown option" + assert_contains "$output" "Unknown option" "reports unknown option" + + teardown +} + +# ── Tests: --uninstall on missing dir ───────────────────────────── + +test_uninstall_no_dir() { + printf '%b\n' "${C_BOLD}Uninstall with no existing installation${C_RESET}" + setup + + prefix="$TEST_TMPDIR/nonexistent" + output=$(sh "$INSTALL_SCRIPT" --prefix "$prefix" --uninstall 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "exits 0 when nothing to uninstall" + assert_contains "$output" "Nothing to uninstall" "reports nothing to uninstall" + + teardown +} + +# ── Tests: --update on missing installation ─────────────────────── + +test_update_no_installation() { + printf '%b\n' "${C_BOLD}Update with no existing installation${C_RESET}" + setup + + prefix="$TEST_TMPDIR/empty" + mkdir -p "$prefix" + output=$(sh "$INSTALL_SCRIPT" --prefix "$prefix" --update 2>&1) && rc=0 || rc=$? + assert_exit_code "1" "$rc" "exits 1 when no installation found" + assert_contains "$output" "No existing installation" "reports missing installation" + + teardown +} + +# ── Tests: DEVCONTAINERS_INSTALL_DIR env var ────────────────────── + +test_env_var_prefix() { + printf '%b\n' "${C_BOLD}DEVCONTAINERS_INSTALL_DIR env var${C_RESET}" + setup + + # The env var should be reflected in the help output or the + # install run. We just test that the script picks it up by + # running --uninstall (lightweight) against a nonexistent path. + prefix="$TEST_TMPDIR/from-env" + output=$(DEVCONTAINERS_INSTALL_DIR="$prefix" sh "$INSTALL_SCRIPT" --uninstall 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "exits 0 with env-var prefix" + assert_contains "$output" "Nothing to uninstall" "uses env-var prefix path" + + teardown +} + +# ── Tests: --prefix flag overrides env var ──────────────────────── + +test_prefix_overrides_env() { + printf '%b\n' "${C_BOLD}--prefix overrides DEVCONTAINERS_INSTALL_DIR${C_RESET}" + setup + + env_dir="$TEST_TMPDIR/env-dir" + flag_dir="$TEST_TMPDIR/flag-dir" + output=$(DEVCONTAINERS_INSTALL_DIR="$env_dir" sh "$INSTALL_SCRIPT" --prefix "$flag_dir" --uninstall 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "exits 0" + # The output should reference flag_dir, not env_dir + assert_contains "$output" "Nothing to uninstall" "--prefix is used over env var" + + teardown +} + +# ── Tests: full install with a specific version ─────────────────── + +test_full_install() { + printf '%b\n' "${C_BOLD}Full install (specific CLI version)${C_RESET}" + setup + + prefix="$TEST_TMPDIR/devcontainers" + + # Use a known CLI version to make the test deterministic + cli_version="0.75.0" + + output=$(sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$cli_version" 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "install exits 0" + + # Directory structure + assert_dir_exists "$prefix/bin" "bin/ directory created" + assert_dir_exists "$prefix/node" "node/ directory created" + assert_dir_exists "$prefix/cli" "cli/ directory created" + assert_dir_exists "$prefix/.metadata" ".metadata/ directory created" + + # Wrapper script + assert_file_exists "$prefix/bin/devcontainer" "wrapper script exists" + assert_executable "$prefix/bin/devcontainer" "wrapper script is executable" + + # Symlinks + assert_symlink "$prefix/node/current" "node/current is a symlink" + assert_symlink "$prefix/cli/current" "cli/current is a symlink" + + # Node.js binary + assert_executable "$prefix/node/current/bin/node" "node binary is executable" + + # CLI entry point + assert_file_exists "$prefix/cli/current/package/devcontainer.js" "CLI entry point exists" + + # Metadata + assert_file_exists "$prefix/.metadata/installed-version" "CLI version metadata written" + assert_file_exists "$prefix/.metadata/node-version" "Node version metadata written" + + installed_version=$(cat "$prefix/.metadata/installed-version") + assert_eq "$cli_version" "$installed_version" "metadata records correct CLI version" + + # Wrapper executes successfully + version_output=$("$prefix/bin/devcontainer" --version 2>/dev/null) && wrc=0 || wrc=$? + assert_exit_code "0" "$wrc" "wrapper --version exits 0" + assert_contains "$version_output" "$cli_version" "wrapper reports installed version" + + teardown +} + +# ── Tests: idempotent install ───────────────────────────────────── + +test_idempotent_install() { + printf '%b\n' "${C_BOLD}Idempotent install (run twice)${C_RESET}" + setup + + prefix="$TEST_TMPDIR/devcontainers" + cli_version="0.75.0" + + # First install + sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$cli_version" >/dev/null 2>&1 + + # Second install – same version, should succeed and say "already installed" + output=$(sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$cli_version" 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "second install exits 0" + assert_contains "$output" "already installed" "detects existing Node.js or CLI" + + # Still works + version_output=$("$prefix/bin/devcontainer" --version 2>/dev/null) && wrc=0 || wrc=$? + assert_exit_code "0" "$wrc" "wrapper still works after second install" + + teardown +} + +# ── Tests: uninstall after install ──────────────────────────────── + +test_uninstall_after_install() { + printf '%b\n' "${C_BOLD}Uninstall removes installation${C_RESET}" + setup + + prefix="$TEST_TMPDIR/devcontainers" + cli_version="0.75.0" + + # Install + sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$cli_version" >/dev/null 2>&1 + + # Uninstall + output=$(sh "$INSTALL_SCRIPT" --prefix "$prefix" --uninstall 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "uninstall exits 0" + assert_contains "$output" "Uninstallation complete" "reports completion" + + # Directory should be gone + TESTS_RUN=$((TESTS_RUN + 1)) + if [ ! -d "$prefix" ]; then + pass "install directory removed" + else + fail "install directory removed" "directory still exists: $prefix" + fi + + teardown +} + +# ── Tests: update existing installation ─────────────────────────── + +test_update_existing() { + printf '%b\n' "${C_BOLD}Update existing installation${C_RESET}" + setup + + prefix="$TEST_TMPDIR/devcontainers" + + # Install an older version first + old_version="0.72.0" + sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$old_version" >/dev/null 2>&1 + + installed=$(cat "$prefix/.metadata/installed-version") + assert_eq "$old_version" "$installed" "initial version installed" + + # Update to a slightly newer specific version + new_version="0.75.0" + # Fake update by doing a fresh install with --version (--update resolves "latest") + sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$new_version" >/dev/null 2>&1 + + updated=$(cat "$prefix/.metadata/installed-version") + assert_eq "$new_version" "$updated" "version updated in metadata" + + # Wrapper reports new version + version_output=$("$prefix/bin/devcontainer" --version 2>/dev/null) && wrc=0 || wrc=$? + assert_exit_code "0" "$wrc" "wrapper works after version change" + assert_contains "$version_output" "$new_version" "wrapper reports new version" + + teardown +} + +# ── Tests: wrapper handles missing node gracefully ──────────────── + +test_wrapper_missing_node() { + printf '%b\n' "${C_BOLD}Wrapper error when Node.js missing${C_RESET}" + setup + + prefix="$TEST_TMPDIR/devcontainers" + cli_version="0.75.0" + + # Install + sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$cli_version" >/dev/null 2>&1 + + # Remove node binary + rm -rf "$prefix/node" + + output=$("$prefix/bin/devcontainer" --version 2>&1) && rc=0 || rc=$? + assert_exit_code "1" "$rc" "wrapper exits 1 when node missing" + assert_contains "$output" "Node.js not found" "wrapper reports missing Node.js" + + teardown +} + +# ── Tests: wrapper handles missing CLI gracefully ───────────────── + +test_wrapper_missing_cli() { + printf '%b\n' "${C_BOLD}Wrapper error when CLI missing${C_RESET}" + setup + + prefix="$TEST_TMPDIR/devcontainers" + cli_version="0.75.0" + + # Install + sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$cli_version" >/dev/null 2>&1 + + # Remove CLI + rm -rf "$prefix/cli" + + output=$("$prefix/bin/devcontainer" --version 2>&1) && rc=0 || rc=$? + assert_exit_code "1" "$rc" "wrapper exits 1 when CLI missing" + assert_contains "$output" "Dev Containers CLI not found" "wrapper reports missing Dev Containers CLI" + + teardown +} + +# ── Tests: install via symlinked wrapper ────────────────────────── + +test_wrapper_via_symlink() { + printf '%b\n' "${C_BOLD}Wrapper works when invoked via symlink${C_RESET}" + setup + + prefix="$TEST_TMPDIR/devcontainers" + cli_version="0.75.0" + + sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$cli_version" >/dev/null 2>&1 + + # Create a symlink to the wrapper in a different directory + link_dir="$TEST_TMPDIR/links" + mkdir -p "$link_dir" + ln -s "$prefix/bin/devcontainer" "$link_dir/devcontainer" + + version_output=$("$link_dir/devcontainer" --version 2>/dev/null) && wrc=0 || wrc=$? + assert_exit_code "0" "$wrc" "symlinked wrapper exits 0" + assert_contains "$version_output" "$cli_version" "symlinked wrapper reports version" + + teardown +} + +# ── Tests: install to path with spaces ──────────────────────────── + +test_path_with_spaces() { + printf '%b\n' "${C_BOLD}Install to path with spaces${C_RESET}" + setup + + prefix="$TEST_TMPDIR/my dev containers" + + cli_version="0.75.0" + output=$(sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "$cli_version" 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "install to spaced path exits 0" + + assert_file_exists "$prefix/bin/devcontainer" "wrapper exists in spaced path" + + version_output=$("$prefix/bin/devcontainer" --version 2>/dev/null) && wrc=0 || wrc=$? + assert_exit_code "0" "$wrc" "wrapper works from spaced path" + assert_contains "$version_output" "$cli_version" "reports correct version from spaced path" + + teardown +} + +# ── Tests: non-writable prefix ──────────────────────────────────── + +test_non_writable_prefix() { + printf '%b\n' "${C_BOLD}Error on non-writable prefix${C_RESET}" + setup + + # Skip if running as root (root can write anywhere) + if [ "$(id -u)" = "0" ]; then + TESTS_RUN=$((TESTS_RUN + 1)) + pass "skipped (running as root)" + teardown + return + fi + + prefix="/usr/local/no-permission-test-devcontainers-$$" + output=$(sh "$INSTALL_SCRIPT" --prefix "$prefix" --version "0.75.0" 2>&1) && rc=0 || rc=$? + assert_exit_code "1" "$rc" "exits 1 for non-writable prefix" + assert_contains "$output" "No write permission" "reports permission error" + + teardown +} + +# ── Tests: --prefix= form (equals delimiter) ───────────────────── + +test_prefix_equals_form() { + printf '%b\n' "${C_BOLD}--prefix=DIR form${C_RESET}" + setup + + prefix="$TEST_TMPDIR/eq-form" + # Just verify parsing works – use uninstall for a lightweight check + output=$(sh "$INSTALL_SCRIPT" "--prefix=$prefix" --uninstall 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "--prefix=DIR is accepted" + assert_contains "$output" "Nothing to uninstall" "--prefix=DIR path is used" + + teardown +} + +test_version_equals_form() { + printf '%b\n' "${C_BOLD}--version=VER form${C_RESET}" + setup + + prefix="$TEST_TMPDIR/ver-eq" + output=$(sh "$INSTALL_SCRIPT" "--prefix=$prefix" "--version=0.75.0" 2>&1) && rc=0 || rc=$? + assert_exit_code "0" "$rc" "--version=VER install exits 0" + assert_contains "$output" "0.75.0" "version from --version=VER is used" + + teardown +} + +# ── Run all tests ───────────────────────────────────────────────── + +printf '%b\n' "" +printf '%b\n' "${C_BOLD}install.sh test suite${C_RESET}" +printf '%b\n' "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +printf '%b\n' "" + +# Fast tests (no network required) +test_help_flag +printf '\n' +test_help_short_flag +printf '\n' +test_unknown_option +printf '\n' +test_uninstall_no_dir +printf '\n' +test_update_no_installation +printf '\n' +test_env_var_prefix +printf '\n' +test_prefix_overrides_env +printf '\n' +test_prefix_equals_form +printf '\n' +test_non_writable_prefix +printf '\n' + +# Integration tests (require network, download Node.js + CLI) +printf '%b\n' "${C_YELLOW}Integration tests (requires network)${C_RESET}" +printf '\n' +test_full_install +printf '\n' +test_idempotent_install +printf '\n' +test_uninstall_after_install +printf '\n' +test_update_existing +printf '\n' +test_wrapper_missing_node +printf '\n' +test_wrapper_missing_cli +printf '\n' +test_wrapper_via_symlink +printf '\n' +test_path_with_spaces +printf '\n' +test_version_equals_form +printf '\n' + +# ── Summary ─────────────────────────────────────────────────────── + +printf '%b\n' "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +if [ "$TESTS_FAILED" -eq 0 ]; then + printf '%b\n' "${C_GREEN}${C_BOLD}All $TESTS_RUN tests passed${C_RESET}" +else + printf '%b\n' "${C_RED}${C_BOLD}$TESTS_FAILED of $TESTS_RUN tests failed${C_RESET}" + printf '%b\n' "$FAILED_NAMES" +fi +printf '%b\n' "" + +exit "$TESTS_FAILED" diff --git a/scripts/updateUID.Dockerfile b/scripts/updateUID.Dockerfile index 64ef383e0..9f6c9a854 100644 --- a/scripts/updateUID.Dockerfile +++ b/scripts/updateUID.Dockerfile @@ -18,9 +18,11 @@ RUN eval $(sed -n "s/${REMOTE_USER}:[^:]*:\([^:]*\):\([^:]*\):[^:]*:\([^:]*\).*/ echo "UIDs and GIDs are the same ($NEW_UID:$NEW_GID)."; \ elif [ "$OLD_UID" != "$NEW_UID" -a -n "$EXISTING_USER" ]; then \ echo "User with UID exists ($EXISTING_USER=$NEW_UID)."; \ - elif [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \ - echo "Group with GID exists ($EXISTING_GROUP=$NEW_GID)."; \ else \ + if [ "$OLD_GID" != "$NEW_GID" -a -n "$EXISTING_GROUP" ]; then \ + echo "Group with GID exists ($EXISTING_GROUP=$NEW_GID)."; \ + NEW_GID="$OLD_GID"; \ + fi; \ echo "Updating UID:GID from $OLD_UID:$OLD_GID to $NEW_UID:$NEW_GID."; \ sed -i -e "s/\(${REMOTE_USER}:[^:]*:\)[^:]*:[^:]*/\1${NEW_UID}:${NEW_GID}/" /etc/passwd; \ if [ "$OLD_GID" != "$NEW_GID" ]; then \ diff --git a/src/spec-common/cliHost.ts b/src/spec-common/cliHost.ts index d74597f78..294f8be4a 100644 --- a/src/spec-common/cliHost.ts +++ b/src/spec-common/cliHost.ts @@ -1,5 +1,5 @@ /*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. + * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ diff --git a/src/spec-common/commonUtils.ts b/src/spec-common/commonUtils.ts index 9b42484ed..c74b3c67f 100644 --- a/src/spec-common/commonUtils.ts +++ b/src/spec-common/commonUtils.ts @@ -14,6 +14,7 @@ import { StringDecoder } from 'string_decoder'; import { toErrorText } from './errors'; import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event'; import { isLocalFile } from '../spec-utils/pfs'; +import { escapeRegExCharacters } from '../spec-utils/strings'; import { Log, nullLog } from '../spec-utils/log'; import { ShellServer } from './shellServer'; @@ -40,6 +41,15 @@ export interface ExecFunction { (params: ExecParameters): Promise; } +export type GoOS = { [OS in NodeJS.Platform]: OS extends 'win32' ? 'windows' : OS; }[NodeJS.Platform]; +export type GoARCH = { [ARCH in NodeJS.Architecture]: ARCH extends 'x64' ? 'amd64' : ARCH; }[NodeJS.Architecture]; + +export interface PlatformInfo { + os: GoOS; + arch: GoARCH; + variant?: string; +} + export interface PtyExec { onData: Event; write?(data: string): void; @@ -69,9 +79,6 @@ export function equalPaths(platform: NodeJS.Platform, a: string, b: string) { return a.toLowerCase() === b.toLowerCase(); } -export const tsnode = path.join(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node'); -export const isTsnode = path.basename(process.argv[0]) === 'ts-node' || process.argv.indexOf('ts-node/register') !== -1; - export async function runCommandNoPty(options: { exec: ExecFunction; cmd: string; @@ -581,3 +588,11 @@ export async function getLocalUsername() { } return localUsername; } + +export function getEntPasswdShellCommand(userNameOrId: string) { + const escapedForShell = userNameOrId.replace(/['\\]/g, '\\$&'); + const escapedForRexExp = escapeRegExCharacters(userNameOrId) + .replaceAll('\'', '\\\''); + // Leading space makes sure we don't concatenate to arithmetic expansion (https://tldp.org/LDP/abs/html/dblparens.html). + return ` (command -v getent >/dev/null 2>&1 && getent passwd '${escapedForShell}' || grep -E '^${escapedForRexExp}|^[^:]*:[^:]*:${escapedForRexExp}:' /etc/passwd || true)`; +} diff --git a/src/spec-common/dotfiles.ts b/src/spec-common/dotfiles.ts index 735f95d8d..d055763db 100644 --- a/src/spec-common/dotfiles.ts +++ b/src/spec-common/dotfiles.ts @@ -43,7 +43,7 @@ export async function installDotfiles(params: ResolverParameters, properties: Co await shellServer.exec(`# Clone & install dotfiles via '${installCommand}' ${createFileCommand(markerFile)} || (echo dotfiles marker found && exit 1) || exit 0 command -v git >/dev/null 2>&1 || (echo git not found && exit 1) || exit 0 -[ -e ${targetPath} ] || ${allEnv}git clone ${repository} ${targetPath} || exit $? +[ -e ${targetPath} ] || ${allEnv}git clone --depth 1 ${repository} ${targetPath} || exit $? echo Setting current directory to '${targetPath}' cd ${targetPath} @@ -74,7 +74,7 @@ fi await shellServer.exec(`# Clone & install dotfiles ${createFileCommand(markerFile)} || (echo dotfiles marker found && exit 1) || exit 0 command -v git >/dev/null 2>&1 || (echo git not found && exit 1) || exit 0 -[ -e ${targetPath} ] || ${allEnv}git clone ${repository} ${targetPath} || exit $? +[ -e ${targetPath} ] || ${allEnv}git clone --depth 1 ${repository} ${targetPath} || exit $? echo Setting current directory to ${targetPath} cd ${targetPath} for f in ${installCommands.join(' ')} diff --git a/src/spec-common/errors.ts b/src/spec-common/errors.ts index fe267f713..33ea26eae 100644 --- a/src/spec-common/errors.ts +++ b/src/spec-common/errors.ts @@ -21,6 +21,8 @@ interface ContainerErrorData { start?: boolean; attach?: boolean; fileWithError?: string; + disallowedFeatureId?: string; + didStopContainer?: boolean; learnMoreUrl?: string; } diff --git a/src/spec-common/git.ts b/src/spec-common/git.ts index dcab2036a..4bbf50730 100644 --- a/src/spec-common/git.ts +++ b/src/spec-common/git.ts @@ -31,12 +31,3 @@ export async function findGitRootFolder(cliHost: FileHost | CLIHost, folderPath: return undefined; } } - -export interface GitCloneOptions { - url: string; - tokenEnvVar?: string; - branch?: string; - recurseSubmodules?: boolean; - env?: NodeJS.ProcessEnv; - fullClone?: boolean; -} diff --git a/src/spec-common/injectHeadless.ts b/src/spec-common/injectHeadless.ts index f482a5ea4..e7770d8f0 100644 --- a/src/spec-common/injectHeadless.ts +++ b/src/spec-common/injectHeadless.ts @@ -10,7 +10,7 @@ import * as crypto from 'crypto'; import { ContainerError, toErrorText, toWarningText } from './errors'; import { launch, ShellServer } from './shellServer'; -import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec } from './commonUtils'; +import { ExecFunction, CLIHost, PtyExecFunction, isFile, Exec, PtyExec, getEntPasswdShellCommand } from './commonUtils'; import { Disposable, Event, NodeEventEmitter } from '../spec-utils/event'; import { PackageConfiguration } from '../spec-utils/product'; import { URI } from 'vscode-uri'; @@ -68,6 +68,7 @@ export interface ResolverParameters { skipPersistingCustomizationsFromFeatures: boolean; omitConfigRemotEnvFromMetadata?: boolean; secretsP?: Promise>; + omitSyntaxDirective?: boolean; } export interface LifecycleHook { @@ -243,7 +244,7 @@ export async function getContainerProperties(options: { params.output.write(toWarningText(`User ${containerUser} not found with 'getent passwd'.`)); } const shell = await getUserShell(containerEnv, passwdUser); - const homeFolder = await getHomeFolder(containerEnv, passwdUser); + const homeFolder = await getHomeFolder(shellServer, containerEnv, passwdUser); const userDataFolder = getUserDataFolder(homeFolder, params); let rootShellServerP: Promise | undefined; if (rootShellServer) { @@ -277,8 +278,19 @@ export async function getUser(shellServer: ShellServer) { return (await shellServer.exec('id -un')).stdout.trim(); } -export async function getHomeFolder(containerEnv: NodeJS.ProcessEnv, passwdUser: PasswdUser | undefined) { - return containerEnv.HOME || (passwdUser && passwdUser.home) || '/root'; +export async function getHomeFolder(shellServer: ShellServer, containerEnv: NodeJS.ProcessEnv, passwdUser: PasswdUser | undefined) { + if (containerEnv.HOME) { + if (containerEnv.HOME === passwdUser?.home || passwdUser?.uid === '0') { + return containerEnv.HOME; + } + try { + await shellServer.exec(`[ ! -e '${containerEnv.HOME}' ] || [ -w '${containerEnv.HOME}' ]`); + return containerEnv.HOME; + } catch { + // Exists but not writable. + } + } + return passwdUser?.home || '/root'; } async function getUserShell(containerEnv: NodeJS.ProcessEnv, passwdUser: PasswdUser | undefined) { @@ -286,7 +298,10 @@ async function getUserShell(containerEnv: NodeJS.ProcessEnv, passwdUser: PasswdU } export async function getUserFromPasswdDB(shellServer: ShellServer, userNameOrId: string) { - const { stdout } = await shellServer.exec(`getent passwd ${userNameOrId}`, { logOutput: false }); + const { stdout } = await shellServer.exec(getEntPasswdShellCommand(userNameOrId), { logOutput: false }); + if (!stdout.trim()) { + return undefined; + } return parseUserInPasswdDB(stdout); } @@ -319,18 +334,21 @@ export function getSystemVarFolder(params: ResolverParameters): string { return params.containerSystemDataFolder || '/var/devcontainer'; } -export async function setupInContainer(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonMergedDevContainerConfig, lifecycleCommandOriginMap: LifecycleHooksInstallMap) { +export async function setupInContainer(params: ResolverParameters, containerProperties: ContainerProperties, config: CommonDevContainerConfig, mergedConfig: CommonMergedDevContainerConfig, lifecycleCommandOriginMap: LifecycleHooksInstallMap) { await patchEtcEnvironment(params, containerProperties); await patchEtcProfile(params, containerProperties); const computeRemoteEnv = params.computeExtensionHostEnv || params.lifecycleHook.enabled; const updatedConfig = containerSubstitute(params.cliHost.platform, config.configFilePath, containerProperties.env, config); - const remoteEnv = computeRemoteEnv ? probeRemoteEnv(params, containerProperties, updatedConfig) : Promise.resolve({}); + const updatedMergedConfig = containerSubstitute(params.cliHost.platform, mergedConfig.configFilePath, containerProperties.env, mergedConfig); + const remoteEnv = computeRemoteEnv ? probeRemoteEnv(params, containerProperties, updatedMergedConfig) : Promise.resolve({}); const secretsP = params.secretsP || Promise.resolve({}); if (params.lifecycleHook.enabled) { - await runLifecycleHooks(params, lifecycleCommandOriginMap, containerProperties, updatedConfig, remoteEnv, secretsP, false); + await runLifecycleHooks(params, lifecycleCommandOriginMap, containerProperties, updatedMergedConfig, remoteEnv, secretsP, false); } return { remoteEnv: params.computeExtensionHostEnv ? await remoteEnv : {}, + updatedConfig, + updatedMergedConfig, }; } @@ -480,38 +498,48 @@ async function runLifecycleCommand({ lifecycleHook }: ResolverParameters, contai }, onDidChangeDimensions: lifecycleHook.output.onDidChangeDimensions, }, LogLevel.Info); - try { - const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder; - async function runSingleCommand(postCommand: string | string[], name?: string) { - const progressDetail = typeof postCommand === 'string' ? postCommand : postCommand.join(' '); - infoOutput.event({ - type: 'progress', - name: progressName, - status: 'running', - stepDetail: progressDetail - }); - - // If we have a command name then the command is running in parallel and - // we need to hold output until the command is done so that the output - // doesn't get interleaved with the output of other commands. - const printMode = name ? 'off' : 'continuous'; - const env = { ...(await remoteEnv), ...(await secrets) }; + const remoteCwd = containerProperties.remoteWorkspaceFolder || containerProperties.homeFolder; + async function runSingleCommand(postCommand: string | string[], name?: string) { + const progressDetails = typeof postCommand === 'string' ? postCommand : postCommand.join(' '); + infoOutput.event({ + type: 'progress', + name: progressName, + status: 'running', + stepDetail: progressDetails + }); + // If we have a command name then the command is running in parallel and + // we need to hold output until the command is done so that the output + // doesn't get interleaved with the output of other commands. + const printMode = name ? 'off' : 'continuous'; + const env = { ...(await remoteEnv), ...(await secrets) }; + try { const { cmdOutput } = await runRemoteCommand({ ...lifecycleHook, output: infoOutput }, containerProperties, typeof postCommand === 'string' ? ['/bin/sh', '-c', postCommand] : postCommand, remoteCwd, { remoteEnv: env, pty: true, print: printMode }); // 'name' is set when parallel execution syntax is used. if (name) { - infoOutput.raw(`\x1b[1mRunning ${name} from ${userCommandOrigin}...\x1b[0m\r\n${cmdOutput}\r\n`); + infoOutput.raw(`\x1b[1mRunning ${name} of ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n${cmdOutput}\r\n`); + } + } catch (err) { + if (printMode === 'off' && err?.cmdOutput) { + infoOutput.raw(`\r\n\x1b[1m${err.cmdOutput}\x1b[0m\r\n\r\n`); + } + if (err && (err.code === 130 || err.signal === 2)) { // SIGINT seen on darwin as code === 130, would also make sense as signal === 2. + infoOutput.raw(`\r\n\x1b[1m${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} interrupted.\x1b[0m\r\n\r\n`); + } else { + if (err?.code) { + infoOutput.write(toErrorText(`${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} failed with exit code ${err.code}. Skipping any further user-provided commands.`)); + } + throw new ContainerError({ + description: `${name ? `${name} of ${lifecycleHookName}` : lifecycleHookName} from ${userCommandOrigin} failed.`, + originalError: err + }); } - - infoOutput.event({ - type: 'progress', - name: progressName, - status: 'succeeded', - }); } + } - infoOutput.raw(`\x1b[1mRunning the ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n\r\n`); + infoOutput.raw(`\x1b[1mRunning the ${lifecycleHookName} from ${userCommandOrigin}...\x1b[0m\r\n\r\n`); + try { let commands; if (typeof userCommand === 'string' || Array.isArray(userCommand)) { commands = [runSingleCommand(userCommand)]; @@ -521,24 +549,24 @@ async function runLifecycleCommand({ lifecycleHook }: ResolverParameters, contai return runSingleCommand(command, name); }); } - await Promise.all(commands); + + const results = await Promise.allSettled(commands); // Wait for all commands to finish (successfully or not) before continuing. + const rejection = results.find(p => p.status === 'rejected'); + if (rejection) { + throw (rejection as PromiseRejectedResult).reason; + } + infoOutput.event({ + type: 'progress', + name: progressName, + status: 'succeeded', + }); } catch (err) { infoOutput.event({ type: 'progress', name: progressName, status: 'failed', }); - if (err && (err.code === 130 || err.signal === 2)) { // SIGINT seen on darwin as code === 130, would also make sense as signal === 2. - infoOutput.raw(`\r\n\x1b[1m${lifecycleHookName} interrupted.\x1b[0m\r\n\r\n`); - } else { - if (err?.code) { - infoOutput.write(toErrorText(`${lifecycleHookName} failed with exit code ${err.code}. Skipping any further user-provided commands.`)); - } - throw new ContainerError({ - description: `The ${lifecycleHookName} in the ${userCommandOrigin} failed.`, - originalError: err, - }); - } + throw err; } } } @@ -724,9 +752,9 @@ async function patchEtcEnvironment(params: ResolverParameters, containerProperti if (params.allowSystemConfigChange && containerProperties.launchRootShellServer && !(await isFile(containerProperties.shellServer, markerFile))) { const rootShellServer = await containerProperties.launchRootShellServer(); if (await createFile(rootShellServer, markerFile)) { - await rootShellServer.exec(`cat >> /etc/environment <<'etcEnvrionmentEOF' + await rootShellServer.exec(`cat >> /etc/environment <<'etcEnvironmentEOF' ${Object.keys(containerProperties.env).map(k => `\n${k}="${containerProperties.env[k]}"`).join('')} -etcEnvrionmentEOF +etcEnvironmentEOF `); } } diff --git a/src/spec-configuration/configuration.ts b/src/spec-configuration/configuration.ts index ae0bdabff..5995e7e2b 100644 --- a/src/spec-configuration/configuration.ts +++ b/src/spec-configuration/configuration.ts @@ -119,6 +119,7 @@ export type DevContainerFromDockerfileConfig = { target?: string; args?: Record; cacheFrom?: string | string[]; + options?: string[]; }; } | @@ -129,6 +130,7 @@ export type DevContainerFromDockerfileConfig = { target?: string; args?: Record; cacheFrom?: string | string[]; + options?: string[]; }; } ); diff --git a/src/spec-configuration/configurationCommonUtils.ts b/src/spec-configuration/configurationCommonUtils.ts index 9e45d613b..371125f52 100644 --- a/src/spec-configuration/configurationCommonUtils.ts +++ b/src/spec-configuration/configurationCommonUtils.ts @@ -10,7 +10,7 @@ import { URI } from 'vscode-uri'; import { CLIHostDocuments } from './editableFiles'; import { FileHost } from '../spec-utils/pfs'; -export { FileHost, FileTypeBitmask } from '../spec-utils/pfs'; +export { FileHost } from '../spec-utils/pfs'; const enum CharCode { Slash = 47, diff --git a/src/spec-configuration/containerCollectionsOCI.ts b/src/spec-configuration/containerCollectionsOCI.ts index 16cab0ad8..a2e4ad55f 100644 --- a/src/spec-configuration/containerCollectionsOCI.ts +++ b/src/spec-configuration/containerCollectionsOCI.ts @@ -7,6 +7,7 @@ import * as crypto from 'crypto'; import { Log, LogLevel } from '../spec-utils/log'; import { isLocalFile, mkdirpLocal, readLocalFile, writeLocalFile } from '../spec-utils/pfs'; import { requestEnsureAuthenticated } from './httpOCIRegistry'; +import { GoARCH, GoOS, PlatformInfo } from '../spec-common/commonUtils'; export const DEVCONTAINER_MANIFEST_MEDIATYPE = 'application/vnd.devcontainers'; export const DEVCONTAINER_TAR_LAYER_MEDIATYPE = 'application/vnd.devcontainers.layer.v1+tar'; @@ -91,6 +92,7 @@ interface OCIImageIndexEntry { digest: string; platform: { architecture: string; + variant?: string; os: string; }; } @@ -116,7 +118,7 @@ const regexForVersionOrDigest = /^[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}$/; // https://go.dev/doc/install/source#environment // Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions -export function mapNodeArchitectureToGOARCH(arch: NodeJS.Architecture): string { +export function mapNodeArchitectureToGOARCH(arch: NodeJS.Architecture): GoARCH { switch (arch) { case 'x64': return 'amd64'; @@ -127,7 +129,7 @@ export function mapNodeArchitectureToGOARCH(arch: NodeJS.Architecture): string { // https://go.dev/doc/install/source#environment // Expected by OCI Spec as seen here: https://github.com/opencontainers/image-spec/blob/main/image-index.md#image-index-property-descriptions -export function mapNodeOSToGOOS(os: NodeJS.Platform): string { +export function mapNodeOSToGOOS(os: NodeJS.Platform): GoOS { switch (os) { case 'win32': return 'windows'; @@ -142,6 +144,12 @@ export function getRef(output: Log, input: string): OCIRef | undefined { // Normalize input by downcasing entire string input = input.toLowerCase(); + // Invalid if first character is a dot + if (input.startsWith('.')) { + output.write(`Input '${input}' failed validation. Expected input to not start with '.'`, LogLevel.Error); + return; + } + const indexOfLastColon = input.lastIndexOf(':'); const indexOfLastAtCharacter = input.lastIndexOf('@'); @@ -195,6 +203,11 @@ export function getRef(output: Log, input: string): OCIRef | undefined { const splitOnSlash = resource.split('/'); + if (splitOnSlash[1] === 'devcontainers-contrib') { + output.write(`Redirecting 'devcontainers-contrib' to 'devcontainers-extra'.`); + splitOnSlash[1] = 'devcontainers-extra'; + } + const id = splitOnSlash[splitOnSlash.length - 1]; // Aka 'featureName' - Eg: 'ruby' const owner = splitOnSlash[1]; const registry = splitOnSlash[0]; @@ -266,13 +279,13 @@ export function getCollectionRef(output: Log, registry: string, namespace: strin export async function fetchOCIManifestIfExists(params: CommonParams, ref: OCIRef | OCICollectionRef, manifestDigest?: string): Promise { const { output } = params; - // Simple mechanism to avoid making a DNS request for + // Simple mechanism to avoid making a DNS request for // something that is not a domain name. if (ref.registry.indexOf('.') < 0 && !ref.registry.startsWith('localhost')) { return; } - // TODO: Always use the manifest digest (the canonical digest) + // TODO: Always use the manifest digest (the canonical digest) // instead of the `ref.version` by referencing some lock file (if available). let reference = ref.version; if (manifestDigest) { @@ -332,7 +345,7 @@ export async function getManifest(params: CommonParams, url: string, ref: OCIRef } // https://github.com/opencontainers/image-spec/blob/main/manifest.md -export async function getImageIndexEntryForPlatform(params: CommonParams, url: string, ref: OCIRef | OCICollectionRef, platformInfo: { arch: NodeJS.Architecture; os: NodeJS.Platform }, mimeType?: string): Promise { +export async function getImageIndexEntryForPlatform(params: CommonParams, url: string, ref: OCIRef | OCICollectionRef, platformInfo: PlatformInfo, mimeType?: string): Promise { const { output } = params; const response = await getJsonWithMimeType(params, url, ref, mimeType || 'application/vnd.oci.image.index.v1+json'); if (!response) { @@ -345,15 +358,12 @@ export async function getImageIndexEntryForPlatform(params: CommonParams, url: s return undefined; } - const ociFriendlyPlatformInfo = { - arch: mapNodeArchitectureToGOARCH(platformInfo.arch), - os: mapNodeOSToGOOS(platformInfo.os), - }; - // Find a manifest for the current architecture and OS. return imageIndex.manifests.find(m => { - if (m.platform?.architecture === ociFriendlyPlatformInfo.arch && m.platform?.os === ociFriendlyPlatformInfo.os) { - return m; + if (m.platform?.architecture === platformInfo.arch && m.platform?.os === platformInfo.os) { + if (!platformInfo.variant || m.platform?.variant === platformInfo.variant) { + return m; + } } return undefined; }); @@ -433,9 +443,28 @@ async function getJsonWithMimeType(params: CommonParams, url: string, ref: OC } } -// Lists published versions/tags of a feature/template +// Gets published tags and sorts them by ascending semantic version. +// Omits any tags (eg: 'latest', or major/minor tags '1','1.0') that are not semantic versions. +export async function getVersionsStrictSorted(params: CommonParams, ref: OCIRef): Promise { + const { output } = params; + + const publishedTags = await getPublishedTags(params, ref); + if (!publishedTags) { + return; + } + + const sortedVersions = publishedTags + .filter(f => semver.valid(f)) // Remove all major,minor,latest tags + .sort((a, b) => semver.compare(a, b)); + + output.write(`Published versions (sorted) for '${ref.id}': ${JSON.stringify(sortedVersions, undefined, 2)}`, LogLevel.Trace); + + return sortedVersions; +} + +// Lists published tags of a Feature/Template // Specification: https://github.com/opencontainers/distribution-spec/blob/v1.0.1/spec.md#content-discovery -export async function getPublishedVersions(params: CommonParams, ref: OCIRef, sorted: boolean = false): Promise { +export async function getPublishedTags(params: CommonParams, ref: OCIRef): Promise { const { output } = params; try { const url = `https://${ref.registry}/v2/${ref.namespace}/${ref.id}/tags/list`; @@ -470,25 +499,17 @@ export async function getPublishedVersions(params: CommonParams, ref: OCIRef, so const publishedVersionsResponse: OCITagList = JSON.parse(body); - if (!sorted) { - return publishedVersionsResponse.tags; - } - - // Sort tags in descending order, removing latest. - const hasLatest = publishedVersionsResponse.tags.includes('latest'); - const sortedVersions = publishedVersionsResponse.tags - .filter(f => f !== 'latest') - .sort((a, b) => semver.compareIdentifiers(a, b)); - - - return hasLatest ? ['latest', ...sortedVersions] : sortedVersions; + // Return published tags from the registry as-is, meaning: + // - Not necessarily sorted + // - *Including* major/minor/latest tags + return publishedVersionsResponse.tags; } catch (e) { output.write(`Failed to parse published versions: ${e}`, LogLevel.Error); return; } } -export async function getBlob(params: CommonParams, url: string, ociCacheDir: string, destCachePath: string, ociRef: OCIRef, expectedDigest: string, ignoredFilesDuringExtraction: string[] = [], metadataFile?: string): Promise<{ files: string[]; metadata: {} | undefined } | undefined> { +export async function getBlob(params: CommonParams, url: string, ociCacheDir: string, destCachePath: string, ociRef: OCIRef, expectedDigest: string, omitDuringExtraction: string[] = [], metadataFile?: string): Promise<{ files: string[]; metadata: {} | undefined } | undefined> { // TODO: Parallelize if multiple layers (not likely). // TODO: Seeking might be needed if the size is too large. @@ -527,24 +548,39 @@ export async function getBlob(params: CommonParams, url: string, ociCacheDir: st await mkdirpLocal(destCachePath); await writeLocalFile(tempTarballPath, resBody); + // https://github.com/devcontainers/spec/blob/main/docs/specs/devcontainer-templates.md#the-optionalpaths-property + const directoriesToOmit = omitDuringExtraction.filter(f => f.endsWith('/*')).map(f => f.slice(0, -1)); + const filesToOmit = omitDuringExtraction.filter(f => !f.endsWith('/*')); + + output.write(`omitDuringExtraction: '${omitDuringExtraction.join(', ')}`, LogLevel.Trace); + output.write(`Files to omit: '${filesToOmit.join(', ')}'`, LogLevel.Info); + if (directoriesToOmit.length) { + output.write(`Dirs to omit : '${directoriesToOmit.join(', ')}'`, LogLevel.Info); + } + const files: string[] = []; await tar.x( { file: tempTarballPath, cwd: destCachePath, - filter: (path: string, stat: tar.FileStat) => { - // Skip files that are in the ignore list - if (ignoredFilesDuringExtraction.some(f => path.indexOf(f) !== -1)) { - // Skip. - output.write(`Skipping file '${path}' during blob extraction`, LogLevel.Trace); - return false; + filter: (tPath, stat) => { + const entryType = 'type' in stat ? stat.type : (stat.isFile() ? 'File' : stat.isDirectory() ? 'Directory' : 'Other'); + output.write(`Testing '${tPath}'(${entryType})`, LogLevel.Trace); + const cleanedPath = tPath + .replace(/\\/g, '/') + .replace(/^\.\//, ''); + + if (filesToOmit.includes(cleanedPath) || directoriesToOmit.some(d => cleanedPath.startsWith(d))) { + output.write(` Omitting '${tPath}'`, LogLevel.Trace); + return false; // Skip } - // Keep track of all files extracted, in case the caller is interested. - output.write(`${path} : ${stat.type}`, LogLevel.Trace); - if ((stat.type.toString() === 'File')) { - files.push(path); + + const isFile = 'type' in stat ? stat.type === 'File' : stat.isFile(); + if (isFile) { + files.push(tPath); } - return true; + + return true; // Keep } } ); @@ -560,8 +596,8 @@ export async function getBlob(params: CommonParams, url: string, ociCacheDir: st { file: tempTarballPath, cwd: ociCacheDir, - filter: (path: string, _: tar.FileStat) => { - return path === `./${metadataFile}`; + filter: (tPath, _) => { + return tPath === `./${metadataFile}`; } }); const pathToMetadataFile = path.join(ociCacheDir, metadataFile); @@ -578,4 +614,4 @@ export async function getBlob(params: CommonParams, url: string, ociCacheDir: st output.write(`Error getting blob: ${e}`, LogLevel.Error); return; } -} \ No newline at end of file +} diff --git a/src/spec-configuration/containerCollectionsOCIPush.ts b/src/spec-configuration/containerCollectionsOCIPush.ts index adc267558..24f811663 100644 --- a/src/spec-configuration/containerCollectionsOCIPush.ts +++ b/src/spec-configuration/containerCollectionsOCIPush.ts @@ -11,7 +11,7 @@ import { requestEnsureAuthenticated } from './httpOCIRegistry'; // Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry // Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry // OCI Spec : https://github.com/opencontainers/distribution-spec/blob/main/spec.md#push -export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCIRef, pathToTgz: string, tags: string[], collectionType: string, featureAnnotations = {}): Promise { +export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCIRef, pathToTgz: string, tags: string[], collectionType: string, annotations: { [key: string]: string } = {}): Promise { const { output } = params; output.write(`-- Starting push of ${collectionType} '${ociRef.id}' to '${ociRef.resource}' with tags '${tags.join(', ')}'`); @@ -25,7 +25,7 @@ export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCI const dataBytes = fs.readFileSync(pathToTgz); // Generate Manifest for given feature/template artifact. - const manifest = await generateCompleteManifestForIndividualFeatureOrTemplate(output, dataBytes, pathToTgz, ociRef, collectionType, featureAnnotations); + const manifest = await generateCompleteManifestForIndividualFeatureOrTemplate(output, dataBytes, pathToTgz, ociRef, collectionType, annotations); if (!manifest) { output.write(`Failed to generate manifest for ${ociRef.id}`, LogLevel.Error); return; @@ -44,8 +44,8 @@ export async function pushOCIFeatureOrTemplate(params: CommonParams, ociRef: OCI { name: 'configLayer', digest: manifest.manifestObj.config.digest, - contents: Buffer.alloc(0), size: manifest.manifestObj.config.size, + contents: Buffer.from('{}'), }, { name: 'tgzLayer', @@ -119,7 +119,7 @@ export async function pushCollectionMetadata(params: CommonParams, collectionRef name: 'configLayer', digest: manifest.manifestObj.config.digest, size: manifest.manifestObj.config.size, - contents: Buffer.alloc(0), + contents: Buffer.from('{}'), }, { name: 'collectionLayer', @@ -268,14 +268,13 @@ async function putBlob(params: CommonParams, blobPutLocationUriPath: string, oci // Generate a layer that follows the `application/vnd.devcontainers.layer.v1+tar` mediaType as defined in // Devcontainer Spec (features) : https://containers.dev/implementors/features-distribution/#oci-registry // Devcontainer Spec (templates): https://github.com/devcontainers/spec/blob/main/proposals/devcontainer-templates-distribution.md#oci-registry -async function generateCompleteManifestForIndividualFeatureOrTemplate(output: Log, dataBytes: Buffer, pathToTgz: string, ociRef: OCIRef, collectionType: string, featureAnnotations = {}): Promise { +async function generateCompleteManifestForIndividualFeatureOrTemplate(output: Log, dataBytes: Buffer, pathToTgz: string, ociRef: OCIRef, collectionType: string, annotations: { [key: string]: string } = {}): Promise { const tgzLayer = await calculateDataLayer(output, dataBytes, path.basename(pathToTgz), DEVCONTAINER_TAR_LAYER_MEDIATYPE); if (!tgzLayer) { output.write(`Failed to calculate tgz layer.`, LogLevel.Error); return undefined; } - let annotations: { [key: string]: string } = featureAnnotations; // Specific registries look for certain optional metadata // in the manifest, in this case for UI presentation. if (ociRef.registry === 'ghcr.io') { @@ -382,16 +381,16 @@ async function postUploadSessionId(params: CommonParams, ociRef: OCIRef | OCICol export async function calculateManifestAndContentDigest(output: Log, ociRef: OCIRef | OCICollectionRef, dataLayer: OCILayer, annotations: { [key: string]: string } | undefined): Promise { // A canonical manifest digest is the sha256 hash of the JSON representation of the manifest, without the signature content. // See: https://docs.docker.com/registry/spec/api/#content-digests - // Below is an example of a serialized manifest that should resolve to '9726054859c13377c4c3c3c73d15065de59d0c25d61d5652576c0125f2ea8ed3' - // {"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.devcontainers","digest":"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855","size":0},"layers":[{"mediaType":"application/vnd.devcontainers.layer.v1+tar","digest":"sha256:b2006e7647191f7b47222ae48df049c6e21a4c5a04acfad0c4ef614d819de4c5","size":15872,"annotations":{"org.opencontainers.image.title":"go.tgz"}}]} + // Below is an example of a serialized manifest that should resolve to 'dd328c25cc7382aaf4e9ee10104425d9a2561b47fe238407f6c0f77b3f8409fc' + // {"schemaVersion":2,"mediaType":"application/vnd.oci.image.manifest.v1+json","config":{"mediaType":"application/vnd.devcontainers","digest":"sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a","size":2},"layers":[{"mediaType":"application/vnd.devcontainers.layer.v1+tar","digest":"sha256:0bb92d2da46d760c599d0a41ed88d52521209408b529761417090b62ee16dfd1","size":3584,"annotations":{"org.opencontainers.image.title":"devcontainer-feature-color.tgz"}}],"annotations":{"dev.containers.metadata":"{\"id\":\"color\",\"version\":\"1.0.0\",\"name\":\"A feature to remind you of your favorite color\",\"options\":{\"favorite\":{\"type\":\"string\",\"enum\":[\"red\",\"gold\",\"green\"],\"default\":\"red\",\"description\":\"Choose your favorite color.\"}}}","com.github.package.type":"devcontainer_feature"}} let manifest: OCIManifest = { schemaVersion: 2, mediaType: 'application/vnd.oci.image.manifest.v1+json', config: { mediaType: 'application/vnd.devcontainers', - digest: 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', // A zero byte digest for the devcontainer mediaType. - size: 0 + digest: 'sha256:44136fa355b3678a1146ad16f7e8649e94fb4fc21fe77e8310c060f61caaff8a', // A empty json byte digest for the devcontainer mediaType. + size: 2 }, layers: [ dataLayer diff --git a/src/spec-configuration/containerFeaturesConfiguration.ts b/src/spec-configuration/containerFeaturesConfiguration.ts index 2bc937f51..5957d0896 100644 --- a/src/spec-configuration/containerFeaturesConfiguration.ts +++ b/src/spec-configuration/containerFeaturesConfiguration.ts @@ -10,17 +10,20 @@ import * as tar from 'tar'; import * as crypto from 'crypto'; import * as semver from 'semver'; import * as os from 'os'; +import * as fs from 'fs'; import { DevContainerConfig, DevContainerFeature, VSCodeCustomizations } from './configuration'; import { mkdirpLocal, readLocalFile, rmLocal, writeLocalFile, cpDirectoryLocal, isLocalFile } from '../spec-utils/pfs'; -import { Log, LogLevel } from '../spec-utils/log'; +import { Log, LogLevel, nullLog } from '../spec-utils/log'; import { request } from '../spec-utils/httpRequest'; import { fetchOCIFeature, tryGetOCIFeatureSet, fetchOCIFeatureManifestIfExistsFromUserIdentifier } from './containerFeaturesOCI'; import { uriToFsPath } from './configurationCommonUtils'; -import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getPublishedVersions, getRef } from './containerCollectionsOCI'; -import { Lockfile, readLockfile, writeLockfile } from './lockfile'; +import { CommonParams, ManifestContainer, OCIManifest, OCIRef, getRef, getVersionsStrictSorted } from './containerCollectionsOCI'; +import { Lockfile, generateLockfile, readLockfile, writeLockfile } from './lockfile'; import { computeDependsOnInstallationOrder } from './containerFeaturesOrder'; import { logFeatureAdvisories } from './featureAdvisories'; +import { getEntPasswdShellCommand } from '../spec-common/commonUtils'; +import { ContainerError } from '../spec-common/errors'; // v1 const V1_ASSET_NAME = 'devcontainer-features.tgz'; @@ -115,7 +118,7 @@ export function parseMount(str: string): Mount { .reduce((acc, [key, value]) => ({ ...acc, [(normalizedMountKeys[key] || key)]: value }), {}) as Mount; } -export type SourceInformation = LocalCacheSourceInformation | GithubSourceInformation | DirectTarballSourceInformation | FilePathSourceInformation | OCISourceInformation; +export type SourceInformation = GithubSourceInformation | DirectTarballSourceInformation | FilePathSourceInformation | OCISourceInformation; interface BaseSourceInformation { type: string; @@ -123,10 +126,6 @@ interface BaseSourceInformation { userFeatureIdWithoutVersion?: string; } -export interface LocalCacheSourceInformation extends BaseSourceInformation { - type: 'local-cache'; -} - export interface OCISourceInformation extends BaseSourceInformation { type: 'oci'; featureRef: OCIRef; @@ -159,15 +158,6 @@ export interface GithubSourceInformation extends BaseSourceInformation { userFeatureIdWithoutVersion: string; } -export interface GithubSourceInformationInput { - owner: string; - repo: string; - ref?: string; - sha?: string; - tag?: string; -} - - export interface FeatureSet { features: Feature[]; internalVersion?: string; @@ -195,13 +185,6 @@ export interface GithubApiReleaseAsset { updated_at: string; } -// Supports the `node` layer by collapsing all the individual features into a single `features` array. -// Regardless of their origin. -// Information is lost, but for the node layer we need not care about which set a given feature came from. -export interface CollapsedFeaturesConfig { - allFeatures: Feature[]; -} - export interface ContainerFeatureInternalParams { extensionPath: string; cacheFolder: string; @@ -210,20 +193,8 @@ export interface ContainerFeatureInternalParams { env: NodeJS.ProcessEnv; skipFeatureAutoMapping: boolean; platform: NodeJS.Platform; - experimentalLockfile?: boolean; - experimentalFrozenLockfile?: boolean; -} - -export const multiStageBuildExploration = false; - -const isTsnode = path.basename(process.argv[0]) === 'ts-node' || process.argv.indexOf('ts-node/register') !== -1; - -export function getContainerFeaturesFolder(_extensionPath: string | { distFolder: string }) { - if (isTsnode) { - return path.join(require.resolve('vscode-dev-containers/package.json'), '..', 'container-features'); - } - const distFolder = typeof _extensionPath === 'string' ? path.join(_extensionPath, 'dist') : _extensionPath.distFolder; - return path.join(distFolder, 'node_modules', 'vscode-dev-containers', 'container-features'); + noLockfile?: boolean; + frozenLockfile?: boolean; } // TODO: Move to node layer. @@ -323,8 +294,8 @@ export function getFeatureLayers(featuresConfig: FeaturesConfig, containerUser: const builtinsEnvFile = `${path.posix.join(FEATURES_CONTAINER_TEMP_DEST_FOLDER, 'devcontainer-features.builtin.env')}`; let result = `RUN \\ -echo "_CONTAINER_USER_HOME=$(getent passwd ${containerUser} | cut -d: -f6)" >> ${builtinsEnvFile} && \\ -echo "_REMOTE_USER_HOME=$(getent passwd ${remoteUser} | cut -d: -f6)" >> ${builtinsEnvFile} +echo "_CONTAINER_USER_HOME=$(${getEntPasswdShellCommand(containerUser)} | cut -d: -f6)" >> ${builtinsEnvFile} && \\ +echo "_REMOTE_USER_HOME=$(${getEntPasswdShellCommand(remoteUser)} | cut -d: -f6)" >> ${builtinsEnvFile} `; @@ -466,29 +437,6 @@ async function askGitHubApiForTarballUri(sourceInformation: GithubSourceInformat return undefined; } -export async function loadFeaturesJson(jsonBuffer: Buffer, filePath: string, output: Log): Promise { - if (jsonBuffer.length === 0) { - output.write('Parsed featureSet is empty.', LogLevel.Error); - return undefined; - } - - const featureSet: FeatureSet = jsonc.parse(jsonBuffer.toString()); - if (!featureSet?.features || featureSet.features.length === 0) { - output.write('Parsed featureSet contains no features.', LogLevel.Error); - return undefined; - } - output.write(`Loaded ${filePath}, which declares ${featureSet.features.length} features and ${(!!featureSet.sourceInformation) ? 'contains' : 'does not contain'} explicit source info.`, - LogLevel.Trace); - - return updateFromOldProperties(featureSet); -} - -export async function loadV1FeaturesJsonFromDisk(pathToDirectory: string, output: Log): Promise { - const filePath = path.join(pathToDirectory, V1_DEVCONTAINER_FEATURES_FILE_NAME); - const jsonBuffer: Buffer = await readLocalFile(filePath); - return loadFeaturesJson(jsonBuffer, filePath, output); -} - function updateFromOldProperties(original: T): T { // https://github.com/microsoft/dev-container-spec/issues/1 if (!original.features.find(f => f.extensions || f.settings)) { @@ -521,7 +469,7 @@ function updateFromOldProperties string, additionalFeatures: Record>) { +export async function generateFeaturesConfig(params: ContainerFeatureInternalParams, dstFolder: string, config: DevContainerConfig, additionalFeatures: Record>) { const { output } = params; const workspaceRoot = params.cwd; @@ -532,21 +480,12 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar return undefined; } - // load local cache of features; - // TODO: Update so that cached features are always version 2 - const localFeaturesFolder = getLocalFeaturesFolder(params.extensionPath); - const locallyCachedFeatureSet = await loadV1FeaturesJsonFromDisk(localFeaturesFolder, output); // TODO: Pass dist folder instead to also work with the devcontainer.json support package. - if (!locallyCachedFeatureSet) { - output.write('Failed to load locally cached features', LogLevel.Error); - return undefined; - } - let configPath = config.configFilePath && uriToFsPath(config.configFilePath, params.platform); output.write(`configPath: ${configPath}`, LogLevel.Trace); const ociCacheDir = await prepareOCICache(dstFolder); - const lockfile = await readLockfile(config); + const { lockfile } = params.noLockfile ? { lockfile: undefined } : await readLockfile(config); const processFeature = async (_userFeature: DevContainerFeature) => { return await processFeatureIdentifier(params, configPath, workspaceRoot, _userFeature, lockfile); @@ -566,32 +505,31 @@ export async function generateFeaturesConfig(params: ContainerFeatureInternalPar // Fetch features, stage into the appropriate build folder, and read the feature's devcontainer-feature.json output.write('--- Fetching User Features ----', LogLevel.Trace); - await fetchFeatures(params, featuresConfig, locallyCachedFeatureSet, dstFolder, localFeaturesFolder, ociCacheDir, lockfile); + await fetchFeatures(params, featuresConfig, dstFolder, ociCacheDir, lockfile); await logFeatureAdvisories(params, featuresConfig); - await writeLockfile(params, config, featuresConfig); + if (!params.noLockfile) { + await writeLockfile(params, config, await generateLockfile(featuresConfig, config, additionalFeatures)); + } return featuresConfig; } export async function loadVersionInfo(params: ContainerFeatureInternalParams, config: DevContainerConfig) { - const { output } = params; - - const userFeatures = updateDeprecatedFeaturesIntoOptions(userFeaturesToArray(config), output); + const userFeatures = userFeaturesToArray(config); if (!userFeatures) { return { features: {} }; } - const lockfile = await readLockfile(config); + const { lockfile } = await readLockfile(config); - const features: Record = {}; + const resolved: Record = {}; await Promise.all(userFeatures.map(async userFeature => { const userFeatureId = userFeature.userFeatureId; - const updatedFeatureId = getBackwardCompatibleFeatureId(output, userFeatureId); - const featureRef = getRef(output, updatedFeatureId); + const featureRef = getRef(nullLog, userFeatureId); // Filters out Feature identifiers that cannot be versioned (e.g. local paths, deprecated, etc..) if (featureRef) { - const versions = (await getPublishedVersions(params, featureRef, true)) - ?.reverse(); + const versions = (await getVersionsStrictSorted(params, featureRef)) + ?.reverse() || []; if (versions) { const lockfileVersion = lockfile?.features[userFeatureId]?.version; let wanted = lockfileVersion; @@ -603,22 +541,33 @@ export async function loadVersionInfo(params: ContainerFeatureInternalParams, co wanted = versions.find(version => semver.satisfies(version, tag)); } } else if (featureRef.digest && !wanted) { - const { type, manifest } = await getFeatureIdType(params, updatedFeatureId, undefined); + const { type, manifest } = await getFeatureIdType(params, userFeatureId, undefined); if (type === 'oci' && manifest) { const wantedFeature = await findOCIFeatureMetadata(params, manifest); wanted = wantedFeature?.version; } } - features[userFeatureId] = { + resolved[userFeatureId] = { current: lockfileVersion || wanted, wanted, + wantedMajor: wanted && semver.major(wanted)?.toString(), latest: versions[0], + latestMajor: versions[0] && semver.major(versions[0])?.toString(), }; } } })); - return { features }; + // Reorder Features to match the order in which they were specified in config + return { + features: userFeatures.reduce((acc, userFeature) => { + const r = resolved[userFeature.userFeatureId]; + if (r) { + acc[userFeature.userFeatureId] = r; + } + return acc; + }, {} as Record) + }; } async function findOCIFeatureMetadata(params: ContainerFeatureInternalParams, manifest: ManifestContainer) { @@ -749,9 +698,14 @@ export async function getFeatureIdType(params: CommonParams, userFeatureId: stri // (1) A feature backed by a GitHub Release // Syntax: //[@version] - // DEPRECATED: This is a legacy feature-set ID + // Legacy feature-set ID if (!userFeatureId.includes('/') && !userFeatureId.includes('\\')) { - return { type: 'local-cache', manifest: undefined }; + const errorMessage = `Legacy feature '${userFeatureId}' not supported. Please check https://containers.dev/features for replacements. +If you were hoping to use local Features, remember to prepend your Feature name with "./". Please check https://containers.dev/implementors/features-distribution/#addendum-locally-referenced for more information.`; + output.write(errorMessage, LogLevel.Error); + throw new ContainerError({ + description: errorMessage + }); } // Direct tarball reference @@ -788,9 +742,6 @@ export function getBackwardCompatibleFeatureId(output: Log, id: string) { deprecatedFeaturesIntoOptions.set('maven', 'java'); deprecatedFeaturesIntoOptions.set('jupyterlab', 'python'); - // TODO: add warning logs once we have context on the new location for these Features. - // const deprecatedFeatures = ['fish', 'homebrew']; - const newFeaturePath = 'ghcr.io/devcontainers/features'; // Note: Pin the versionBackwardComp to '1' to avoid breaking changes. const versionBackwardComp = '1'; @@ -831,29 +782,6 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: const { type, manifest } = await getFeatureIdType(params, userFeature.userFeatureId, lockfile); - // cached feature - // Resolves deprecated features (fish, maven, gradle, homebrew, jupyterlab) - if (type === 'local-cache') { - output.write(`Cached feature found.`); - - let feat: Feature = { - id: userFeature.userFeatureId, - name: userFeature.userFeatureId, - value: userFeature.options, - included: true, - }; - - let newFeaturesSet: FeatureSet = { - sourceInformation: { - type: 'local-cache', - userFeatureId: originalUserFeatureId - }, - features: [feat], - }; - - return newFeaturesSet; - } - // remote tar file if (type === 'direct-tarball') { output.write(`Remote tar file found.`); @@ -1027,7 +955,7 @@ export async function processFeatureIdentifier(params: CommonParams, configPath: // throw new Error(`Unsupported feature source type: ${type}`); } -async function fetchFeatures(params: { extensionPath: string; cwd: string; output: Log; env: NodeJS.ProcessEnv }, featuresConfig: FeaturesConfig, localFeatures: FeatureSet, dstFolder: string, localFeaturesFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) { +async function fetchFeatures(params: { extensionPath: string; cwd: string; output: Log; env: NodeJS.ProcessEnv }, featuresConfig: FeaturesConfig, dstFolder: string, ociCacheDir: string, lockfile: Lockfile | undefined) { const featureSets = featuresConfig.featureSets; for (let idx = 0; idx < featureSets.length; idx++) { // Index represents the previously computed installation order. const featureSet = featureSets[idx]; @@ -1036,10 +964,6 @@ async function fetchFeatures(params: { extensionPath: string; cwd: string; outpu continue; } - if (!localFeatures) { - continue; - } - const { output } = params; const feature = featureSet.features[0]; @@ -1077,18 +1001,6 @@ async function fetchFeatures(params: { extensionPath: string; cwd: string; outpu continue; } - if (sourceInfoType === 'local-cache') { - // create copy of the local features to set the environment variables for them. - await mkdirpLocal(featCachePath); - await cpDirectoryLocal(localFeaturesFolder, featCachePath); - - if (!(await applyFeatureConfigToFeature(output, featureSet, feature, featCachePath, undefined))) { - const err = `Failed to parse feature '${featureDebugId}'. Please check your devcontainer.json 'features' attribute.`; - throw new Error(err); - } - continue; - } - if (sourceInfoType === 'file-path') { output.write(`Detected local file path`, LogLevel.Trace); await mkdirpLocal(featCachePath); @@ -1188,7 +1100,7 @@ export async function fetchContentsAtTarballUri(params: { output: Log; env: Node } // Filter what gets emitted from the tar.extract(). - const filter = (file: string, _: tar.FileStat) => { + const filter = (file: string, _: fs.Stats | tar.ReadEntry) => { // Don't include .dotfiles or the archive itself. if (file.startsWith('./.') || file === `./${V1_ASSET_NAME}` || file === './.') { return false; @@ -1219,7 +1131,7 @@ export async function fetchContentsAtTarballUri(params: { output: Log; env: Node { file: tempTarballPath, cwd: featCachePath, - filter: (path: string, _: tar.FileStat) => { + filter: (path, _) => { return path === `./${metadataFile}`; } }); diff --git a/src/spec-configuration/containerTemplatesConfiguration.ts b/src/spec-configuration/containerTemplatesConfiguration.ts index 3f64507a8..2020bac65 100644 --- a/src/spec-configuration/containerTemplatesConfiguration.ts +++ b/src/spec-configuration/containerTemplatesConfiguration.ts @@ -5,13 +5,15 @@ export interface Template { description?: string; documentationURL?: string; licenseURL?: string; - type?: string; - fileCount?: number; + type?: string; // Added programatically during packaging + fileCount?: number; // Added programatically during packaging featureIds?: string[]; options?: Record; platforms?: string[]; publisher?: string; keywords?: string[]; + optionalPaths?: string[]; + files: string[]; // Added programatically during packaging } export type TemplateOption = { diff --git a/src/spec-configuration/containerTemplatesOCI.ts b/src/spec-configuration/containerTemplatesOCI.ts index acd40a702..4c5c27755 100644 --- a/src/spec-configuration/containerTemplatesOCI.ts +++ b/src/spec-configuration/containerTemplatesOCI.ts @@ -19,12 +19,13 @@ export interface SelectedTemplate { id: string; options: TemplateOptions; features: TemplateFeatureOption[]; + omitPaths: string[]; } export async function fetchTemplate(params: CommonParams, selectedTemplate: SelectedTemplate, templateDestPath: string, userProvidedTmpDir?: string): Promise { const { output } = params; - let { id: userSelectedId, options: userSelectedOptions } = selectedTemplate; + let { id: userSelectedId, options: userSelectedOptions, omitPaths } = selectedTemplate; const templateRef = getRef(output, userSelectedId); if (!templateRef) { output.write(`Failed to parse template ref for ${userSelectedId}`, LogLevel.Error); @@ -46,10 +47,11 @@ export async function fetchTemplate(params: CommonParams, selectedTemplate: Sele output.write(`blob url: ${blobUrl}`, LogLevel.Trace); const tmpDir = userProvidedTmpDir || path.join(os.tmpdir(), 'vsch-template-temp', `${Date.now()}`); - const blobResult = await getBlob(params, blobUrl, tmpDir, templateDestPath, templateRef, blobDigest, ['devcontainer-template.json', 'README.md', 'NOTES.md'], 'devcontainer-template.json'); + const blobResult = await getBlob(params, blobUrl, tmpDir, templateDestPath, templateRef, blobDigest, [...omitPaths, 'devcontainer-template.json', 'README.md', 'NOTES.md'], 'devcontainer-template.json'); if (!blobResult) { - throw new Error(`Failed to download package for ${templateRef.resource}`); + output.write(`Failed to download package for ${templateRef.resource}`, LogLevel.Error); + return; } const { files, metadata } = blobResult; @@ -161,7 +163,7 @@ async function addFeatures(output: Log, newFeatures: TemplateFeatureOption[], co const propertyPath = ['features', newFeature.id]; edits = edits.concat( - jsonc.modify(updatedText, propertyPath, newFeature.options, { formattingOptions: {} } + jsonc.modify(updatedText, propertyPath, newFeature.options ?? {}, { formattingOptions: {} } )); updatedText = jsonc.applyEdits(updatedText, edits); diff --git a/src/spec-configuration/editableFiles.ts b/src/spec-configuration/editableFiles.ts index eb8d303b5..d233a15fc 100644 --- a/src/spec-configuration/editableFiles.ts +++ b/src/spec-configuration/editableFiles.ts @@ -161,21 +161,3 @@ export function createDocuments(fileHost: FileHost, shellServer?: ShellServer): export interface ShellServer { exec(cmd: string, options?: { logOutput?: boolean; stdin?: Buffer }): Promise<{ stdout: string; stderr: string }>; } - -const editQueues = new Map Promise)[]>(); - -export async function runEdit(uri: URI, edit: () => Promise) { - const uriString = uri.toString(); - let queue = editQueues.get(uriString); - if (!queue) { - editQueues.set(uriString, queue = []); - } - queue.push(edit); - if (queue.length === 1) { - while (queue.length) { - await queue[0](); - queue.shift(); - } - editQueues.delete(uriString); - } -} diff --git a/src/spec-configuration/httpOCIRegistry.ts b/src/spec-configuration/httpOCIRegistry.ts index 3fe350eef..2bebba82e 100644 --- a/src/spec-configuration/httpOCIRegistry.ts +++ b/src/spec-configuration/httpOCIRegistry.ts @@ -37,7 +37,7 @@ const scopeRegex = /scope="([^"]+)"/; // https://docs.docker.com/registry/spec/auth/token/#how-to-authenticate export async function requestEnsureAuthenticated(params: CommonParams, httpOptions: { type: string; url: string; headers: HEADERS; data?: Buffer }, ociRef: OCIRef | OCICollectionRef) { - // If needed, Initialize the Authorization header cache. + // If needed, Initialize the Authorization header cache. if (!params.cachedAuthHeader) { params.cachedAuthHeader = {}; } @@ -54,14 +54,14 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio const initialAttemptRes = await requestResolveHeaders(httpOptions, output); - // For anything except a 401 response - // Simply return the original response to the caller. - if (initialAttemptRes.statusCode !== 401) { + // For anything except a 401 (invalid/no token) or 403 (insufficient scope) + // response simply return the original response to the caller. + if (initialAttemptRes.statusCode !== 401 && initialAttemptRes.statusCode !== 403) { output.write(`[httpOci] ${initialAttemptRes.statusCode} (${maybeCachedAuthHeader ? 'Cached' : 'NoAuth'}): ${httpOptions.url}`, LogLevel.Trace); return initialAttemptRes; } - // -- 'responseAttempt' status code was 401 at this point. + // -- 'responseAttempt' status code was 401 or 403 at this point. // Attempt to authenticate via WWW-Authenticate Header. const wwwAuthenticate = initialAttemptRes.resHeaders['WWW-Authenticate'] || initialAttemptRes.resHeaders['www-authenticate']; @@ -70,9 +70,10 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio return; } - switch (wwwAuthenticate.split(' ')[0]) { + const authenticationMethod = wwwAuthenticate.split(' ')[0]; + switch (authenticationMethod.toLowerCase()) { // Basic realm="localhost" - case 'Basic': + case 'basic': output.write(`[httpOci] Attempting to authenticate via 'Basic' auth.`, LogLevel.Trace); @@ -87,7 +88,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio break; // Bearer realm="https://auth.docker.io/token",service="registry.docker.io",scope="repository:samalba/my-app:pull,push" - case 'Bearer': + case 'bearer': output.write(`[httpOci] Attempting to authenticate via 'Bearer' auth.`, LogLevel.Trace); @@ -95,7 +96,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio const serviceGroup = serviceRegex.exec(wwwAuthenticate); const scopeGroup = scopeRegex.exec(wwwAuthenticate); - if (!realmGroup || !serviceGroup || !scopeGroup) { + if (!realmGroup || !serviceGroup) { output.write(`[httpOci] WWW-Authenticate header is not in expected format. Got: ${wwwAuthenticate}`, LogLevel.Trace); return; } @@ -103,7 +104,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio const wwwAuthenticateData = { realm: realmGroup[1], service: serviceGroup[1], - scope: scopeGroup[1], + scope: scopeGroup ? scopeGroup[1] : '', }; const bearerToken = await fetchRegistryBearerToken(params, ociRef, wwwAuthenticateData); @@ -116,7 +117,7 @@ export async function requestEnsureAuthenticated(params: CommonParams, httpOptio break; default: - output.write(`[httpOci] ERR: Unsupported authentication mode '${wwwAuthenticate.split(' ')[0]}'`, LogLevel.Error); + output.write(`[httpOci] ERR: Unsupported authentication mode '${authenticationMethod}'`, LogLevel.Error); return; } diff --git a/src/spec-configuration/lockfile.ts b/src/spec-configuration/lockfile.ts index bf32b9dd8..e41ae2025 100644 --- a/src/spec-configuration/lockfile.ts +++ b/src/spec-configuration/lockfile.ts @@ -13,22 +13,15 @@ export interface Lockfile { features: Record; } -export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, featuresConfig: FeaturesConfig) { - const lockfilePath = getLockfilePath(config); - const oldLockfileContent = await readLocalFile(lockfilePath) - .catch(err => { - if (err?.code !== 'ENOENT') { - throw err; - } - }); - - if (!oldLockfileContent && !params.experimentalLockfile && !params.experimentalFrozenLockfile) { - return; - } - - const lockfile: Lockfile = featuresConfig.featureSets +export async function generateLockfile(featuresConfig: FeaturesConfig, config?: DevContainerConfig, additionalFeatures?: Record>): Promise { + // Features supplied only via `--additional-features` (i.e., not present in `config.features`) + // should not be written to the lockfile. + const configFeatureKeys = new Set(Object.keys(config?.features || {})); + const excludeUserFeatureIds = new Set(Object.keys(additionalFeatures || {}).filter(key => !configFeatureKeys.has(key))); + return featuresConfig.featureSets .map(f => [f, f.sourceInformation] as const) .filter((tup): tup is [FeatureSet, OCISourceInformation | DirectTarballSourceInformation] => ['oci', 'direct-tarball'].indexOf(tup[1].type) !== -1) + .filter(([, source]) => !excludeUserFeatureIds.has(source.userFeatureId)) .map(([set, source]) => { const dependsOn = Object.keys(set.features[0].dependsOn || {}); return { @@ -50,26 +43,58 @@ export async function writeLockfile(params: ContainerFeatureInternalParams, conf }, { features: {} as Record, }); +} + +export async function writeLockfile(params: ContainerFeatureInternalParams, config: DevContainerConfig, lockfile: Lockfile): Promise { + if (params.noLockfile) { + return; + } + + const lockfilePath = getLockfilePath(config); + const oldLockfileContent = await readLocalFile(lockfilePath) + .catch(err => { + if (err?.code !== 'ENOENT') { + throw err; + } + }); - const newLockfileContent = Buffer.from(JSON.stringify(lockfile, null, 2)); - if (params.experimentalFrozenLockfile && !oldLockfileContent) { + // Trailing newline per POSIX convention + const newLockfileContentString = JSON.stringify(lockfile, null, 2) + '\n'; + const newLockfileContent = Buffer.from(newLockfileContentString); + if (params.frozenLockfile && !oldLockfileContent) { throw new Error('Lockfile does not exist.'); } - if (!oldLockfileContent || !newLockfileContent.equals(oldLockfileContent)) { - if (params.experimentalFrozenLockfile) { + // Normalize the existing lockfile through JSON.parse -> JSON.stringify to produce + // the same canonical format as newLockfileContentString, so that the string comparison + // below ignores cosmetic differences (indentation, trailing whitespace, etc.). + let oldLockfileNormalized: string | undefined; + if (oldLockfileContent) { + try { + oldLockfileNormalized = JSON.stringify(JSON.parse(oldLockfileContent.toString()), null, 2) + '\n'; + } catch { + // Empty or invalid JSON; treat as needing rewrite. + } + } + if (!oldLockfileNormalized || oldLockfileNormalized !== newLockfileContentString) { + if (params.frozenLockfile) { throw new Error('Lockfile does not match.'); } await writeLocalFile(lockfilePath, newLockfileContent); } + return; } -export async function readLockfile(config: DevContainerConfig): Promise { +export async function readLockfile(config: DevContainerConfig): Promise<{ lockfile?: Lockfile; initLockfile?: boolean }> { try { const content = await readLocalFile(getLockfilePath(config)); - return JSON.parse(content.toString()) as Lockfile; + // If empty file, use as marker to initialize lockfile when build completes. + if (content.toString().trim() === '') { + return { initLockfile: true }; + } + return { lockfile: JSON.parse(content.toString()) as Lockfile }; } catch (err) { if (err?.code === 'ENOENT') { - return undefined; + return {}; } throw err; } diff --git a/src/spec-configuration/typings/zlib-zstd.d.ts b/src/spec-configuration/typings/zlib-zstd.d.ts new file mode 100644 index 000000000..6614b3eab --- /dev/null +++ b/src/spec-configuration/typings/zlib-zstd.d.ts @@ -0,0 +1,6 @@ +// Stub types for Zstd compression classes added in Node.js 23.8.0 +// Required for minizlib's type definitions which reference these types +declare module 'zlib' { + interface ZstdCompress extends NodeJS.ReadWriteStream {} + interface ZstdDecompress extends NodeJS.ReadWriteStream {} +} diff --git a/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts b/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts new file mode 100644 index 000000000..0a866f818 --- /dev/null +++ b/src/spec-node/collectionCommonUtils/generateDocsCommandImpl.ts @@ -0,0 +1,198 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as jsonc from 'jsonc-parser'; +import { Log, LogLevel } from '../../spec-utils/log'; + +const FEATURES_README_TEMPLATE = ` +# #{Name} + +#{Description} + +## Example Usage + +\`\`\`json +"features": { + "#{Registry}/#{Namespace}/#{Id}:#{Version}": {} +} +\`\`\` + +#{OptionsTable} +#{Customizations} +#{Notes} + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._ +`; + +const TEMPLATE_README_TEMPLATE = ` +# #{Name} + +#{Description} + +#{OptionsTable} + +#{Notes} + +--- + +_Note: This file was auto-generated from the [devcontainer-template.json](#{RepoUrl}). Add additional notes to a \`NOTES.md\`._ +`; + +export async function generateFeaturesDocumentation( + basePath: string, + ociRegistry: string, + namespace: string, + gitHubOwner: string, + gitHubRepo: string, + output: Log +) { + await _generateDocumentation(output, basePath, FEATURES_README_TEMPLATE, + 'devcontainer-feature.json', ociRegistry, namespace, gitHubOwner, gitHubRepo); +} + +export async function generateTemplatesDocumentation( + basePath: string, + gitHubOwner: string, + gitHubRepo: string, + output: Log +) { + await _generateDocumentation(output, basePath, TEMPLATE_README_TEMPLATE, + 'devcontainer-template.json', '', '', gitHubOwner, gitHubRepo); +} + +async function _generateDocumentation( + output: Log, + basePath: string, + readmeTemplate: string, + metadataFile: string, + ociRegistry: string = '', + namespace: string = '', + gitHubOwner: string = '', + gitHubRepo: string = '' +) { + const directories = fs.readdirSync(basePath); + + await Promise.all( + directories.map(async (f: string) => { + if (!f.startsWith('.')) { + const readmePath = path.join(basePath, f, 'README.md'); + output.write(`Generating ${readmePath}...`, LogLevel.Info); + + const jsonPath = path.join(basePath, f, metadataFile); + + if (!fs.existsSync(jsonPath)) { + output.write(`(!) Warning: ${metadataFile} not found at path '${jsonPath}'. Skipping...`, LogLevel.Warning); + return; + } + + let parsedJson: any | undefined = undefined; + try { + parsedJson = jsonc.parse(fs.readFileSync(jsonPath, 'utf8')); + } catch (err) { + output.write(`Failed to parse ${jsonPath}: ${err}`, LogLevel.Error); + return; + } + + if (!parsedJson || !parsedJson?.id) { + output.write(`${metadataFile} for '${f}' does not contain an 'id'`, LogLevel.Error); + return; + } + + // Add version + let version = 'latest'; + const parsedVersion: string = parsedJson?.version; + if (parsedVersion) { + // example - 1.0.0 + const splitVersion = parsedVersion.split('.'); + version = splitVersion[0]; + } + + const generateOptionsMarkdown = () => { + const options = parsedJson?.options; + if (!options) { + return ''; + } + + const keys = Object.keys(options); + const contents = keys + .map(k => { + const val = options[k]; + + const desc = val.description || '-'; + const type = val.type || '-'; + const def = val.default !== '' ? val.default : '-'; + + return `| ${k} | ${desc} | ${type} | ${def} |`; + }) + .join('\n'); + + return '## Options\n\n' + '| Options Id | Description | Type | Default Value |\n' + '|-----|-----|-----|-----|\n' + contents; + }; + + const generateNotesMarkdown = () => { + const notesPath = path.join(basePath, f, 'NOTES.md'); + return fs.existsSync(notesPath) ? fs.readFileSync(path.join(notesPath), 'utf8') : ''; + }; + + let urlToConfig = `${metadataFile}`; + const basePathTrimmed = basePath.startsWith('./') ? basePath.substring(2) : basePath; + if (gitHubOwner !== '' && gitHubRepo !== '') { + urlToConfig = `https://github.com/${gitHubOwner}/${gitHubRepo}/blob/main/${basePathTrimmed}/${f}/${metadataFile}`; + } + + let header; + const isDeprecated = parsedJson?.deprecated; + const hasLegacyIds = parsedJson?.legacyIds && parsedJson?.legacyIds.length > 0; + + if (isDeprecated || hasLegacyIds) { + header = '### **IMPORTANT NOTE**\n'; + + if (isDeprecated) { + header += `- **This Feature is deprecated, and will no longer receive any further updates/support.**\n`; + } + + if (hasLegacyIds) { + const formattedLegacyIds = parsedJson.legacyIds.map((legacyId: string) => `'${legacyId}'`); + header += `- **Ids used to publish this Feature in the past - ${formattedLegacyIds.join(', ')}**\n`; + } + } + + let extensions = ''; + if (parsedJson?.customizations?.vscode?.extensions) { + const extensionsList = parsedJson.customizations.vscode.extensions; + if (extensionsList && extensionsList.length > 0) { + extensions = + '\n## Customizations\n\n### VS Code Extensions\n\n' + extensionsList.map((ext: string) => `- \`${ext}\``).join('\n') + '\n'; + } + } + + let newReadme = readmeTemplate + // Templates & Features + .replace('#{Id}', parsedJson.id) + .replace('#{Name}', parsedJson.name ? `${parsedJson.name} (${parsedJson.id})` : `${parsedJson.id}`) + .replace('#{Description}', parsedJson.description ?? '') + .replace('#{OptionsTable}', generateOptionsMarkdown()) + .replace('#{Notes}', generateNotesMarkdown()) + .replace('#{RepoUrl}', urlToConfig) + // Features Only + .replace('#{Registry}', ociRegistry) + .replace('#{Namespace}', namespace) + .replace('#{Version}', version) + .replace('#{Customizations}', extensions); + + if (header) { + newReadme = header + newReadme; + } + + // Remove previous readme + if (fs.existsSync(readmePath)) { + fs.unlinkSync(readmePath); + } + + // Write new readme + fs.writeFileSync(readmePath, newReadme); + } + }) + ); +} diff --git a/src/spec-node/collectionCommonUtils/packageCommandImpl.ts b/src/spec-node/collectionCommonUtils/packageCommandImpl.ts index 2be6ace02..cd54533c3 100644 --- a/src/spec-node/collectionCommonUtils/packageCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/packageCommandImpl.ts @@ -1,4 +1,4 @@ -import tar from 'tar'; +import * as tar from 'tar'; import * as jsonc from 'jsonc-parser'; import * as os from 'os'; import * as recursiveDirReader from 'recursive-readdir'; @@ -9,6 +9,7 @@ import path from 'path'; import { DevContainerConfig, isDockerFileConfig } from '../../spec-configuration/configuration'; import { Template } from '../../spec-configuration/containerTemplatesConfiguration'; import { Feature } from '../../spec-configuration/containerFeaturesConfiguration'; +import { getRef } from '../../spec-configuration/containerCollectionsOCI'; export interface SourceInformation { source: string; @@ -133,9 +134,45 @@ async function addsAdditionalTemplateProps(srcFolder: string, devcontainerTempla return false; } + const fileNames = (await recursiveDirReader.default(srcFolder))?.map((f) => path.relative(srcFolder, f)) ?? []; + templateData.type = type; - templateData.fileCount = (await recursiveDirReader.default(srcFolder)).length; - templateData.featureIds = config.features ? Object.keys(config.features).map((k) => k.split(':')[0]) : []; + templateData.files = fileNames; + templateData.fileCount = fileNames.length; + templateData.featureIds = + config.features + ? Object.keys(config.features) + .map((f) => getRef(output, f)?.resource) + .filter((f) => f !== undefined) as string[] + : []; + + // If the Template is omitting a folder and that folder contains just a single file, + // replace the entry in the metadata with the full file name, + // as that provides a better user experience when tools consume the metadata. + // Eg: If the template is omitting ".github/*" and the Template source contains just a single file + // "workflow.yml", replace ".github/*" with ".github/workflow.yml" + if (templateData.optionalPaths && templateData.optionalPaths?.length) { + const optionalPaths = templateData.optionalPaths; + for (const optPath of optionalPaths) { + // Skip if not a directory + if (!optPath.endsWith('/*') || optPath.length < 3) { + continue; + } + const dirPath = optPath.slice(0, -2); + const dirFiles = fileNames.filter((f) => f.startsWith(dirPath)); + output.write(`Given optionalPath starting with '${dirPath}' has ${dirFiles.length} files`, LogLevel.Trace); + if (dirFiles.length === 1) { + // If that one item is a file and not a directory + const f = dirFiles[0]; + output.write(`Checking if optionalPath '${optPath}' with lone contents '${f}' is a file `, LogLevel.Trace); + const localPath = path.join(srcFolder, f); + if (await isLocalFile(localPath)) { + output.write(`Checked path '${localPath}' on disk is a file. Replacing optionalPaths entry '${optPath}' with '${f}'`, LogLevel.Trace); + templateData.optionalPaths[optionalPaths.indexOf(optPath)] = f; + } + } + } + } await writeLocalFile(devcontainerTemplateJsonPath, JSON.stringify(templateData, null, 4)); diff --git a/src/spec-node/collectionCommonUtils/publishCommandImpl.ts b/src/spec-node/collectionCommonUtils/publishCommandImpl.ts index ad646ca6d..ebdb433e8 100644 --- a/src/spec-node/collectionCommonUtils/publishCommandImpl.ts +++ b/src/spec-node/collectionCommonUtils/publishCommandImpl.ts @@ -1,22 +1,22 @@ import path from 'path'; import * as semver from 'semver'; import { Log, LogLevel } from '../../spec-utils/log'; -import { CommonParams, getPublishedVersions, OCICollectionRef, OCIRef } from '../../spec-configuration/containerCollectionsOCI'; +import { CommonParams, getPublishedTags, OCICollectionRef, OCIRef } from '../../spec-configuration/containerCollectionsOCI'; import { OCICollectionFileName } from './packageCommandImpl'; import { pushCollectionMetadata, pushOCIFeatureOrTemplate } from '../../spec-configuration/containerCollectionsOCIPush'; let semanticVersions: string[] = []; -function updateSemanticVersionsList(publishedVersions: string[], version: string, range: string, publishVersion: string) { +function updateSemanticTagsList(publishedTags: string[], version: string, range: string, publishVersion: string) { // Reference: https://github.com/npm/node-semver#ranges-1 - const publishedMaxVersion = semver.maxSatisfying(publishedVersions, range); + const publishedMaxVersion = semver.maxSatisfying(publishedTags, range); if (publishedMaxVersion === null || semver.compare(version, publishedMaxVersion) === 1) { semanticVersions.push(publishVersion); } return; } -export function getSemanticVersions(version: string, publishedVersions: string[], output: Log) { - if (publishedVersions.includes(version)) { +export function getSemanticTags(version: string, tags: string[], output: Log) { + if (tags.includes(version)) { output.write(`(!) WARNING: Version ${version} already exists, skipping ${version}...`, LogLevel.Warning); return undefined; } @@ -31,36 +31,36 @@ export function getSemanticVersions(version: string, publishedVersions: string[] // Adds semantic versions depending upon the existings (published) versions // eg. 1.2.3 --> [1, 1.2, 1.2.3, latest] - updateSemanticVersionsList(publishedVersions, version, `${parsedVersion.major}.x.x`, `${parsedVersion.major}`); - updateSemanticVersionsList(publishedVersions, version, `${parsedVersion.major}.${parsedVersion.minor}.x`, `${parsedVersion.major}.${parsedVersion.minor}`); + updateSemanticTagsList(tags, version, `${parsedVersion.major}.x.x`, `${parsedVersion.major}`); + updateSemanticTagsList(tags, version, `${parsedVersion.major}.${parsedVersion.minor}.x`, `${parsedVersion.major}.${parsedVersion.minor}`); semanticVersions.push(version); - updateSemanticVersionsList(publishedVersions, version, `x.x.x`, 'latest'); + updateSemanticTagsList(tags, version, `x.x.x`, 'latest'); return semanticVersions; } -export async function doPublishCommand(params: CommonParams, version: string, ociRef: OCIRef, outputDir: string, collectionType: string, archiveName: string, featureAnnotations = {}) { +export async function doPublishCommand(params: CommonParams, version: string, ociRef: OCIRef, outputDir: string, collectionType: string, archiveName: string, annotations: { [key: string]: string } = {}) { const { output } = params; output.write(`Fetching published versions...`, LogLevel.Info); - const publishedVersions = await getPublishedVersions(params, ociRef); + const publishedTags = await getPublishedTags(params, ociRef); - if (!publishedVersions) { + if (!publishedTags) { return; } - const semanticVersions: string[] | undefined = getSemanticVersions(version, publishedVersions, output); + const semanticTags: string[] | undefined = getSemanticTags(version, publishedTags, output); - if (!!semanticVersions) { - output.write(`Publishing versions: ${semanticVersions.toString()}...`, LogLevel.Info); + if (!!semanticTags) { + output.write(`Publishing tags: ${semanticTags.toString()}...`, LogLevel.Info); const pathToTgz = path.join(outputDir, archiveName); - const digest = await pushOCIFeatureOrTemplate(params, ociRef, pathToTgz, semanticVersions, collectionType, featureAnnotations); + const digest = await pushOCIFeatureOrTemplate(params, ociRef, pathToTgz, semanticTags, collectionType, annotations); if (!digest) { output.write(`(!) ERR: Failed to publish ${collectionType}: '${ociRef.resource}'`, LogLevel.Error); return; } output.write(`Published ${collectionType}: '${ociRef.id}'`, LogLevel.Info); - return { publishedVersions: semanticVersions, digest }; + return { publishedTags: semanticTags, digest }; } return {}; // Not an error if no versions were published, likely they just already existed and were skipped. diff --git a/src/spec-node/configContainer.ts b/src/spec-node/configContainer.ts index dddd08d58..c0b21eb82 100644 --- a/src/spec-node/configContainer.ts +++ b/src/spec-node/configContainer.ts @@ -9,7 +9,7 @@ import * as jsonc from 'jsonc-parser'; import { openDockerfileDevContainer } from './singleContainer'; import { openDockerComposeDevContainer } from './dockerCompose'; -import { ResolverResult, DockerResolverParameters, isDockerFileConfig, runInitializeCommand, createDocuments, getWorkspaceConfiguration, BindMountConsistency, uriToFsPath, DevContainerAuthority, isDevContainerAuthority, SubstituteConfig, SubstitutedConfig, addSubstitution, envListToObj, findContainerAndIdLabels } from './utils'; +import { ResolverResult, DockerResolverParameters, isDockerFileConfig, runInitializeCommand, getWorkspaceConfiguration, BindMountConsistency, uriToFsPath, DevContainerAuthority, isDevContainerAuthority, SubstituteConfig, SubstitutedConfig, addSubstitution, envListToObj, findContainerAndIdLabels } from './utils'; import { beforeContainerSubstitute, substitute } from '../spec-common/variableSubstitution'; import { ContainerError } from '../spec-common/errors'; import { Workspace, workspaceFromPath, isWorkspacePath } from '../spec-utils/workspaces'; @@ -20,8 +20,8 @@ import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn } from '. import { DevContainerConfig, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, DevContainerFromImageConfig, updateFromOldProperties } from '../spec-configuration/configuration'; import { ensureNoDisallowedFeatures } from './disallowedFeatures'; import { DockerCLIParameters } from '../spec-shutdown/dockerUtils'; +import { createDocuments } from '../spec-configuration/editableFiles'; -export { getWellKnownDevContainerPaths as getPossibleDevContainerPaths } from '../spec-configuration/configurationCommonUtils'; export async function resolve(params: DockerResolverParameters, configFile: URI | undefined, overrideConfigFile: URI | undefined, providedIdLabels: string[] | undefined, additionalFeatures: Record>): Promise { if (configFile && !/\/\.?devcontainer\.json$/.test(configFile.path)) { @@ -46,7 +46,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, workspaceMountConsistencyDefault, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, workspaceMountConsistencyDefault, overrideConfigFile) || undefined; if (!configs) { if (configPath || workspace) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configPath || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); @@ -60,7 +60,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu const { dockerCLI, dockerComposeCLI } = params; const { env } = common; - const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output }; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; await ensureNoDisallowedFeatures(cliParams, config, additionalFeatures, idLabels); await runInitializeCommand({ ...params, common: { ...common, output: common.lifecycleHook.output } }, config.initializeCommand, common.lifecycleHook.onDidInput); @@ -79,7 +79,7 @@ async function resolveWithLocalFolder(params: DockerResolverParameters, parsedAu return result; } -export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) { +export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Workspace | undefined, configFile: URI, mountWorkspaceGitRoot: boolean, mountGitWorktreeCommonDir: boolean, output: Log, consistency?: BindMountConsistency, overrideConfigFile?: URI) { const documents = createDocuments(cliHost); const content = await documents.readDocument(overrideConfigFile ?? configFile); if (!content) { @@ -90,7 +90,7 @@ export async function readDevContainerConfigFile(cliHost: CLIHost, workspace: Wo if (!updated || typeof updated !== 'object' || Array.isArray(updated)) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) must contain a JSON object literal.` }); } - const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, updated, mountWorkspaceGitRoot, output, consistency); + const workspaceConfig = await getWorkspaceConfiguration(cliHost, workspace, updated, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, output, consistency); const substitute0: SubstituteConfig = value => substitute({ platform: cliHost.platform, localWorkspaceFolder: workspace?.rootFolderPath, diff --git a/src/spec-node/containerFeatures.ts b/src/spec-node/containerFeatures.ts index e9091b179..d8912967c 100644 --- a/src/spec-node/containerFeatures.ts +++ b/src/spec-node/containerFeatures.ts @@ -4,17 +4,15 @@ *--------------------------------------------------------------------------------------------*/ import * as path from 'path'; -import { StringDecoder } from 'string_decoder'; -import * as tar from 'tar'; import { DevContainerConfig } from '../spec-configuration/configuration'; import { dockerCLI, dockerPtyCLI, ImageDetails, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils'; -import { LogLevel, makeLog, toErrorText } from '../spec-utils/log'; -import { FeaturesConfig, getContainerFeaturesFolder, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, V1_DEVCONTAINER_FEATURES_FILE_NAME, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration'; +import { LogLevel, makeLog } from '../spec-utils/log'; +import { FeaturesConfig, getContainerFeaturesBaseDockerFile, getFeatureInstallWrapperScript, getFeatureLayers, getFeatureMainValue, getFeatureValueObject, generateFeaturesConfig, Feature, generateContainerEnvs } from '../spec-configuration/containerFeaturesConfiguration'; import { readLocalFile } from '../spec-utils/pfs'; import { includeAllConfiguredFeatures } from '../spec-utils/product'; -import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder, SubstitutedConfig } from './utils'; -import { isEarlierVersion, parseVersion } from '../spec-common/commonUtils'; +import { createFeaturesTempFolder, DockerResolverParameters, getCacheFolder, getFolderImageName, getEmptyContextFolder, SubstitutedConfig, isBuildxCacheToInline } from './utils'; +import { isEarlierVersion, parseVersion, runCommandNoPty } from '../spec-common/commonUtils'; import { getDevcontainerMetadata, getDevcontainerMetadataLabel, getImageBuildInfoFromImage, ImageBuildInfo, ImageMetadataEntry, imageMetadataLabel, MergedDevContainerConfig } from './imageMetadata'; import { supportsBuildContexts } from './dockerfileUtils'; import { ContainerError } from '../spec-common/errors'; @@ -24,13 +22,13 @@ import { ContainerError } from '../spec-common/errors'; // Environment variables must contain: // - alpha-numeric values, or // - the '_' character, and -// - a number cannot be the first character +// - a number cannot be the first character export const getSafeId = (str: string) => str .replace(/[^\w_]/g, '_') .replace(/^[\d_]+/g, '_') .toUpperCase(); -export async function extendImage(params: DockerResolverParameters, config: SubstitutedConfig, imageName: string, additionalFeatures: Record>, canAddLabelsToContainer: boolean) { +export async function extendImage(params: DockerResolverParameters, config: SubstitutedConfig, imageName: string, additionalImageNames: string[], additionalFeatures: Record>, canAddLabelsToContainer: boolean) { const { common } = params; const { cliHost, output } = common; @@ -38,6 +36,13 @@ export async function extendImage(params: DockerResolverParameters, config: Subs const extendImageDetails = await getExtendImageBuildInfo(params, config, imageName, imageBuildInfo, undefined, additionalFeatures, canAddLabelsToContainer); if (!extendImageDetails?.featureBuildInfo) { // no feature extensions - return + if (additionalImageNames.length) { + if (params.isTTY) { + await Promise.all(additionalImageNames.map(name => dockerPtyCLI(params, 'tag', imageName, name))); + } else { + await Promise.all(additionalImageNames.map(name => dockerCLI(params, 'tag', imageName, name))); + } + } return { updatedImageName: [imageName], imageMetadata: getDevcontainerMetadata(imageBuildInfo.metadata, config, extendImageDetails?.featuresConfig), @@ -80,16 +85,29 @@ export async function extendImage(params: DockerResolverParameters, config: Subs if (params.buildxCacheTo) { args.push('--cache-to', params.buildxCacheTo); } + if (!isBuildxCacheToInline(params.buildxCacheTo)) { + args.push('--build-arg', 'BUILDKIT_INLINE_CACHE=1'); + } + if (!params.buildNoCache) { + params.additionalCacheFroms.forEach(cacheFrom => args.push('--cache-from', cacheFrom)); + } for (const buildContext in featureBuildInfo.buildKitContexts) { args.push('--build-context', `${buildContext}=${featureBuildInfo.buildKitContexts[buildContext]}`); } + + for (const securityOpt of featureBuildInfo.securityOpts) { + args.push('--security-opt', securityOpt); + } } else { // Not using buildx args.push( 'build', ); } + if (params.buildNoCache) { + args.push('--no-cache'); + } for (const buildArg in featureBuildInfo.buildArgs) { args.push('--build-arg', `${buildArg}=${featureBuildInfo.buildArgs[buildArg]}`); } @@ -100,8 +118,9 @@ export async function extendImage(params: DockerResolverParameters, config: Subs cliHost.mkdirp(emptyTempDir); args.push( '--target', featureBuildInfo.overrideTarget, - '-t', updatedImageName, '-f', dockerfilePath, + ...additionalImageNames.length > 0 ? additionalImageNames.map(name => ['-t', name]).flat() : ['-t', updatedImageName], + ...params.additionalLabels.length > 0 ? params.additionalLabels.map(label => ['--label', label]).flat() : [], emptyTempDir ); @@ -113,7 +132,7 @@ export async function extendImage(params: DockerResolverParameters, config: Subs await dockerCLI(infoParams, ...args); } return { - updatedImageName: [ updatedImageName ], + updatedImageName: additionalImageNames.length > 0 ? additionalImageNames : [updatedImageName], imageMetadata: getDevcontainerMetadata(imageBuildInfo.metadata, config, featuresConfig), imageDetails: async () => imageBuildInfo.imageDetails, }; @@ -124,15 +143,12 @@ export async function getExtendImageBuildInfo(params: DockerResolverParameters, // Creates the folder where the working files will be setup. const dstFolder = await createFeaturesTempFolder(params.common); - // Extracts the local cache of features. - await createLocalFeatures(params, dstFolder); - // Processes the user's configuration. const platform = params.common.cliHost.platform; const cacheFolder = await getCacheFolder(params.common.cliHost); - const { experimentalLockfile, experimentalFrozenLockfile } = params; - const featuresConfig = await generateFeaturesConfig({ ...params.common, platform, cacheFolder, experimentalLockfile, experimentalFrozenLockfile }, dstFolder, config.config, getContainerFeaturesFolder, additionalFeatures); + const { noLockfile, frozenLockfile } = params; + const featuresConfig = await generateFeaturesConfig({ ...params.common, platform, cacheFolder, noLockfile, frozenLockfile }, dstFolder, config.config, additionalFeatures); if (!featuresConfig) { if (canAddLabelsToContainer && !imageBuildInfo.dockerfile) { return { @@ -141,7 +157,7 @@ export async function getExtendImageBuildInfo(params: DockerResolverParameters, } }; } - return { featureBuildInfo: getImageBuildOptions(params, config, dstFolder, baseName, imageBuildInfo) }; + return { featureBuildInfo: await getImageBuildOptions(params, config, dstFolder, baseName, imageBuildInfo) }; } // Generates the end configuration. @@ -170,50 +186,6 @@ export function generateContainerEnvsV1(featuresConfig: FeaturesConfig) { return result; } -async function createLocalFeatures(params: DockerResolverParameters, dstFolder: string) -{ - const { common } = params; - const { cliHost, output } = common; - - // Name of the local cache folder inside the working directory - const localCacheBuildFolderName = 'local-cache'; - - const srcFolder = getContainerFeaturesFolder(common.extensionPath); - output.write(`local container features stored at: ${srcFolder}`); - await cliHost.mkdirp(`${dstFolder}/${localCacheBuildFolderName}`); - const create = tar.c({ - cwd: srcFolder, - filter: path => (path !== './Dockerfile' && path !== `./${V1_DEVCONTAINER_FEATURES_FILE_NAME}`), - }, ['.']); - const createExit = new Promise((resolve, reject) => { - create.on('error', reject); - create.on('finish', resolve); - }); - const extract = await cliHost.exec({ - cmd: 'tar', - args: [ - '--no-same-owner', - '-x', - '-f', '-', - ], - cwd: `${dstFolder}/${localCacheBuildFolderName}`, - output, - }); - const stdoutDecoder = new StringDecoder(); - extract.stdout.on('data', (chunk: Buffer) => { - output.write(stdoutDecoder.write(chunk)); - }); - const stderrDecoder = new StringDecoder(); - extract.stderr.on('data', (chunk: Buffer) => { - output.write(toErrorText(stderrDecoder.write(chunk))); - }); - create.pipe(extract.stdin); - await Promise.all([ - extract.exit, - createExit, // Allow errors to surface. - ]); -} - export interface ImageBuildOptions { dstFolder: string; dockerfileContent: string; @@ -221,25 +193,27 @@ export interface ImageBuildOptions { dockerfilePrefixContent: string; buildArgs: Record; buildKitContexts: Record; + securityOpts: string[]; } -function getImageBuildOptions(params: DockerResolverParameters, config: SubstitutedConfig, dstFolder: string, baseName: string, imageBuildInfo: ImageBuildInfo): ImageBuildOptions { - const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax; - return { - dstFolder, - dockerfileContent: ` +async function getImageBuildOptions(params: DockerResolverParameters, config: SubstitutedConfig, dstFolder: string, baseName: string, imageBuildInfo: ImageBuildInfo): Promise { + const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax; + return { + dstFolder, + dockerfileContent: ` FROM $_DEV_CONTAINERS_BASE_IMAGE AS dev_containers_target_stage ${getDevcontainerMetadataLabel(getDevcontainerMetadata(imageBuildInfo.metadata, config, { featureSets: [] }, [], getOmitDevcontainerPropertyOverride(params.common)))} `, - overrideTarget: 'dev_containers_target_stage', - dockerfilePrefixContent: `${syntax ? `# syntax=${syntax}` : ''} - ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder + overrideTarget: 'dev_containers_target_stage', + dockerfilePrefixContent: `${syntax ? `# syntax=${syntax}` : ''} + ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder `, - buildArgs: { - _DEV_CONTAINERS_BASE_IMAGE: baseName, - } as Record, - buildKitContexts: {} as Record, - }; + buildArgs: { + _DEV_CONTAINERS_BASE_IMAGE: baseName, + } as Record, + buildKitContexts: {} as Record, + securityOpts: [], + }; } function getOmitDevcontainerPropertyOverride(resolverParams: { omitConfigRemotEnvFromMetadata?: boolean }): (keyof DevContainerConfig & keyof ImageMetadataEntry)[] { @@ -265,11 +239,15 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont // For non-Buildkit, we build a temporary image to hold the container-features content in a way // that is accessible from the docker build for non-BuiltKit builds // TODO generate an image name that is specific to this dev container? - const buildKitVersionParsed = params.buildKitVersion ? parseVersion(params.buildKitVersion) : null; + const buildKitVersionParsed = params.buildKitVersion?.versionMatch ? parseVersion(params.buildKitVersion.versionMatch) : undefined; const minRequiredVersion = [0, 8, 0]; const useBuildKitBuildContexts = buildKitVersionParsed ? !isEarlierVersion(buildKitVersionParsed, minRequiredVersion) : false; const buildContentImageName = 'dev_container_feature_content_temp'; - + const disableSELinuxLabels = useBuildKitBuildContexts && await isUsingSELinuxLabels(params); + // Access Docker engine version + const dockerEngineVersionParsed = params.dockerEngineVersion?.versionMatch ? parseVersion(params.dockerEngineVersion.versionMatch) : undefined; + const minDockerEngineVersion = [23, 0, 0]; + const skipDefaultSyntax = dockerEngineVersionParsed ? !isEarlierVersion(dockerEngineVersionParsed, minDockerEngineVersion) : false; const omitPropertyOverride = params.common.skipPersistingCustomizationsFromFeatures ? ['customizations'] : []; const imageMetadata = getDevcontainerMetadata(imageBuildInfo.metadata, devContainerConfig, featuresConfig, omitPropertyOverride, getOmitDevcontainerPropertyOverride(params.common)); const { containerUser, remoteUser } = findContainerUsers(imageMetadata, composeServiceUser, imageBuildInfo.user); @@ -290,10 +268,12 @@ async function getFeaturesBuildOptions(params: DockerResolverParameters, devCont .replace('#{devcontainerMetadata}', getDevcontainerMetadataLabel(imageMetadata)) .replace('#{containerEnvMetadata}', generateContainerEnvs(devContainerConfig.config.containerEnv, true)) ; - const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax; - const dockerfilePrefixContent = `${useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? - '# syntax=docker/dockerfile:1.4' : - syntax ? `# syntax=${syntax}` : ''} + const syntax = imageBuildInfo.dockerfile?.preamble.directives.syntax; + const omitSyntaxDirective = common.omitSyntaxDirective; // Can be removed when https://github.com/moby/buildkit/issues/4556 is fixed + const dockerfilePrefixContent = `${omitSyntaxDirective ? '' : + skipDefaultSyntax ? (syntax ? `# syntax=${syntax}` : '') : + useBuildKitBuildContexts && !(imageBuildInfo.dockerfile && supportsBuildContexts(imageBuildInfo.dockerfile)) ? '# syntax=docker/dockerfile:1.4' : + syntax ? `# syntax=${syntax}` : ''} ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder `; @@ -376,9 +356,32 @@ ARG _DEV_CONTAINERS_BASE_IMAGE=placeholder _DEV_CONTAINERS_FEATURE_CONTENT_SOURCE: buildContentImageName, }, buildKitContexts: useBuildKitBuildContexts ? { dev_containers_feature_content_source: dstFolder } : {}, + securityOpts: disableSELinuxLabels ? ['label=disable'] : [], }; } +async function isUsingSELinuxLabels(params: DockerResolverParameters): Promise { + try { + const { common } = params; + const { cliHost, output } = common; + return params.isPodman && cliHost.platform === 'linux' + && (await runCommandNoPty({ + exec: cliHost.exec, + cmd: 'getenforce', + output, + print: true, + })).stdout.toString().trim() !== 'Disabled' + && (await dockerCLI({ + ...toExecParameters(params), + print: true, + }, 'info', '-f', '{{.Host.Security.SELinuxEnabled}}')).stdout.toString().trim() === 'true'; + } catch { + // If we can't run the commands, assume SELinux is not enabled. + return false; + + } +} + export function findContainerUsers(imageMetadata: SubstitutedConfig, composeServiceUser: string | undefined, imageUser: string) { const reversed = imageMetadata.config.slice().reverse(); const containerUser = reversed.find(entry => entry.containerUser)?.containerUser || composeServiceUser || imageUser; @@ -391,7 +394,7 @@ function getFeatureEnvVariables(f: Feature) { const values = getFeatureValueObject(f); const idSafe = getSafeId(f.id); const variables = []; - + if(f.internalVersion !== '2') { if (values) { @@ -412,7 +415,7 @@ function getFeatureEnvVariables(f: Feature) { variables.push(`${f.buildArg}=${getFeatureMainValue(f)}`); } return variables; - } + } } export async function getRemoteUserUIDUpdateDetails(params: DockerResolverParameters, mergedConfig: MergedDevContainerConfig, imageName: string, imageDetails: () => Promise, runArgsUser: string | undefined) { @@ -435,6 +438,7 @@ export async function getRemoteUserUIDUpdateDetails(params: DockerResolverParame imageName: fixedImageName, remoteUser, imageUser, + platform: [details.Os, details.Architecture, details.Variant].filter(Boolean).join('/') }; } @@ -446,7 +450,7 @@ export async function updateRemoteUserUID(params: DockerResolverParameters, merg if (!updateDetails) { return imageName; } - const { imageName: fixedImageName, remoteUser, imageUser } = updateDetails; + const { imageName: fixedImageName, remoteUser, imageUser, platform } = updateDetails; const dockerfileName = 'updateUID.Dockerfile'; const srcDockerfile = path.join(common.extensionPath, 'scripts', dockerfileName); @@ -462,7 +466,8 @@ export async function updateRemoteUserUID(params: DockerResolverParameters, merg 'build', '-f', destDockerfile, '-t', fixedImageName, - '--build-arg', `BASE_IMAGE=${imageName}`, + ...(platform ? ['--platform', platform] : []), + '--build-arg', `BASE_IMAGE=${params.isPodman && !hasRegistryHostname(imageName) ? 'localhost/' : ''}${imageName}`, // Podman: https://github.com/microsoft/vscode-remote-release/issues/9748 '--build-arg', `REMOTE_USER=${remoteUser}`, '--build-arg', `NEW_UID=${await cliHost.getuid!()}`, '--build-arg', `NEW_GID=${await cliHost.getgid!()}`, @@ -476,3 +481,12 @@ export async function updateRemoteUserUID(params: DockerResolverParameters, merg } return fixedImageName; } + +function hasRegistryHostname(imageName: string) { + if (imageName.startsWith('localhost/')) { + return true; + } + const dot = imageName.indexOf('.'); + const slash = imageName.indexOf('/'); + return dot !== -1 && slash !== -1 && dot < slash; +} diff --git a/src/spec-node/devContainers.ts b/src/spec-node/devContainers.ts index e7a7c6538..d647bb614 100644 --- a/src/spec-node/devContainers.ts +++ b/src/spec-node/devContainers.ts @@ -7,16 +7,17 @@ import * as path from 'path'; import * as crypto from 'crypto'; import * as os from 'os'; -import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder } from './utils'; +import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { DockerResolverParameters, DevContainerAuthority, UpdateRemoteUserUIDDefault, BindMountConsistency, getCacheFolder, GPUAvailability } from './utils'; import { createNullLifecycleHook, finishBackgroundTasks, ResolverParameters, UserEnvProbe } from '../spec-common/injectHeadless'; -import { getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; +import { GoARCH, GoOS, getCLIHost, loadNativeModule } from '../spec-common/commonUtils'; import { resolve } from './configContainer'; import { URI } from 'vscode-uri'; import { LogLevel, LogDimensions, toErrorText, createCombinedLog, createTerminalLog, Log, makeLog, LogFormat, createJSONLog, createPlainLog, LogHandler, replaceAllLog } from '../spec-utils/log'; import { dockerComposeCLIConfig } from './dockerCompose'; import { Mount } from '../spec-configuration/containerFeaturesConfiguration'; import { getPackageConfig, PackageConfiguration } from '../spec-utils/product'; -import { dockerBuildKitVersion } from '../spec-shutdown/dockerUtils'; +import { dockerBuildKitVersion, dockerEngineVersion, isPodman } from '../spec-shutdown/dockerUtils'; import { Event } from '../spec-utils/event'; @@ -27,7 +28,9 @@ export interface ProvisionOptions { containerSystemDataFolder: string | undefined; workspaceFolder: string | undefined; workspaceMountConsistency?: BindMountConsistency; + gpuAvailability?: GPUAvailability; mountWorkspaceGitRoot: boolean; + mountGitWorktreeCommonDir: boolean; configFile: URI | undefined; overrideConfigFile: URI | undefined; logLevel: LogLevel; @@ -51,6 +54,7 @@ export interface ProvisionOptions { omitLoggerHeader?: boolean | undefined; buildxPlatform: string | undefined; buildxPush: boolean; + additionalLabels: string[]; buildxOutput: string | undefined; buildxCacheTo: string | undefined; additionalFeatures?: Record>; @@ -64,9 +68,12 @@ export interface ProvisionOptions { installCommand?: string; targetPath?: string; }; - experimentalLockfile?: boolean; - experimentalFrozenLockfile?: boolean; + noLockfile?: boolean; + frozenLockfile?: boolean; secretsP?: Promise>; + omitSyntaxDirective?: boolean; + includeConfig?: boolean; + includeMergedConfig?: boolean; } export async function launch(options: ProvisionOptions, providedIdLabels: string[] | undefined, disposables: (() => Promise | undefined)[]) { @@ -79,10 +86,12 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string output.stop(text, start); const { dockerContainerId, composeProjectName } = result; return { - containerId: dockerContainerId!, + containerId: dockerContainerId, composeProjectName, remoteUser: result.properties.user, remoteWorkspaceFolder: result.properties.remoteWorkspaceFolder, + configuration: options.includeConfig ? result.config : undefined, + mergedConfiguration: options.includeMergedConfig ? result.mergedConfig : undefined, finishBackgroundTasks: async () => { try { await finishBackgroundTasks(result.params.backgroundTasks); @@ -94,7 +103,7 @@ export async function launch(options: ProvisionOptions, providedIdLabels: string } export async function createDockerParams(options: ProvisionOptions, disposables: (() => Promise | undefined)[]): Promise { - const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, mountWorkspaceGitRoot, remoteEnv, experimentalLockfile, experimentalFrozenLockfile, omitLoggerHeader, secretsP } = options; + const { persistedFolder, additionalMounts, updateRemoteUserUIDDefault, containerDataFolder, containerSystemDataFolder, workspaceMountConsistency, gpuAvailability, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, remoteEnv, noLockfile, frozenLockfile, omitLoggerHeader, secretsP } = options; let parsedAuthority: DevContainerAuthority | undefined; if (options.workspaceFolder) { parsedAuthority = { hostPath: options.workspaceFolder } as DevContainerAuthority; @@ -151,7 +160,8 @@ export async function createDockerParams(options: ProvisionOptions, disposables: repository: options.dotfiles.repository, installCommand: options.dotfiles.installCommand, targetPath: options.dotfiles.targetPath || '~/dotfiles', - } + }, + omitSyntaxDirective: options.omitSyntaxDirective, }; const dockerPath = options.dockerPath || 'docker'; @@ -161,21 +171,69 @@ export async function createDockerParams(options: ProvisionOptions, disposables: env: cliHost.env, output: common.output, }, dockerPath, dockerComposePath); - const buildKitVersion = options.useBuildKit === 'never' ? null : (await dockerBuildKitVersion({ + + const buildPlatformInfo = { + os: mapNodeOSToGOOS(cliHost.platform), + arch: mapNodeArchitectureToGOARCH(cliHost.arch), + }; + + const targetPlatformInfo = (() => { + if (common.buildxPlatform) { + const slash1 = common.buildxPlatform.indexOf('/'); + const slash2 = common.buildxPlatform.indexOf('/', slash1 + 1); + // `--platform linux/amd64/v3` `--platform linux/arm64/v8` + if (slash2 !== -1) { + return { + os: common.buildxPlatform.slice(0, slash1), + arch: common.buildxPlatform.slice(slash1 + 1, slash2), + variant: common.buildxPlatform.slice(slash2 + 1), + }; + } + // `--platform linux/amd64` and `--platform linux/arm64` + return { + os: common.buildxPlatform.slice(0, slash1), + arch: common.buildxPlatform.slice(slash1 + 1), + }; + } else { + // `--platform` omitted + return { + os: mapNodeOSToGOOS(cliHost.platform), + arch: mapNodeArchitectureToGOARCH(cliHost.arch), + }; + } + })(); + + const buildKitVersion = options.useBuildKit === 'never' ? undefined : (await dockerBuildKitVersion({ cliHost, dockerCLI: dockerPath, dockerComposeCLI, env: cliHost.env, - output + output, + buildPlatformInfo, + targetPlatformInfo })); + + const dockerEngineVer = await dockerEngineVersion({ + cliHost, + dockerCLI: dockerPath, + dockerComposeCLI, + env: cliHost.env, + output, + buildPlatformInfo, + targetPlatformInfo + }); + return { common, parsedAuthority, dockerCLI: dockerPath, + isPodman: await isPodman({ exec: cliHost.exec, cmd: dockerPath, env: cliHost.env, output }), dockerComposeCLI: dockerComposeCLI, dockerEnv: cliHost.env, workspaceMountConsistencyDefault: workspaceMountConsistency, + gpuAvailability: gpuAvailability || 'detect', mountWorkspaceGitRoot, + mountGitWorktreeCommonDir, updateRemoteUserUIDOnMacOS: false, cacheMount: 'bind', removeOnStartup: options.removeExistingContainer, @@ -186,13 +244,17 @@ export async function createDockerParams(options: ProvisionOptions, disposables: updateRemoteUserUIDDefault, additionalCacheFroms: options.additionalCacheFroms, buildKitVersion, + dockerEngineVersion: dockerEngineVer, isTTY: process.stdout.isTTY || options.logFormat === 'json', - experimentalLockfile, - experimentalFrozenLockfile, + noLockfile, + frozenLockfile, buildxPlatform: common.buildxPlatform, buildxPush: common.buildxPush, + additionalLabels: options.additionalLabels, buildxOutput: common.buildxOutput, buildxCacheTo: common.buildxCacheTo, + buildPlatformInfo, + targetPlatformInfo }; } diff --git a/src/spec-node/devContainersSpecCLI.ts b/src/spec-node/devContainersSpecCLI.ts index adf89ea52..832e9603f 100644 --- a/src/spec-node/devContainersSpecCLI.ts +++ b/src/spec-node/devContainersSpecCLI.ts @@ -10,26 +10,26 @@ import textTable from 'text-table'; import * as jsonc from 'jsonc-parser'; import { createDockerParams, createLog, launch, ProvisionOptions } from './devContainers'; -import { SubstitutedConfig, createContainerProperties, createFeaturesTempFolder, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder } from './utils'; +import { SubstitutedConfig, createContainerProperties, envListToObj, inspectDockerImage, isDockerFileConfig, SubstituteConfig, addSubstitution, findContainerAndIdLabels, getCacheFolder, runAsyncHandler } from './utils'; import { URI } from 'vscode-uri'; import { ContainerError } from '../spec-common/errors'; import { Log, LogDimensions, LogLevel, makeLog, mapLogLevel } from '../spec-utils/log'; import { probeRemoteEnv, runLifecycleHooks, runRemoteCommand, UserEnvProbe, setupInContainer } from '../spec-common/injectHeadless'; import { extendImage } from './containerFeatures'; -import { DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils'; +import { dockerCLI, DockerCLIParameters, dockerPtyCLI, inspectContainer } from '../spec-shutdown/dockerUtils'; import { buildAndExtendDockerCompose, dockerComposeCLIConfig, getDefaultImageName, getProjectName, readDockerComposeConfig, readVersionPrefix } from './dockerCompose'; -import { DevContainerConfig, DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration'; +import { DevContainerFromDockerComposeConfig, DevContainerFromDockerfileConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration'; import { workspaceFromPath } from '../spec-utils/workspaces'; import { readDevContainerConfigFile } from './configContainer'; import { getDefaultDevContainerConfigPath, getDevContainerConfigPathIn, uriToFsPath } from '../spec-configuration/configurationCommonUtils'; import { CLIHost, getCLIHost } from '../spec-common/cliHost'; import { loadNativeModule, processSignals } from '../spec-common/commonUtils'; -import { FeaturesConfig, generateFeaturesConfig, getContainerFeaturesFolder, loadVersionInfo } from '../spec-configuration/containerFeaturesConfiguration'; +import { loadVersionInfo } from '../spec-configuration/containerFeaturesConfiguration'; import { featuresTestOptions, featuresTestHandler } from './featuresCLI/test'; import { featuresPackageHandler, featuresPackageOptions } from './featuresCLI/package'; import { featuresPublishHandler, featuresPublishOptions } from './featuresCLI/publish'; import { beforeContainerSubstitute, containerSubstitute, substitute } from '../spec-common/variableSubstitution'; -import { getPackageConfig, PackageConfiguration } from '../spec-utils/product'; +import { getPackageConfig, } from '../spec-utils/product'; import { getDevcontainerMetadata, getImageBuildInfo, getImageMetadataFromContainer, ImageMetadataEntry, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { templatesPublishHandler, templatesPublishOptions } from './templatesCLI/publish'; import { templateApplyHandler, templateApplyOptions } from './templatesCLI/apply'; @@ -39,6 +39,12 @@ import { Event, NodeEventEmitter } from '../spec-utils/event'; import { ensureNoDisallowedFeatures } from './disallowedFeatures'; import { featuresResolveDependenciesHandler, featuresResolveDependenciesOptions } from './featuresCLI/resolveDependencies'; import { getFeatureIdWithoutVersion } from '../spec-configuration/containerFeaturesOCI'; +import { featuresUpgradeHandler, featuresUpgradeOptions } from './upgradeCommand'; +import { readFeaturesConfig } from './featureUtils'; +import { featuresGenerateDocsHandler, featuresGenerateDocsOptions } from './featuresCLI/generateDocs'; +import { templatesGenerateDocsHandler, templatesGenerateDocsOptions } from './templatesCLI/generateDocs'; +import { mapNodeOSToGOOS, mapNodeArchitectureToGOARCH } from '../spec-configuration/containerCollectionsOCI'; +import { templateMetadataHandler, templateMetadataOptions } from './templatesCLI/metadata'; const defaultDefaultUserEnvProbe: UserEnvProbe = 'loginInteractiveShell'; @@ -68,16 +74,20 @@ const mountRegex = /^type=(bind|volume),source=([^,]+),target=([^,]+)(?:,externa y.command('run-user-commands', 'Run user commands', runUserCommandsOptions, runUserCommandsHandler); y.command('read-configuration', 'Read configuration', readConfigurationOptions, readConfigurationHandler); y.command('outdated', 'Show current and available versions', outdatedOptions, outdatedHandler); + y.command('upgrade', 'Upgrade lockfile', featuresUpgradeOptions, featuresUpgradeHandler); y.command('features', 'Features commands', (y: Argv) => { y.command('test [target]', 'Test Features', featuresTestOptions, featuresTestHandler); y.command('package ', 'Package Features', featuresPackageOptions, featuresPackageHandler); y.command('publish ', 'Package and publish Features', featuresPublishOptions, featuresPublishHandler); y.command('info ', 'Fetch metadata for a published Feature', featuresInfoOptions, featuresInfoHandler); y.command('resolve-dependencies', 'Read and resolve dependency graph from a configuration', featuresResolveDependenciesOptions, featuresResolveDependenciesHandler); + y.command('generate-docs', 'Generate documentation', featuresGenerateDocsOptions, featuresGenerateDocsHandler); }); y.command('templates', 'Templates commands', (y: Argv) => { y.command('apply', 'Apply a template to the project', templateApplyOptions, templateApplyHandler); y.command('publish ', 'Package and publish templates', templatesPublishOptions, templatesPublishHandler); + y.command('metadata ', 'Fetch a published Template\'s metadata', templateMetadataOptions, templateMetadataHandler); + y.command('generate-docs', 'Generate documentation', templatesGenerateDocsOptions, templatesGenerateDocsHandler); }); y.command(restArgs ? ['exec', '*'] : ['exec [args..]'], 'Execute a command on a running dev container', execOptions, execHandler); y.epilog(`devcontainer@${version} ${packageFolder}`); @@ -93,16 +103,18 @@ function provisionOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --id-label, --override-config, and --workspace-folder are not provided, this defaults to the current directory.' }, 'workspace-mount-consistency': { choices: ['consistent' as 'consistent', 'cached' as 'cached', 'delegated' as 'delegated'], default: 'cached' as 'cached', description: 'Workspace mount consistency.' }, + 'gpu-availability': { choices: ['all' as 'all', 'detect' as 'detect', 'none' as 'none'], default: 'detect' as 'detect', description: 'Availability of GPUs in case the dev container requires any. `all` expects a GPU to be available.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'mount-git-worktree-common-dir': { type: 'boolean', default: false, description: 'Mount the Git worktree common dir for Git operations to work in the container. This requires the worktree to be created with relative paths (`git worktree add --relative-paths`).' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. These will be set on the container and used to query for an existing container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, - 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, - 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, 'update-remote-user-uid-default': { choices: ['never' as 'never', 'on' as 'on', 'off' as 'off'], default: 'on' as 'on', description: 'Default for updating the remote user\'s UID and GID to the local user\'s one.' }, 'remove-existing-container': { type: 'boolean', default: false, description: 'Removes the dev container if it already exists.' }, @@ -115,6 +127,7 @@ function provisionOptions(y: Argv) { 'mount': { type: 'string', description: 'Additional mount point(s). Format: type=,source=,target=[,external=]' }, 'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' }, 'cache-from': { type: 'string', description: 'Additional image to use as potential layer cache during image building' }, + 'cache-to': { type: 'string', description: 'Additional image to use as potential layer cache during image building' }, 'buildkit': { choices: ['auto' as 'auto', 'never' as 'never'], default: 'auto' as 'auto', description: 'Control whether BuildKit should be used' }, 'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' }, 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, @@ -126,27 +139,39 @@ function provisionOptions(y: Argv) { 'omit-config-remote-env-from-metadata': { type: 'boolean', default: false, hidden: true, description: 'Omit remoteEnv from devcontainer.json for container metadata label' }, 'secrets-file': { type: 'string', description: 'Path to a json file containing secret environment variables as key-value pairs.' }, 'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' }, - 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' } + 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' }, + 'no-lockfile': { type: 'boolean', default: false, description: 'Disable lockfile generation and verification.' }, + 'frozen-lockfile': { type: 'boolean', default: false, description: 'Ensure lockfile exists and remains unchanged; fail otherwise.' }, + 'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' }, + 'include-configuration': { type: 'boolean', default: false, description: 'Include configuration in result.' }, + 'include-merged-configuration': { type: 'boolean', default: false, description: 'Include merged configuration in result.' }, }) .check(argv => { const idLabels = (argv['id-label'] && (Array.isArray(argv['id-label']) ? argv['id-label'] : [argv['id-label']])) as string[] | undefined; if (idLabels?.some(idLabel => !/.+=.+/.test(idLabel))) { throw new Error('Unmatched argument format: id-label must match ='); } - if (!(argv['workspace-folder'] || argv['id-label'])) { - throw new Error('Missing required argument: workspace-folder or id-label'); - } - if (!(argv['workspace-folder'] || argv['override-config'])) { - throw new Error('Missing required argument: workspace-folder or override-config'); + // Default workspace-folder to current directory if not provided and no id-label or override-config + if (!argv['workspace-folder'] && !argv['id-label'] && !argv['override-config']) { + argv['workspace-folder'] = process.cwd(); } const mounts = (argv.mount && (Array.isArray(argv.mount) ? argv.mount : [argv.mount])) as string[] | undefined; if (mounts?.some(mount => !mountRegex.test(mount))) { throw new Error('Unmatched argument format: mount must match type=,source=,target=[,external=]'); } const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; - if (remoteEnvs?.some(remoteEnv => !/.+=.+/.test(remoteEnv))) { + if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); } + if (argv['no-lockfile'] && argv['frozen-lockfile']) { + throw new Error('--no-lockfile and --frozen-lockfile are mutually exclusive.'); + } + if (argv['no-lockfile'] && argv['experimental-frozen-lockfile']) { + throw new Error('--no-lockfile and --experimental-frozen-lockfile are mutually exclusive.'); + } + if (argv['no-lockfile'] && argv['experimental-lockfile']) { + throw new Error('--no-lockfile and --experimental-lockfile are mutually exclusive.'); + } return true; }); } @@ -154,7 +179,7 @@ function provisionOptions(y: Argv) { type ProvisionArgs = UnpackArgv>; function provisionHandler(args: ProvisionArgs) { - (async () => provision(args))().catch(console.error); + runAsyncHandler(provision.bind(null, args)); } async function provision({ @@ -165,7 +190,9 @@ async function provision({ 'container-system-data-folder': containerSystemDataFolder, 'workspace-folder': workspaceFolderArg, 'workspace-mount-consistency': workspaceMountConsistency, + 'gpu-availability': gpuAvailability, 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'mount-git-worktree-common-dir': mountGitWorktreeCommonDir, 'id-label': idLabel, config, 'override-config': overrideConfig, @@ -184,6 +211,7 @@ async function provision({ mount, 'remote-env': addRemoteEnv, 'cache-from': addCacheFrom, + 'cache-to': addCacheTo, 'buildkit': buildkit, 'additional-features': additionalFeaturesJson, 'skip-feature-auto-mapping': skipFeatureAutoMapping, @@ -195,9 +223,17 @@ async function provision({ 'omit-config-remote-env-from-metadata': omitConfigRemotEnvFromMetadata, 'secrets-file': secretsFile, 'experimental-lockfile': experimentalLockfile, - 'experimental-frozen-lockfile': experimentalFrozenLockfile + 'experimental-frozen-lockfile': experimentalFrozenLockfile, + 'no-lockfile': noLockfile, + 'frozen-lockfile': frozenLockfile, + 'omit-syntax-directive': omitSyntaxDirective, + 'include-configuration': includeConfig, + 'include-merged-configuration': includeMergedConfig, }: ProvisionArgs) { + warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); + const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile; + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : undefined; const addRemoteEnvs = addRemoteEnv ? (Array.isArray(addRemoteEnv) ? addRemoteEnv as string[] : [addRemoteEnv]) : []; const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; @@ -215,7 +251,9 @@ async function provision({ containerSystemDataFolder, workspaceFolder, workspaceMountConsistency, + gpuAvailability, mountWorkspaceGitRoot, + mountGitWorktreeCommonDir, configFile: config ? URI.file(path.resolve(process.cwd(), config)) : undefined, overrideConfigFile: overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : undefined, logLevel: mapLogLevel(logLevel), @@ -251,21 +289,27 @@ async function provision({ useBuildKit: buildkit, buildxPlatform: undefined, buildxPush: false, + additionalLabels: [], buildxOutput: undefined, - buildxCacheTo: undefined, + buildxCacheTo: addCacheTo, additionalFeatures, skipFeatureAutoMapping, skipPostAttach, containerSessionDataFolder, skipPersistingCustomizationsFromFeatures: false, - omitConfigRemotEnvFromMetadata: omitConfigRemotEnvFromMetadata, - experimentalLockfile, - experimentalFrozenLockfile, + omitConfigRemotEnvFromMetadata, + noLockfile, + frozenLockfile: effectiveFrozenLockfile, + omitSyntaxDirective, + includeConfig, + includeMergedConfig, }; const result = await doProvision(options, providedIdLabels); const exitCode = result.outcome === 'error' ? 1 : 0; - console.log(JSON.stringify(result)); + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(result) + '\n', err => err ? reject(err) : resolve()); + }); if (result.outcome === 'success') { await result.finishBackgroundTasks(); } @@ -299,6 +343,9 @@ async function doProvision(options: ProvisionOptions, providedIdLabels: string[] message: err.message, description: err.description, containerId: err.containerId, + disallowedFeatureId: err.data.disallowedFeatureId, + didStopContainer: err.data.didStopContainer, + learnMoreUrl: err.data.learnMoreUrl, dispose, }; } @@ -313,8 +360,8 @@ function setUpOptions(y: Argv) { 'config': { type: 'string', description: 'devcontainer.json path.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, - 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, - 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, 'skip-post-create': { type: 'boolean', default: false, description: 'Do not run onCreateCommand, updateContentCommand, postCreateCommand, postStartCommand or postAttachCommand and do not install dotfiles.' }, 'skip-non-blocking-commands': { type: 'boolean', default: false, description: 'Stop running user commands after running the command configured with waitFor or the updateContentCommand by default.' }, @@ -324,10 +371,12 @@ function setUpOptions(y: Argv) { 'dotfiles-install-command': { type: 'string', description: 'The command to run after cloning the dotfiles repository. Defaults to run the first file of `install.sh`, `install`, `bootstrap.sh`, `bootstrap`, `setup.sh` and `setup` found in the dotfiles repository`s root folder.' }, 'dotfiles-target-path': { type: 'string', default: '~/dotfiles', description: 'The path to clone the dotfiles repository to. Defaults to `~/dotfiles`.' }, 'container-session-data-folder': { type: 'string', description: 'Folder to cache CLI data, for example userEnvProbe results' }, + 'include-configuration': { type: 'boolean', default: false, description: 'Include configuration in result.' }, + 'include-merged-configuration': { type: 'boolean', default: false, description: 'Include merged configuration in result.' }, }) .check(argv => { const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; - if (remoteEnvs?.some(remoteEnv => !/.+=.+/.test(remoteEnv))) { + if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); } return true; @@ -337,13 +386,15 @@ function setUpOptions(y: Argv) { type SetUpArgs = UnpackArgv>; function setUpHandler(args: SetUpArgs) { - (async () => setUp(args))().catch(console.error); + runAsyncHandler(setUp.bind(null, args)); } async function setUp(args: SetUpArgs) { const result = await doSetUp(args); const exitCode = result.outcome === 'error' ? 1 : 0; - console.log(JSON.stringify(result)); + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(result) + '\n', err => err ? reject(err) : resolve()); + }); await result.dispose(); process.exit(exitCode); } @@ -367,6 +418,8 @@ async function doSetUp({ 'dotfiles-install-command': dotfilesInstallCommand, 'dotfiles-target-path': dotfilesTargetPath, 'container-session-data-folder': containerSessionDataFolder, + 'include-configuration': includeConfig, + 'include-merged-configuration': includeMergedConfig, }: SetUpArgs) { const disposables: (() => Promise | undefined)[] = []; @@ -384,6 +437,7 @@ async function doSetUp({ containerSystemDataFolder, workspaceFolder: undefined, mountWorkspaceGitRoot: false, + mountGitWorktreeCommonDir: false, configFile, overrideConfigFile: undefined, logLevel: mapLogLevel(logLevel), @@ -405,6 +459,7 @@ async function doSetUp({ useBuildKit: 'auto', buildxPlatform: undefined, buildxPush: false, + additionalLabels: [], buildxOutput: undefined, buildxCacheTo: undefined, skipFeatureAutoMapping: false, @@ -419,7 +474,7 @@ async function doSetUp({ const { common } = params; const { cliHost, output } = common; - const configs = configFile && await readDevContainerConfigFile(cliHost, undefined, configFile, params.mountWorkspaceGitRoot, output, undefined, undefined); + const configs = configFile && await readDevContainerConfigFile(cliHost, undefined, configFile, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, undefined); if (configFile && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile, cliHost.platform)}) not found.` }); } @@ -435,15 +490,16 @@ async function doSetUp({ bailOut(common.output, 'Dev container not found.'); } - const config1 = addSubstitution(config0, config => beforeContainerSubstitute(undefined, config)); - const config = addSubstitution(config1, config => containerSubstitute(cliHost.platform, config1.config.configFilePath, envListToObj(container.Config.Env), config)); + const config = addSubstitution(config0, config => beforeContainerSubstitute(undefined, config)); const imageMetadata = getImageMetadataFromContainer(container, config, undefined, undefined, output).config; const mergedConfig = mergeConfiguration(config.config, imageMetadata); const containerProperties = await createContainerProperties(params, container.Id, configs?.workspaceConfig.workspaceFolder, mergedConfig.remoteUser); - await setupInContainer(common, containerProperties, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); + const res = await setupInContainer(common, containerProperties, config.config, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); return { outcome: 'success' as 'success', + configuration: includeConfig ? res.updatedConfig : undefined, + mergedConfiguration: includeMergedConfig ? res.updatedMergedConfig : undefined, dispose, }; } catch (originalError) { @@ -469,7 +525,7 @@ function buildOptions(y: Argv) { 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, - 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If not provided, defaults to the current directory.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level.' }, 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, @@ -480,25 +536,43 @@ function buildOptions(y: Argv) { 'buildkit': { choices: ['auto' as 'auto', 'never' as 'never'], default: 'auto' as 'auto', description: 'Control whether BuildKit should be used' }, 'platform': { type: 'string', description: 'Set target platforms.' }, 'push': { type: 'boolean', default: false, description: 'Push to a container registry.' }, + 'label': { type: 'string', description: 'Provide key and value configuration that adds metadata to an image' }, 'output': { type: 'string', description: 'Overrides the default behavior to load built images into the local docker registry. Valid options are the same ones provided to the --output option of docker buildx build.' }, 'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' }, 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, 'skip-persisting-customizations-from-features': { type: 'boolean', default: false, hidden: true, description: 'Do not save customizations from referenced Features as image metadata' }, 'experimental-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Write lockfile' }, - 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' } - }); + 'experimental-frozen-lockfile': { type: 'boolean', default: false, hidden: true, description: 'Ensure lockfile remains unchanged' }, + 'no-lockfile': { type: 'boolean', default: false, description: 'Disable lockfile generation and verification.' }, + 'frozen-lockfile': { type: 'boolean', default: false, description: 'Ensure lockfile exists and remains unchanged; fail otherwise.' }, + 'omit-syntax-directive': { type: 'boolean', default: false, hidden: true, description: 'Omit Dockerfile syntax directives' }, + }) + .check(argv => { + if (argv['no-lockfile'] && argv['frozen-lockfile']) { + throw new Error('--no-lockfile and --frozen-lockfile are mutually exclusive.'); + } + if (argv['no-lockfile'] && argv['experimental-frozen-lockfile']) { + throw new Error('--no-lockfile and --experimental-frozen-lockfile are mutually exclusive.'); + } + if (argv['no-lockfile'] && argv['experimental-lockfile']) { + throw new Error('--no-lockfile and --experimental-lockfile are mutually exclusive.'); + } + return true; + }); } type BuildArgs = UnpackArgv>; function buildHandler(args: BuildArgs) { - (async () => build(args))().catch(console.error); + runAsyncHandler(build.bind(null, args)); } async function build(args: BuildArgs) { const result = await doBuild(args); const exitCode = result.outcome === 'error' ? 1 : 0; - console.log(JSON.stringify(result)); + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(result) + '\n', err => err ? reject(err) : resolve()); + }); await result.dispose(); process.exit(exitCode); } @@ -517,20 +591,27 @@ async function doBuild({ 'buildkit': buildkit, 'platform': buildxPlatform, 'push': buildxPush, + 'label': buildxLabel, 'output': buildxOutput, 'cache-to': buildxCacheTo, 'additional-features': additionalFeaturesJson, 'skip-feature-auto-mapping': skipFeatureAutoMapping, 'skip-persisting-customizations-from-features': skipPersistingCustomizationsFromFeatures, 'experimental-lockfile': experimentalLockfile, - 'experimental-frozen-lockfile': experimentalFrozenLockfile + 'experimental-frozen-lockfile': experimentalFrozenLockfile, + 'no-lockfile': noLockfile, + 'frozen-lockfile': frozenLockfile, + 'omit-syntax-directive': omitSyntaxDirective, }: BuildArgs) { + warnDeprecatedLockfileFlags(experimentalLockfile, experimentalFrozenLockfile); + const effectiveFrozenLockfile = frozenLockfile || experimentalFrozenLockfile; + const disposables: (() => Promise | undefined)[] = []; const dispose = async () => { await Promise.all(disposables.map(d => d())); }; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); const configFile: URI | undefined = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const overrideConfigFile: URI | undefined = /* overrideConfig ? URI.file(path.resolve(process.cwd(), overrideConfig)) : */ undefined; const addCacheFroms = addCacheFrom ? (Array.isArray(addCacheFrom) ? addCacheFrom as string[] : [addCacheFrom]) : []; @@ -542,6 +623,7 @@ async function doBuild({ containerSystemDataFolder: undefined, workspaceFolder, mountWorkspaceGitRoot: false, + mountGitWorktreeCommonDir: false, configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -563,24 +645,26 @@ async function doBuild({ useBuildKit: buildkit, buildxPlatform, buildxPush, + additionalLabels: [], buildxOutput, buildxCacheTo, skipFeatureAutoMapping, skipPostAttach: true, skipPersistingCustomizationsFromFeatures: skipPersistingCustomizationsFromFeatures, dotfiles: {}, - experimentalLockfile, - experimentalFrozenLockfile + noLockfile, + frozenLockfile: effectiveFrozenLockfile, + omitSyntaxDirective, }, disposables); - const { common, dockerCLI, dockerComposeCLI } = params; + const { common, dockerComposeCLI } = params; const { cliHost, env, output } = common; const workspace = workspaceFromPath(cliHost.path, workspaceFolder); const configPath = configFile ? configFile : workspace ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined; if (!configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -592,21 +676,21 @@ async function doBuild({ throw new ContainerError({ description: '--push true cannot be used with --output.' }); } - const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output }; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI: params.dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; await ensureNoDisallowedFeatures(buildParams, config, additionalFeatures, undefined); // Support multiple use of `--image-name` const imageNames = (argImageName && (Array.isArray(argImageName) ? argImageName : [argImageName]) as string[]) || undefined; + // Support multiple use of `--label` + params.additionalLabels = (buildxLabel && (Array.isArray(buildxLabel) ? buildxLabel : [buildxLabel]) as string[]) || []; + if (isDockerFileConfig(config)) { // Build the base image and extend with features etc. let { updatedImageName } = await buildNamedImageAndExtend(params, configWithRaw as SubstitutedConfig, additionalFeatures, false, imageNames); if (imageNames) { - if (!buildxPush && !buildxOutput) { - await Promise.all(imageNames.map(imageName => dockerPtyCLI(params, 'tag', updatedImageName[0], imageName))); - } imageNameResult = imageNames; } else { imageNameResult = updatedImageName; @@ -634,9 +718,9 @@ async function doBuild({ if (envFile) { composeGlobalArgs.push('--env-file', envFile); } - const projectName = await getProjectName(params, workspace, composeFiles); - + const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); + const projectName = await getProjectName(params, workspace, composeFiles, composeConfig); const services = Object.keys(composeConfig.services || {}); if (services.indexOf(config.service) === -1) { throw new Error(`Service '${config.service}' configured in devcontainer.json not found in Docker Compose configuration.`); @@ -650,7 +734,12 @@ async function doBuild({ const originalImageName = overrideImageName || service.image || getDefaultImageName(await buildParams.dockerComposeCLI(), projectName, config.service); if (imageNames) { - await Promise.all(imageNames.map(imageName => dockerPtyCLI(params, 'tag', originalImageName, imageName))); + // Future improvement: Compose 2.6.0 (released 2022-05-30) added `tags` to the compose file. + if (params.isTTY) { + await Promise.all(imageNames.map(imageName => dockerPtyCLI(params, 'tag', originalImageName, imageName))); + } else { + await Promise.all(imageNames.map(imageName => dockerCLI(params, 'tag', originalImageName, imageName))); + } imageNameResult = imageNames; } else { imageNameResult = originalImageName; @@ -662,10 +751,9 @@ async function doBuild({ } await inspectDockerImage(params, config.image, true); - const { updatedImageName } = await extendImage(params, configWithRaw, config.image, additionalFeatures, false); + const { updatedImageName } = await extendImage(params, configWithRaw, config.image, imageNames || [], additionalFeatures, false); if (imageNames) { - await Promise.all(imageNames.map(imageName => dockerPtyCLI(params, 'tag', updatedImageName[0], imageName))); imageNameResult = imageNames; } else { imageNameResult = updatedImageName; @@ -702,16 +790,17 @@ function runUserCommandsOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path.The devcontainer.json will be looked up relative to this path. If --container-id, --id-label, and --workspace-folder are not provided, this defaults to the current directory.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'mount-git-worktree-common-dir': { type: 'boolean', default: false, description: 'Mount the Git worktree common dir for Git operations to work in the container. This requires the worktree to be created with relative paths (`git worktree add --relative-paths`).' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, - 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, - 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, 'skip-non-blocking-commands': { type: 'boolean', default: false, description: 'Stop running user commands after running the command configured with waitFor or the updateContentCommand by default.' }, prebuild: { type: 'boolean', default: false, description: 'Stop after onCreateCommand and updateContentCommand, rerunning updateContentCommand if it has run before.' }, @@ -731,11 +820,11 @@ function runUserCommandsOptions(y: Argv) { throw new Error('Unmatched argument format: id-label must match ='); } const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; - if (remoteEnvs?.some(remoteEnv => !/.+=.+/.test(remoteEnv))) { + if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); } if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { - throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + argv['workspace-folder'] = process.cwd(); } return true; }); @@ -744,12 +833,14 @@ function runUserCommandsOptions(y: Argv) { type RunUserCommandsArgs = UnpackArgv>; function runUserCommandsHandler(args: RunUserCommandsArgs) { - (async () => runUserCommands(args))().catch(console.error); + runAsyncHandler(runUserCommands.bind(null, args)); } async function runUserCommands(args: RunUserCommandsArgs) { const result = await doRunUserCommands(args); const exitCode = result.outcome === 'error' ? 1 : 0; - console.log(JSON.stringify(result)); + await new Promise((resolve, reject) => { + process.stdout.write(JSON.stringify(result) + '\n', err => err ? reject(err) : resolve()); + }); await result.dispose(); process.exit(exitCode); } @@ -762,6 +853,7 @@ async function doRunUserCommands({ 'container-system-data-folder': containerSystemDataFolder, 'workspace-folder': workspaceFolderArg, 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'mount-git-worktree-common-dir': mountGitWorktreeCommonDir, 'container-id': containerId, 'id-label': idLabel, config: configParam, @@ -805,6 +897,7 @@ async function doRunUserCommands({ containerSystemDataFolder, workspaceFolder, mountWorkspaceGitRoot, + mountGitWorktreeCommonDir, configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -826,6 +919,7 @@ async function doRunUserCommands({ useBuildKit: 'auto', buildxPlatform: undefined, buildxPush: false, + additionalLabels: [], buildxOutput: undefined, buildxCacheTo: undefined, skipFeatureAutoMapping, @@ -847,7 +941,7 @@ async function doRunUserCommands({ ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined; if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -901,16 +995,17 @@ function readConfigurationOptions(y: Argv) { 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, 'docker-path': { type: 'string', description: 'Docker CLI path.' }, 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --container-id, --id-label, and --workspace-folder are not provided, this defaults to the current directory.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'mount-git-worktree-common-dir': { type: 'boolean', default: false, description: 'Mount the Git worktree common dir for Git operations to work in the container. This requires the worktree to be created with relative paths (`git worktree add --relative-paths`).' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, - 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, - 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, 'include-features-configuration': { type: 'boolean', default: false, description: 'Include features configuration.' }, 'include-merged-configuration': { type: 'boolean', default: false, description: 'Include merged configuration.' }, 'additional-features': { type: 'string', description: 'Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)' }, @@ -922,7 +1017,7 @@ function readConfigurationOptions(y: Argv) { throw new Error('Unmatched argument format: id-label must match ='); } if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { - throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + argv['workspace-folder'] = process.cwd(); } return true; }); @@ -931,7 +1026,7 @@ function readConfigurationOptions(y: Argv) { type ReadConfigurationArgs = UnpackArgv>; function readConfigurationHandler(args: ReadConfigurationArgs) { - (async () => readConfiguration(args))().catch(console.error); + runAsyncHandler(readConfiguration.bind(null, args)); } async function readConfiguration({ @@ -940,6 +1035,7 @@ async function readConfiguration({ 'docker-compose-path': dockerComposePath, 'workspace-folder': workspaceFolderArg, 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'mount-git-worktree-common-dir': mountGitWorktreeCommonDir, config: configParam, 'override-config': overrideConfig, 'container-id': containerId, @@ -980,7 +1076,7 @@ async function readConfiguration({ ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, mountWorkspaceGitRoot, mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined; if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -997,12 +1093,18 @@ async function readConfiguration({ env: cliHost.env, output, }, dockerCLI, dockerComposePath || 'docker-compose'); + const buildPlatformInfo = { + os: mapNodeOSToGOOS(cliHost.platform), + arch: mapNodeArchitectureToGOARCH(cliHost.arch), + }; const params: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env: cliHost.env, - output + output, + buildPlatformInfo, + targetPlatformInfo: buildPlatformInfo }; const { container, idLabels } = await findContainerAndIdLabels(params, containerId, providedIdLabels, workspaceFolder, configPath?.fsPath); if (container) { @@ -1047,31 +1149,23 @@ async function readConfiguration({ process.exit(0); } -async function readFeaturesConfig(params: DockerCLIParameters, pkg: PackageConfiguration, config: DevContainerConfig, extensionPath: string, skipFeatureAutoMapping: boolean, additionalFeatures: Record>): Promise { - const { cliHost, output } = params; - const { cwd, env, platform } = cliHost; - const featuresTmpFolder = await createFeaturesTempFolder({ cliHost, package: pkg }); - const cacheFolder = await getCacheFolder(cliHost); - return generateFeaturesConfig({ extensionPath, cacheFolder, cwd, output, env, skipFeatureAutoMapping, platform }, featuresTmpFolder, config, getContainerFeaturesFolder, additionalFeatures); -} - function outdatedOptions(y: Argv) { return y.options({ 'user-data-folder': { type: 'string', description: 'Host path to a directory that is intended to be persisted and share state between sessions.' }, - 'workspace-folder': { type: 'string', required: true, description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --workspace-folder is not provided, defaults to the current directory.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'output-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text', description: 'Output format.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, - 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, - 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, }); } type OutdatedArgs = UnpackArgv>; function outdatedHandler(args: OutdatedArgs) { - (async () => outdated(args))().catch(console.error); + runAsyncHandler(outdated.bind(null, args)); } async function outdated({ @@ -1090,7 +1184,7 @@ async function outdated({ }; let output: Log | undefined; try { - const workspaceFolder = path.resolve(process.cwd(), workspaceFolderArg); + const workspaceFolder = workspaceFolderArg ? path.resolve(process.cwd(), workspaceFolderArg) : process.cwd(); const configFile = configParam ? URI.file(path.resolve(process.cwd(), configParam)) : undefined; const cliHost = await getCLIHost(workspaceFolder, loadNativeModule, logFormat === 'text'); const extensionPath = path.join(__dirname, '..', '..'); @@ -1105,7 +1199,7 @@ async function outdated({ const workspace = workspaceFromPath(cliHost.path, workspaceFolder); const configPath = configFile ? configFile : await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath); - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, output) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, true, false, output) || undefined; if (!configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -1160,16 +1254,17 @@ function execOptions(y: Argv) { 'docker-compose-path': { type: 'string', description: 'Docker Compose CLI path.' }, 'container-data-folder': { type: 'string', description: 'Container data folder where user data inside the container will be stored.' }, 'container-system-data-folder': { type: 'string', description: 'Container system data folder where system data inside the container will be stored.' }, - 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path.' }, + 'workspace-folder': { type: 'string', description: 'Workspace folder path. The devcontainer.json will be looked up relative to this path. If --container-id, --id-label, and --workspace-folder are not provided, this defaults to the current directory.' }, 'mount-workspace-git-root': { type: 'boolean', default: true, description: 'Mount the workspace using its Git root.' }, + 'mount-git-worktree-common-dir': { type: 'boolean', default: false, description: 'Mount the Git worktree common dir for Git operations to work in the container. This requires the worktree to be created with relative paths (`git worktree add --relative-paths`).' }, 'container-id': { type: 'string', description: 'Id of the container to run the user commands for.' }, 'id-label': { type: 'string', description: 'Id label(s) of the format name=value. If no --container-id is given the id labels will be used to look up the container. If no --id-label is given, one will be inferred from the --workspace-folder path.' }, 'config': { type: 'string', description: 'devcontainer.json path. The default is to use .devcontainer/devcontainer.json or, if that does not exist, .devcontainer.json in the workspace folder.' }, 'override-config': { type: 'string', description: 'devcontainer.json path to override any devcontainer.json in the workspace folder (or built-in configuration). This is required when there is no devcontainer.json otherwise.' }, 'log-level': { choices: ['info' as 'info', 'debug' as 'debug', 'trace' as 'trace'], default: 'info' as 'info', description: 'Log level for the --terminal-log-file. When set to trace, the log level for --log-file will also be set to trace.' }, 'log-format': { choices: ['text' as 'text', 'json' as 'json'], default: 'text' as 'text', description: 'Log format.' }, - 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, - 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-columns': { type: 'number', implies: ['terminal-rows'], description: 'Number of columns to render the output for. This is required for some of the subprocesses to correctly render their output.' }, + 'terminal-rows': { type: 'number', implies: ['terminal-columns'], description: 'Number of rows to render the output for. This is required for some of the subprocesses to correctly render their output.' }, 'default-user-env-probe': { choices: ['none' as 'none', 'loginInteractiveShell' as 'loginInteractiveShell', 'interactiveShell' as 'interactiveShell', 'loginShell' as 'loginShell'], default: defaultDefaultUserEnvProbe, description: 'Default value for the devcontainer.json\'s "userEnvProbe".' }, 'remote-env': { type: 'string', description: 'Remote environment variables of the format name=value. These will be added when executing the user commands.' }, 'skip-feature-auto-mapping': { type: 'boolean', default: false, hidden: true, description: 'Temporary option for testing.' }, @@ -1190,11 +1285,11 @@ function execOptions(y: Argv) { throw new Error('Unmatched argument format: id-label must match ='); } const remoteEnvs = (argv['remote-env'] && (Array.isArray(argv['remote-env']) ? argv['remote-env'] : [argv['remote-env']])) as string[] | undefined; - if (remoteEnvs?.some(remoteEnv => !/.+=.+/.test(remoteEnv))) { + if (remoteEnvs?.some(remoteEnv => !/.+=.*/.test(remoteEnv))) { throw new Error('Unmatched argument format: remote-env must match ='); } if (!argv['container-id'] && !idLabels?.length && !argv['workspace-folder']) { - throw new Error('Missing required argument: One of --container-id, --id-label or --workspace-folder is required.'); + argv['workspace-folder'] = process.cwd(); } return true; }); @@ -1203,7 +1298,7 @@ function execOptions(y: Argv) { export type ExecArgs = UnpackArgv>; function execHandler(args: ExecArgs) { - (async () => exec(args))().catch(console.error); + runAsyncHandler(exec.bind(null, args)); } async function exec(args: ExecArgs) { @@ -1223,6 +1318,7 @@ export async function doExec({ 'container-system-data-folder': containerSystemDataFolder, 'workspace-folder': workspaceFolderArg, 'mount-workspace-git-root': mountWorkspaceGitRoot, + 'mount-git-worktree-common-dir': mountGitWorktreeCommonDir, 'container-id': containerId, 'id-label': idLabel, config: configParam, @@ -1255,6 +1351,7 @@ export async function doExec({ containerSystemDataFolder, workspaceFolder, mountWorkspaceGitRoot, + mountGitWorktreeCommonDir, configFile, overrideConfigFile, logLevel: mapLogLevel(logLevel), @@ -1278,6 +1375,7 @@ export async function doExec({ omitLoggerHeader: true, buildxPlatform: undefined, buildxPush: false, + additionalLabels: [], buildxCacheTo: undefined, skipFeatureAutoMapping, buildxOutput: undefined, @@ -1294,7 +1392,7 @@ export async function doExec({ ? (await getDevContainerConfigPathIn(cliHost, workspace.configFolderPath) || (overrideConfigFile ? getDefaultDevContainerConfigPath(cliHost, workspace.configFolderPath) : undefined)) : overrideConfigFile; - const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, output, undefined, overrideConfigFile) || undefined; + const configs = configPath && await readDevContainerConfigFile(cliHost, workspace, configPath, params.mountWorkspaceGitRoot, params.mountGitWorktreeCommonDir, output, undefined, overrideConfigFile) || undefined; if ((configFile || workspaceFolder || overrideConfigFile) && !configs) { throw new ContainerError({ description: `Dev container config (${uriToFsPath(configFile || getDefaultDevContainerConfigPath(cliHost, workspace!.configFolderPath), cliHost.platform)}) not found.` }); } @@ -1379,3 +1477,12 @@ async function readSecretsFromFile(params: { output?: Log; secretsFile?: string; }); } } + +function warnDeprecatedLockfileFlags(experimentalLockfile: boolean, experimentalFrozenLockfile: boolean) { + if (experimentalLockfile) { + process.stderr.write('Warning: --experimental-lockfile is deprecated. Lockfiles are now enabled by default.\n'); + } + if (experimentalFrozenLockfile) { + process.stderr.write('Warning: --experimental-frozen-lockfile is deprecated. Use --frozen-lockfile instead.\n'); + } +} diff --git a/src/spec-node/disallowedFeatures.ts b/src/spec-node/disallowedFeatures.ts index 805ddca51..4f7b08a8a 100644 --- a/src/spec-node/disallowedFeatures.ts +++ b/src/spec-node/disallowedFeatures.ts @@ -40,6 +40,11 @@ export async function ensureNoDisallowedFeatures(params: DockerCLIParameters, co const documentationURL = d.disallowedFeatureEntry.documentationURL; throw new ContainerError({ description: `Cannot use the '${d.configFeatureId}' Feature since it was reported to be problematic. Please remove this Feature from your configuration and rebuild any dev container using it before continuing.${stopped ? ' The existing dev container was stopped.' : ''}${documentationURL ? ` See ${documentationURL} to learn more.` : ''}`, + data: { + disallowedFeatureId: d.configFeatureId, + didStopContainer: stopped, + learnMoreUrl: documentationURL, + }, }); } diff --git a/src/spec-node/dockerCompose.ts b/src/spec-node/dockerCompose.ts index c209fe95b..8093464cc 100644 --- a/src/spec-node/dockerCompose.ts +++ b/src/spec-node/dockerCompose.ts @@ -6,12 +6,12 @@ import * as yaml from 'js-yaml'; import * as shellQuote from 'shell-quote'; -import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, DockerResolverParameters, inspectDockerImage, getEmptyContextFolder, getFolderImageName, SubstitutedConfig, checkDockerSupportForGPU } from './utils'; +import { createContainerProperties, startEventSeen, ResolverResult, getTunnelInformation, DockerResolverParameters, inspectDockerImage, getEmptyContextFolder, getFolderImageName, SubstitutedConfig, checkDockerSupportForGPU, isBuildKitImagePolicyError } from './utils'; import { ContainerProperties, setupInContainer, ResolverProgress } from '../spec-common/injectHeadless'; import { ContainerError } from '../spec-common/errors'; import { Workspace } from '../spec-utils/workspaces'; import { equalPaths, parseVersion, isEarlierVersion, CLIHost } from '../spec-common/commonUtils'; -import { ContainerDetails, inspectContainer, listContainers, DockerCLIParameters, dockerCLI, dockerComposeCLI, dockerComposePtyCLI, PartialExecParameters, DockerComposeCLI, ImageDetails, toExecParameters, toPtyExecParameters } from '../spec-shutdown/dockerUtils'; +import { ContainerDetails, inspectContainer, listContainers, DockerCLIParameters, dockerComposeCLI, dockerComposePtyCLI, PartialExecParameters, DockerComposeCLI, ImageDetails, toExecParameters, toPtyExecParameters, removeContainer } from '../spec-shutdown/dockerUtils'; import { DevContainerFromDockerComposeConfig, getDockerComposeFilePaths } from '../spec-configuration/configuration'; import { Log, LogLevel, makeLog, terminalEscapeSequences } from '../spec-utils/log'; import { getExtendImageBuildInfo, updateRemoteUserUID } from './containerFeatures'; @@ -19,6 +19,7 @@ import { Mount, parseMount } from '../spec-configuration/containerFeaturesConfig import path from 'path'; import { getDevcontainerMetadata, getImageBuildInfoFromDockerfile, getImageBuildInfoFromImage, getImageMetadataFromContainer, ImageBuildInfo, lifecycleCommandOriginMapFromMetadata, mergeConfiguration, MergedDevContainerConfig } from './imageMetadata'; import { ensureDockerfileHasFinalStageName } from './dockerfileUtils'; +import { randomUUID } from 'crypto'; const projectLabel = 'com.docker.compose.project'; const serviceLabel = 'com.docker.compose.service'; @@ -26,7 +27,7 @@ const serviceLabel = 'com.docker.compose.service'; export async function openDockerComposeDevContainer(params: DockerResolverParameters, workspace: Workspace, config: SubstitutedConfig, idLabels: string[], additionalFeatures: Record>): Promise { const { common, dockerCLI, dockerComposeCLI } = params; const { cliHost, env, output } = common; - const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output }; + const buildParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; return _openDockerComposeDevContainer(params, buildParams, workspace, config, getRemoteWorkspaceFolder(config.config), idLabels, additionalFeatures); } @@ -42,7 +43,8 @@ async function _openDockerComposeDevContainer(params: DockerResolverParameters, const composeFiles = await getDockerComposeFilePaths(buildCLIHost, config, buildCLIHost.env, buildCLIHost.cwd); const cwdEnvFile = buildCLIHost.path.join(buildCLIHost.cwd, '.env'); const envFile = Array.isArray(config.dockerComposeFile) && config.dockerComposeFile.length === 0 && await buildCLIHost.isFile(cwdEnvFile) ? cwdEnvFile : undefined; - const projectName = await getProjectName(buildParams, workspace, composeFiles); + const composeConfig = await readDockerComposeConfig(buildParams, composeFiles, envFile); + const projectName = await getProjectName(buildParams, workspace, composeFiles, composeConfig); const containerId = await findComposeContainer(params, projectName, config.service); if (params.expectExistingContainer && !containerId) { throw new ContainerError({ description: 'The expected container does not exist.' }); @@ -52,14 +54,14 @@ async function _openDockerComposeDevContainer(params: DockerResolverParameters, if (container && (params.removeOnStartup === true || params.removeOnStartup === container.Id)) { const text = 'Removing existing container.'; const start = common.output.start(text); - await dockerCLI(params, 'rm', '-f', container.Id); + await removeContainer(params, container.Id); common.output.stop(text, start); container = undefined; } // let collapsedFeaturesConfig: CollapsedFeaturesConfig | undefined; if (!container || container.State.Status !== 'running') { - const res = await startContainer(params, buildParams, configWithRaw, projectName, composeFiles, envFile, container, idLabels, additionalFeatures); + const res = await startContainer(params, buildParams, configWithRaw, projectName, composeFiles, envFile, composeConfig, container, idLabels, additionalFeatures); container = await inspectContainer(params, res.containerId); // collapsedFeaturesConfig = res.collapsedFeaturesConfig; // } else { @@ -74,12 +76,15 @@ async function _openDockerComposeDevContainer(params: DockerResolverParameters, const { remoteEnv: extensionHostEnv, - } = await setupInContainer(common, containerProperties, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); + updatedConfig, + updatedMergedConfig, + } = await setupInContainer(common, containerProperties, config, mergedConfig, lifecycleCommandOriginMapFromMetadata(imageMetadata)); return { params: common, properties: containerProperties, - config, + config: updatedConfig, + mergedConfig: updatedMergedConfig, resolvedAuthority: { extensionHostEnv, }, @@ -150,7 +155,7 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf const { cliHost, env, output } = common; const { config } = configWithRaw; - const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc, env, output }; + const cliParams: DockerCLIParameters = { cliHost, dockerCLI, dockerComposeCLI: dockerComposeCLIFunc, env, output, buildPlatformInfo: params.buildPlatformInfo, targetPlatformInfo: params.targetPlatformInfo }; const composeConfig = await readDockerComposeConfig(cliParams, localComposeFiles, envFile); const composeService = composeConfig.services[config.service]; @@ -182,8 +187,10 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf } // determine whether we need to extend with features - const noBuildKitParams = { ...params, buildKitVersion: null }; // skip BuildKit -> can't set additional build contexts with compose - const extendImageBuildInfo = await getExtendImageBuildInfo(noBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer); + const version = parseVersion((await params.dockerComposeCLI()).version); + const supportsAdditionalBuildContexts = !params.isPodman && version && !isEarlierVersion(version, [2, 17, 0]); + const optionalBuildKitParams = supportsAdditionalBuildContexts ? params : { ...params, buildKitVersion: undefined }; + const extendImageBuildInfo = await getExtendImageBuildInfo(optionalBuildKitParams, configWithRaw, baseName, imageBuildInfo, composeService.user, additionalFeatures, canAddLabelsToContainer); let overrideImageName: string | undefined; let buildOverrideContent = ''; @@ -229,6 +236,13 @@ export async function buildAndExtendDockerCompose(configWithRaw: SubstitutedConf buildOverrideContent += ` - ${buildArg}=${featureBuildInfo.buildArgs[buildArg]}\n`; } } + + if (Object.keys(featureBuildInfo.buildKitContexts).length > 0) { + buildOverrideContent += ' additional_contexts:\n'; + for (const buildKitContext in featureBuildInfo.buildKitContexts) { + buildOverrideContent += ` - ${buildKitContext}=${featureBuildInfo.buildKitContexts[buildKitContext]}\n`; + } + } } // Generate the docker-compose override and build @@ -274,6 +288,10 @@ ${cacheFromOverrideContent} await dockerComposeCLI(infoParams, ...args); } } catch (err) { + if (isBuildKitImagePolicyError(err)) { + throw new ContainerError({ description: 'Could not resolve image due to policy.', originalError: err, data: { fileWithError: localComposeFiles[0] } }); + } + throw err instanceof ContainerError ? err : new ContainerError({ description: 'An error occurred building the Docker Compose images.', originalError: err, data: { fileWithError: localComposeFiles[0] } }); } } @@ -313,7 +331,8 @@ async function checkForPersistedFile(cliHost: CLIHost, output: Log, files: strin foundLabel: false }; } -async function startContainer(params: DockerResolverParameters, buildParams: DockerCLIParameters, configWithRaw: SubstitutedConfig, projectName: string, composeFiles: string[], envFile: string | undefined, container: ContainerDetails | undefined, idLabels: string[], additionalFeatures: Record>) { + +async function startContainer(params: DockerResolverParameters, buildParams: DockerCLIParameters, configWithRaw: SubstitutedConfig, projectName: string, composeFiles: string[], envFile: string | undefined, composeConfig: any, container: ContainerDetails | undefined, idLabels: string[], additionalFeatures: Record>) { const { common } = params; const { persistedFolder, output } = common; const { cliHost: buildCLIHost } = buildParams; @@ -323,15 +342,13 @@ async function startContainer(params: DockerResolverParameters, buildParams: Doc common.progress(ResolverProgress.StartingContainer); - const localComposeFiles = composeFiles; // If dockerComposeFile is an array, add -f in order. https://docs.docker.com/compose/extends/#multiple-compose-files - const composeGlobalArgs = ([] as string[]).concat(...localComposeFiles.map(composeFile => ['-f', composeFile])); + const composeGlobalArgs = ([] as string[]).concat(...composeFiles.map(composeFile => ['-f', composeFile])); if (envFile) { composeGlobalArgs.push('--env-file', envFile); } const infoOutput = makeLog(buildParams.output, LogLevel.Info); - const composeConfig = await readDockerComposeConfig(buildParams, localComposeFiles, envFile); const services = Object.keys(composeConfig.services || {}); if (services.indexOf(config.service) === -1) { throw new ContainerError({ description: `Service '${config.service}' configured in devcontainer.json not found in Docker Compose configuration.`, data: { fileWithError: composeFiles[0] } }); @@ -377,9 +394,9 @@ async function startContainer(params: DockerResolverParameters, buildParams: Doc if (!container || !didRestoreFromPersistedShare) { const noBuild = !!container; //if we have an existing container, just recreate override files but skip the build - const versionPrefix = await readVersionPrefix(buildCLIHost, localComposeFiles); + const versionPrefix = await readVersionPrefix(buildCLIHost, composeFiles); const infoParams = { ...params, common: { ...params.common, output: infoOutput } }; - const { imageMetadata, additionalComposeOverrideFiles, overrideImageName, labels } = await buildAndExtendDockerCompose(configWithRaw, projectName, infoParams, localComposeFiles, envFile, composeGlobalArgs, config.runServices ?? [], params.buildNoCache ?? false, persistedFolder, featuresBuildOverrideFilePrefix, versionPrefix, additionalFeatures, true, params.additionalCacheFroms, noBuild); + const { imageMetadata, additionalComposeOverrideFiles, overrideImageName, labels } = await buildAndExtendDockerCompose(configWithRaw, projectName, infoParams, composeFiles, envFile, composeGlobalArgs, config.runServices ?? [], params.buildNoCache ?? false, persistedFolder, featuresBuildOverrideFilePrefix, versionPrefix, additionalFeatures, true, params.additionalCacheFroms, noBuild); additionalComposeOverrideFiles.forEach(overrideFilePath => composeGlobalArgs.push('-f', overrideFilePath)); const currentImageName = overrideImageName || originalImageName; @@ -393,7 +410,7 @@ async function startContainer(params: DockerResolverParameters, buildParams: Doc // Note: As a fallback, persistedFolder is set to the build's tmpDir() directory const additionalLabels = labels ? idLabels.concat(Object.keys(labels).map(key => `${key}=${labels[key]}`)) : idLabels; const overrideFilePath = await writeFeaturesComposeOverrideFile(updatedImageName, currentImageName, mergedConfig, config, versionPrefix, imageDetails, service, additionalLabels, params.additionalMounts, persistedFolder, featuresStartOverrideFilePrefix, buildCLIHost, params, output); - + if (overrideFilePath) { // Add file path to override file as parameter composeGlobalArgs.push('-f', overrideFilePath); @@ -419,7 +436,13 @@ async function startContainer(params: DockerResolverParameters, buildParams: Doc } } catch (err) { cancel!(); - throw new ContainerError({ description: 'An error occurred starting Docker Compose up.', originalError: err, data: { fileWithError: localComposeFiles[0] } }); + + let description = 'An error occurred starting Docker Compose up.'; + if (err?.cmdOutput?.includes('Cannot create container for service app: authorization denied by plugin')) { + description = err.cmdOutput; + } + + throw new ContainerError({ description, originalError: err, data: { fileWithError: composeFiles[0] } }); } await started; @@ -464,7 +487,7 @@ async function writeFeaturesComposeOverrideFile( if (overrideFileHasContents) { output.write(`Docker Compose override file for creating container:\n${composeOverrideContent}`); - const fileName = `${overrideFilePrefix}-${Date.now()}.yml`; + const fileName = `${overrideFilePrefix}-${Date.now()}-${randomUUID()}.yml`; const composeFolder = buildCLIHost.path.join(overrideFilePath, 'docker-compose'); const composeOverrideFile = buildCLIHost.path.join(composeFolder, fileName); output.write(`Writing ${fileName} to ${composeFolder}`); @@ -534,7 +557,7 @@ while sleep 1 & wait $$!; do :; done", "-"${userEntrypoint.map(a => `, ${JSON.st init: true` : ''}${user ? ` user: ${user}` : ''}${Object.keys(env).length ? ` environment:${Object.keys(env).map(key => ` - - ${key}=${env[key]}`).join('')}` : ''}${mergedConfig.privileged ? ` + - '${key}=${String(env[key]).replace(/\n/g, '\\n').replace(/\$/g, '$$$$').replace(/'/g, '\'\'')}'`).join('')}` : ''}${mergedConfig.privileged ? ` privileged: true` : ''}${capAdd.length ? ` cap_add:${capAdd.map(cap => ` - ${cap}`).join('')}` : ''}${securityOpts.length ? ` @@ -612,7 +635,7 @@ export async function findComposeContainer(params: DockerCLIParameters | DockerR return list && list[0]; } -export async function getProjectName(params: DockerCLIParameters | DockerResolverParameters, workspace: Workspace, composeFiles: string[]) { +export async function getProjectName(params: DockerCLIParameters | DockerResolverParameters, workspace: Workspace, composeFiles: string[], composeConfig: any) { const { cliHost } = 'cliHost' in params ? params : params.common; const newProjectName = await useNewProjectName(params); const envName = toProjectName(cliHost.env.COMPOSE_PROJECT_NAME || '', newProjectName); @@ -633,6 +656,23 @@ export async function getProjectName(params: DockerCLIParameters | DockerResolve throw err; } } + if (composeConfig?.name) { + if (composeConfig.name !== 'devcontainer') { + return toProjectName(composeConfig.name, newProjectName); + } + // Check if 'devcontainer' is from a compose file or just the default. + for (let i = composeFiles.length - 1; i >= 0; i--) { + try { + const fragment = yaml.load((await cliHost.readFile(composeFiles[i])).toString()) || {} as any; + if (fragment.name) { + // Use composeConfig.name ('devcontainer') because fragment.name could include environment variables. + return toProjectName(composeConfig.name, newProjectName); + } + } catch (error) { + // Ignore when parsing fails due to custom yaml tags (e.g., !reset) + } + } + } const configDir = workspace.configFolderPath; const workingDir = composeFiles[0] ? cliHost.path.dirname(composeFiles[0]) : cliHost.cwd; // From https://github.com/docker/compose/blob/79557e3d3ab67c3697641d9af91866d7e400cfeb/compose/config/config.py#L290 if (equalPaths(cliHost.platform, workingDir, cliHost.path.join(configDir, '.devcontainer'))) { @@ -665,22 +705,19 @@ export function dockerComposeCLIConfig(params: Omit; return () => { return result || (result = (async () => { - let v2 = false; + let v2 = true; let stdout: Buffer; try { stdout = (await dockerComposeCLI({ ...params, - cmd: dockerComposeCLICmd, - }, 'version', '--short')).stdout; + cmd: dockerCLICmd, + }, 'compose', 'version', '--short')).stdout; } catch (err) { - if (err?.code !== 'ENOENT') { - throw err; - } stdout = (await dockerComposeCLI({ ...params, - cmd: dockerCLICmd, - }, 'compose', 'version', '--short')).stdout; - v2 = true; + cmd: dockerComposeCLICmd, + }, 'version', '--short')).stdout; + v2 = false; } const version = stdout.toString().trim(); params.output.write(`Docker Compose version: ${version}`); @@ -695,7 +732,7 @@ export function dockerComposeCLIConfig(params: Omit\s*FROM.*)/, 'gmi'); const parseFromLine = /FROM\s+(?--platform=\S+\s+)?(?"?[^\s]+"?)(\s+AS\s+(?