From 48c5a74303c420790b6610dd39ff32a2ce0c6e6b Mon Sep 17 00:00:00 2001
From: Julian Pawlowski <75446+jpawlowski@users.noreply.github.com>
Date: Thu, 25 Jul 2024 23:43:49 +0200
Subject: [PATCH] Initial commit
---
.devcontainer/devcontainer.json | 39 +
.editorconfig | 64 ++
.gitattributes | 41 ++
.github/workflows/release.yaml | 47 ++
.github/workflows/test.yaml | 60 ++
.github/workflows/validate.yml | 16 +
.gitignore | 665 ++++++++++++++++++
.markdownlint.jsonc | 22 +
.vscode/settings.json | 10 +
LICENSE.txt | 21 +
README.md | 67 ++
scripts/test-feature-autogenerated.sh | 15 +
scripts/test-feature-scenarios.sh | 17 +
scripts/test-feature.sh | 15 +
src/cli-microsoft365/NOTES.md | 0
.../devcontainer-feature.json | 68 ++
src/cli-microsoft365/install.sh | 136 ++++
src/pnp.powershell/devcontainer-feature.json | 44 ++
src/pnp.powershell/install.sh | 45 ++
src/powershell-extended/NOTES.md | 57 ++
.../devcontainer-feature.json | 61 ++
src/powershell-extended/install.sh | 322 +++++++++
src/powershell-extended/lib.sh | 286 ++++++++
test/_global/.gitkeep | 0
test/cli-microsoft365/test.sh | 13 +
test/pnp.powershell/test.sh | 13 +
test/powershell/Test-Profile.ps1 | 1 +
.../install_powershell_fallback_test.sh | 26 +
test/powershell/install_powershell_profile.sh | 13 +
test/powershell/install_resources.sh | 14 +
test/powershell/install_resources_version.sh | 14 +
.../install_resources_version_range.sh | 14 +
test/powershell/lib.sh | 168 +++++
test/powershell/register_repositories.sh | 21 +
test/powershell/scenarios.json | 48 ++
test/powershell/test.sh | 13 +
36 files changed, 2476 insertions(+)
create mode 100644 .devcontainer/devcontainer.json
create mode 100644 .editorconfig
create mode 100644 .gitattributes
create mode 100644 .github/workflows/release.yaml
create mode 100644 .github/workflows/test.yaml
create mode 100644 .github/workflows/validate.yml
create mode 100644 .gitignore
create mode 100644 .markdownlint.jsonc
create mode 100644 .vscode/settings.json
create mode 100644 LICENSE.txt
create mode 100644 README.md
create mode 100755 scripts/test-feature-autogenerated.sh
create mode 100755 scripts/test-feature-scenarios.sh
create mode 100755 scripts/test-feature.sh
create mode 100644 src/cli-microsoft365/NOTES.md
create mode 100644 src/cli-microsoft365/devcontainer-feature.json
create mode 100755 src/cli-microsoft365/install.sh
create mode 100644 src/pnp.powershell/devcontainer-feature.json
create mode 100755 src/pnp.powershell/install.sh
create mode 100644 src/powershell-extended/NOTES.md
create mode 100644 src/powershell-extended/devcontainer-feature.json
create mode 100755 src/powershell-extended/install.sh
create mode 100644 src/powershell-extended/lib.sh
create mode 100644 test/_global/.gitkeep
create mode 100755 test/cli-microsoft365/test.sh
create mode 100755 test/pnp.powershell/test.sh
create mode 100644 test/powershell/Test-Profile.ps1
create mode 100755 test/powershell/install_powershell_fallback_test.sh
create mode 100755 test/powershell/install_powershell_profile.sh
create mode 100755 test/powershell/install_resources.sh
create mode 100755 test/powershell/install_resources_version.sh
create mode 100755 test/powershell/install_resources_version_range.sh
create mode 100644 test/powershell/lib.sh
create mode 100755 test/powershell/register_repositories.sh
create mode 100644 test/powershell/scenarios.json
create mode 100755 test/powershell/test.sh
diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
new file mode 100644
index 0000000..eff8ad3
--- /dev/null
+++ b/.devcontainer/devcontainer.json
@@ -0,0 +1,39 @@
+{
+ "name": "🚧 devcontainer-features",
+ "image": "mcr.microsoft.com/devcontainers/base:debian",
+ "customizations": {
+ "vscode": {
+ "settings": {
+ "terminal.integrated.defaultProfile.linux": "bash",
+ "json.schemas": [
+ {
+ "fileMatch": [
+ "*/devcontainer-feature.json"
+ ],
+ "url": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainerFeature.schema.json"
+ }
+ ]
+ },
+ "extensions": [
+ "lizebang.bash-extension-pack",
+ "ms-python.python",
+ "ms-python.vscode-pylance",
+ "DavidAnson.vscode-markdownlint",
+ "github.vscode-github-actions"
+ ]
+ }
+ },
+ "features": {
+ "ghcr.io/devcontainers/features/common-utils:2": {
+ "installZsh": "false",
+ "configureZshAsDefaultShell": "false",
+ "installOhMyZsh": "false"
+ },
+ "ghcr.io/devcontainers/features/docker-in-docker:2": {},
+ "ghcr.io/devcontainers/features/github-cli:1": {},
+ "ghcr.io/devcontainers/features/node:1": {},
+ "ghcr.io/devcontainers/features/powershell:1": {},
+ "ghcr.io/devcontainers/features/python:1": {}
+ },
+ "postCreateCommand": "npm install -g @devcontainers/cli"
+}
\ No newline at end of file
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..2c0cbc3
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,64 @@
+# EditorConfig is awesome: https://EditorConfig.org
+
+# top-most EditorConfig file
+root = true
+
+# Unix-style newlines with a newline ending every file
+[*]
+end_of_line = lf
+
+# PowerShell files
+[*.{ps1,psd1,psm1,ps1xml,psc1,clixml}]
+end_of_line = lf
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+# Windows script and batch files
+[*.{cmd,bat}]
+end_of_line = crlf
+indent_style = space
+indent_size = 4
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+# Markdown files
+[*.md]
+trim_trailing_whitespace = false
+insert_final_newline = true
+
+# CSV and Text files
+[*.{csv,txt}]
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+# JSON and YAML files
+[*.{json,yml}]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+# XML files
+[*.xml]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+insert_final_newline = true
+
+# VS Code workspace files
+[*.code-workspace]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+# Matches the exact file .editorconfig
+[.editorconfig]
+indent_style = space
+indent_size = 2
+trim_trailing_whitespace = true
+
+# Docker
+[Dockerfile]
+indent_style = space
+indent_size = 4
diff --git a/.gitattributes b/.gitattributes
new file mode 100644
index 0000000..32f8b5e
--- /dev/null
+++ b/.gitattributes
@@ -0,0 +1,41 @@
+# Set the default behavior, in case people don't have core.autocrlf set.
+* text=auto
+
+# PowerShell files will always have LF line endings on checkout and in the repository.
+*.ps1 text eol=lf
+*.psd1 text eol=lf
+*.psm1 text eol=lf
+*.ps1xml text eol=lf
+*.psc1 text eol=lf
+*.clixml text eol=lf
+
+# Windows script and batch files will always have CRLF line endings on checkout and in the repository.
+*.cmd text eol=crlf
+*.bat text eol=crlf
+
+# Markdown, CSV, and Text files will always have LF line endings on checkout and in the repository.
+*.md text eol=lf
+*.csv text eol=lf
+*.txt text eol=lf
+
+# JSON and YAML files will always have LF line endings on checkout and in the repository.
+*.json text eol=lf
+*.yml text eol=lf
+*.yaml text eol=lf
+
+# XML files will always have LF line endings on checkout and in the repository.
+*.xml text eol=lf
+
+# Set svg to binary type, as SVG is unlikely to be edited by hand. Can be treated as checked in blob
+*.svg binary
+
+# Denote all files that are truly binary and should not be modified.
+*.png binary
+*.jpg binary
+*.jpeg binary
+*.gif binary
+*.pdf binary
+*.zip binary
+*.gz binary
+*.tar binary
+*.exe binary
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
new file mode 100644
index 0000000..d0ed9ca
--- /dev/null
+++ b/.github/workflows/release.yaml
@@ -0,0 +1,47 @@
+name: "Release dev container features & Generate Documentation"
+on:
+ workflow_dispatch:
+
+jobs:
+ deploy:
+ if: ${{ github.ref == 'refs/heads/main' }}
+ runs-on: ubuntu-latest
+ permissions:
+ contents: write
+ pull-requests: write
+ packages: write
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: "Publish Features"
+ uses: devcontainers/action@v1
+ with:
+ publish-features: "true"
+ base-path-to-features: "./src"
+ generate-docs: "true"
+
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Create PR for Documentation
+ id: push_image_info
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -e
+ echo "Start."
+ # Configure git and Push updates
+ git config --global user.email github-actions[bot]@users.noreply.github.com
+ git config --global user.name github-actions[bot]
+ git config pull.rebase false
+ branch=automated-documentation-update-$GITHUB_RUN_ID
+ git checkout -b $branch
+ message='Automated documentation update'
+ # Add / update and commit
+ git add */**/README.md
+ git commit -m 'Automated documentation update [skip ci]' || export NO_UPDATES=true
+ # Push
+ if [ "$NO_UPDATES" != "true" ] ; then
+ git push origin "$branch"
+ gh pr create --title "$message" --body "$message"
+ fi
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
new file mode 100644
index 0000000..5d28b63
--- /dev/null
+++ b/.github/workflows/test.yaml
@@ -0,0 +1,60 @@
+name: "CI - Test Features"
+on:
+ push:
+ branches:
+ - main
+ pull_request:
+ workflow_dispatch:
+
+jobs:
+ test-autogenerated:
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ strategy:
+ matrix:
+ features:
+ - cli-microsoft365
+ - pnp.powershell
+ - powershell-extended
+ baseImage:
+ - debian:latest
+ - ubuntu:latest
+ - mcr.microsoft.com/devcontainers/base:ubuntu
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: "Install latest devcontainer CLI"
+ run: npm install -g @devcontainers/cli
+
+ - name: "Generating tests for '${{ matrix.features }}' against '${{ matrix.baseImage }}'"
+ run: devcontainer features test --skip-scenarios -f ${{ matrix.features }} -i ${{ matrix.baseImage }} .
+
+ test-scenarios:
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ strategy:
+ matrix:
+ features:
+ - cli-microsoft365
+ - pnp.powershell
+ - powershell-extended
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: "Install latest devcontainer CLI"
+ run: npm install -g @devcontainers/cli
+
+ - name: "Generating tests for '${{ matrix.features }}' scenarios"
+ run: devcontainer features test -f ${{ matrix.features }} --skip-autogenerated --skip-duplicated .
+
+ test-global:
+ runs-on: ubuntu-latest
+ continue-on-error: true
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: "Install latest devcontainer CLI"
+ run: npm install -g @devcontainers/cli
+
+ - name: "Testing global scenarios"
+ run: devcontainer features test --global-scenarios-only .
diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml
new file mode 100644
index 0000000..863418e
--- /dev/null
+++ b/.github/workflows/validate.yml
@@ -0,0 +1,16 @@
+name: "Validate devcontainer-feature.json files"
+on:
+ workflow_dispatch:
+ pull_request:
+
+jobs:
+ validate:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: "Validate devcontainer-feature.json files"
+ uses: devcontainers/action@v1
+ with:
+ validate-only: "true"
+ base-path-to-features: "./src"
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..7e937dc
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,665 @@
+# Created by https://www.toptal.com/developers/gitignore/api/git,linux,macos,dotenv,windows,powershell,visualstudiocode,compressedarchive,node,go,eclipse,jetbrains,emacs,mercurial,vagrant
+# Edit at https://www.toptal.com/developers/gitignore?templates=git,linux,macos,dotenv,windows,powershell,visualstudiocode,compressedarchive,node,go,eclipse,jetbrains,emacs,mercurial,vagrant
+
+### CompressedArchive ###
+
+### Mostly from https://en.wikipedia.org/wiki/List_of_archive_formats
+
+## Archiving and compression
+# Open source file format. Used by 7-Zip.
+*.7z
+# Mac OS X, restoration on different platforms is possible although not immediate Yes Based on 7z. Preserves Spotlight metadata, resource forks, owner/group information, dates and other data which would be otherwise lost with compression.
+*.s7z
+# Old archive versions only Proprietary format
+*.ace
+# A format that compresses and doubly encrypt the data (AES256 and CAS256) avoiding brute force attacks, also hide files in an AFA file. It has two ways to safeguard data integrity and subsequent repair of the file if has an error (repair with AstroA2P (online) or Astrotite (offline)).
+*.afa
+# A mainly Korean format designed for very large archives.
+*.alz
+# Android application package (variant of JAR file format).
+*.apk
+# ??
+*.arc
+# Originally DOS, now multiple
+*.arj
+# Open archive format, used by B1 Free Archiver (http://dev.b1.org/standard/archive-format.html)
+*.b1
+# Binary Archive with external header
+*.ba
+# Proprietary format from the ZipTV Compression Components
+*.bh
+# The Microsoft Windows native archive format, which is also used by many commercial installers such as InstallShield and WISE.
+*.cab
+# Originally DOS, now DOS and Windows Created by Yaakov Gringeler; released last in 2003 (Compressia 1.0.0.1 beta), now apparently defunct. Free trial of 30 days lets user create and extract archives; after that it is possible to extract, but not to create.
+*.car
+# Open source file format.
+*.cfs
+# Compact Pro archive, a common archiver used on Mac platforms until about Mac OS 7.5.x. Competed with StuffIt; now obsolete.
+*.cpt
+# Windows, Unix-like, Mac OS X Open source file format. Files are compressed individually with either gzip, bzip2 or lzo.
+*.dar
+# DiskDoubler Mac OS obsolete
+*.dd
+# ??
+*.dgc
+# Apple Disk Image upports "Internet-enabled" disk images, which, once downloaded, are automatically decompressed, mounted, have the contents extracted, and thrown away. Currently, Safari is the only browser that supports this form of extraction; however, the images can be manually extracted as well. This format can also be password-protected or encrypted with 128-bit or 256-bit AES encryption.
+*.dmg
+# Enterprise Java Archive archive
+*.ear
+# ETSoft compressed archive
+*.egg
+# The predecessor of DGCA.
+*.gca
+# Originally DOS Yes, but may be covered by patents DOS era format; uses arithmetic/Markov coding
+*.ha
+# MS Windows HKI
+*.hki
+# Produced by ICEOWS program. Excels at text file compression.
+*.ice
+# Java archive, compatible with ZIP files
+*.jar
+# Open sourced archiver with compression using the PAQ family of algorithms and optional encryption.
+*.kgb
+# Originally DOS, now multiple Multiple Yes The standard format on Amiga.
+*.lzh
+*.lha
+# Archiver originally used on The Amiga. Now copied by Microsoft to use in their .cab and .chm files.
+*.lzx
+# file format from NoGate Consultings, a rival from ARC-Compressor.
+*.pak
+# A disk image archive format that supports several compression methods as well as splitting the archive into smaller pieces.
+*.partimg
+# An experimental open source packager (http://mattmahoney.net/dc)
+*.paq*
+# Open source archiver supporting authenticated encryption, volume spanning, customizable object level and volume level integrity checks (form CRCs to SHA-512 and Whirlpool hashes), fast deflate based compression
+*.pea
+# The format from the PIM - a freeware compression tool by Ilia Muraviev. It uses an LZP-based compression algorithm with set of filters for executable, image and audio files.
+*.pim
+# PackIt Mac OS obsolete
+*.pit
+# Used for data in games written using the Quadruple D library for Delphi. Uses byte pair compression.
+*.qda
+# A proprietary archive format, second in popularity to .zip files.
+*.rar
+# The format from a commercial archiving package. Odd among commercial packages in that they focus on incorporating experimental algorithms with the highest possible compression (at the expense of speed and memory), such as PAQ, PPMD and PPMZ (PPMD with unlimited-length strings), as well as a proprietary algorithms.
+*.rk
+# Self Dissolving ARChive Commodore 64, Commodore 128 Commodore 64, Commodore 128 Yes SDAs refer to Self Dissolving ARC files, and are based on the Commodore 64 and Commodore 128 versions of ARC, originally written by Chris Smeets. While the files share the same extension, they are not compatible between platforms. That is, an SDA created on a Commodore 64 but run on a Commodore 128 in Commodore 128 mode will crash the machine, and vice versa. The intended successor to SDA is SFX.
+*.sda
+# A pre-Mac OS X Self-Extracting Archive format. StuffIt, Compact Pro, Disk Doubler and others could create .sea files, though the StuffIt versions were the most common.
+*.sea
+# Scifer Archive with internal header
+*.sen
+# Commodore 64, Commodore 128 SFX is a Self Extracting Archive which uses the LHArc compression algorithm. It was originally developed by Chris Smeets on the Commodore platform, and runs primarily using the CS-DOS extension for the Commodore 128. Unlike its predecessor SDA, SFX files will run on both the Commodore 64 and Commodore 128 regardless of which machine they were created on.
+*.sfx
+# An archive format designed for the Apple II series of computers. The canonical implementation is ShrinkIt, which can operate on disk images as well as files. Preferred compression algorithm is a combination of RLE and 12-bit LZW. Archives can be manipulated with the command-line NuLib tool, or the Windows-based CiderPress.
+*.shk
+# A compression format common on Apple Macintosh computers. The free StuffIt Expander is available for Windows and OS X.
+*.sit
+# The replacement for the .sit format that supports more compression methods, UNIX file permissions, long file names, very large files, more encryption options, data specific compressors (JPEG, Zip, PDF, 24-bit image, MP3). The free StuffIt Expander is available for Windows and OS X.
+*.sitx
+# A royalty-free compressing format
+*.sqx
+# The "tarball" format combines tar archives with a file-based compression scheme (usually gzip). Commonly used for source and binary distribution on Unix-like platforms, widely available elsewhere.
+*.tar.gz
+*.tgz
+*.tar.Z
+*.tar.bz2
+*.tbz2
+*.tar.lzma
+*.tlz
+# UltraCompressor 2.3 was developed to act as an alternative to the then popular PKZIP application. The main feature of the application is its ability to create large archives. This means that compressed archives with the UC2 file extension can hold almost 1 million files.
+*.uc
+*.uc0
+*.uc2
+*.ucn
+*.ur2
+*.ue2
+# Based on PAQ, RZM, CSC, CCM, and 7zip. The format consists of a PAQ, RZM, CSC, or CCM compressed file and a manifest with compression settings stored in a 7z archive.
+*.uca
+# A high compression rate archive format originally for DOS.
+*.uha
+# Web Application archive (Java-based web app)
+*.war
+# File-based disk image format developed to deploy Microsoft Windows.
+*.wim
+# XAR
+*.xar
+# Native format of the Open Source KiriKiri Visual Novel engine. Uses combination of block splitting and zlib compression. The filenames and pathes are stored in UTF-16 format. For integrity check, the Adler-32 hashsum is used. For many commercial games, the files are encrypted (and decoded on runtime) via so-called "cxdec" module, which implements xor-based encryption.
+*.xp3
+# Yamazaki zipper archive. Compression format used in DeepFreezer archiver utility created by Yamazaki Satoshi. Read and write support exists in TUGZip, IZArc and ZipZag
+*.yz1
+# The most widely used compression format on Microsoft Windows. Commonly used on Macintosh and Unix systems as well.
+*.zip
+*.zipx
+# application/x-zoo zoo Multiple Multiple Yes
+*.zoo
+# Journaling (append-only) archive format with rollback capability. Supports deduplication and incremental update based on last-modified dates. Multi-threaded. Compresses in LZ77, BWT, and context mixing formats. Open source.
+*.zpaq
+# Archiver with a compression algorithm based on the Burrows-Wheeler transform method.
+*.zz
+
+
+### dotenv ###
+.env
+
+### Eclipse ###
+.metadata
+bin/
+tmp/
+*.tmp
+*.bak
+*.swp
+*~.nib
+local.properties
+.settings/
+.loadpath
+.recommenders
+
+# External tool builders
+.externalToolBuilders/
+
+# Locally stored "Eclipse launch configurations"
+*.launch
+
+# PyDev specific (Python IDE for Eclipse)
+*.pydevproject
+
+# CDT-specific (C/C++ Development Tooling)
+.cproject
+
+# CDT- autotools
+.autotools
+
+# Java annotation processor (APT)
+.factorypath
+
+# PDT-specific (PHP Development Tools)
+.buildpath
+
+# sbteclipse plugin
+.target
+
+# Tern plugin
+.tern-project
+
+# TeXlipse plugin
+.texlipse
+
+# STS (Spring Tool Suite)
+.springBeans
+
+# Code Recommenders
+.recommenders/
+
+# Annotation Processing
+.apt_generated/
+.apt_generated_test/
+
+# Scala IDE specific (Scala & Java development for Eclipse)
+.cache-main
+.scala_dependencies
+.worksheet
+
+# Uncomment this line if you wish to ignore the project description file.
+# Typically, this file would be tracked if it contains build/dependency configurations:
+#.project
+
+### Eclipse Patch ###
+# Spring Boot Tooling
+.sts4-cache/
+
+### Emacs ###
+# -*- mode: gitignore; -*-
+*~
+\#*\#
+/.emacs.desktop
+/.emacs.desktop.lock
+*.elc
+auto-save-list
+tramp
+.\#*
+
+# Org-mode
+.org-id-locations
+*_archive
+
+# flymake-mode
+*_flymake.*
+
+# eshell files
+/eshell/history
+/eshell/lastdir
+
+# elpa packages
+/elpa/
+
+# reftex files
+*.rel
+
+# AUCTeX auto folder
+/auto/
+
+# cask packages
+.cask/
+dist/
+
+# Flycheck
+flycheck_*.el
+
+# server auth directory
+/server/
+
+# projectiles files
+.projectile
+
+# directory configuration
+.dir-locals.el
+
+# network security
+/network-security.data
+
+
+### Git ###
+# Created by git for backups. To disable backups in Git:
+# $ git config --global mergetool.keepBackup false
+*.orig
+
+# Created by git when using merge tools for conflicts
+*.BACKUP.*
+*.BASE.*
+*.LOCAL.*
+*.REMOTE.*
+*_BACKUP_*.txt
+*_BASE_*.txt
+*_LOCAL_*.txt
+*_REMOTE_*.txt
+
+### Go ###
+# If you prefer the allow list template instead of the deny list, see community template:
+# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
+#
+# Binaries for programs and plugins
+*.exe
+*.exe~
+*.dll
+*.so
+*.dylib
+
+# Test binary, built with `go test -c`
+*.test
+
+# Output of the go coverage tool, specifically when used with LiteIDE
+*.out
+
+# Dependency directories (remove the comment below to include it)
+# vendor/
+
+# Go workspace file
+go.work
+
+### JetBrains ###
+# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
+# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
+
+# User-specific stuff
+.idea/**/workspace.xml
+.idea/**/tasks.xml
+.idea/**/usage.statistics.xml
+.idea/**/dictionaries
+.idea/**/shelf
+
+# AWS User-specific
+.idea/**/aws.xml
+
+# Generated files
+.idea/**/contentModel.xml
+
+# Sensitive or high-churn files
+.idea/**/dataSources/
+.idea/**/dataSources.ids
+.idea/**/dataSources.local.xml
+.idea/**/sqlDataSources.xml
+.idea/**/dynamic.xml
+.idea/**/uiDesigner.xml
+.idea/**/dbnavigator.xml
+
+# Gradle
+.idea/**/gradle.xml
+.idea/**/libraries
+
+# Gradle and Maven with auto-import
+# When using Gradle or Maven with auto-import, you should exclude module files,
+# since they will be recreated, and may cause churn. Uncomment if using
+# auto-import.
+# .idea/artifacts
+# .idea/compiler.xml
+# .idea/jarRepositories.xml
+# .idea/modules.xml
+# .idea/*.iml
+# .idea/modules
+# *.iml
+# *.ipr
+
+# CMake
+cmake-build-*/
+
+# Mongo Explorer plugin
+.idea/**/mongoSettings.xml
+
+# File-based project format
+*.iws
+
+# IntelliJ
+out/
+
+# mpeltonen/sbt-idea plugin
+.idea_modules/
+
+# JIRA plugin
+atlassian-ide-plugin.xml
+
+# Cursive Clojure plugin
+.idea/replstate.xml
+
+# SonarLint plugin
+.idea/sonarlint/
+
+# Crashlytics plugin (for Android Studio and IntelliJ)
+com_crashlytics_export_strings.xml
+crashlytics.properties
+crashlytics-build.properties
+fabric.properties
+
+# Editor-based Rest Client
+.idea/httpRequests
+
+# Android studio 3.1+ serialized cache file
+.idea/caches/build_file_checksums.ser
+
+### JetBrains Patch ###
+# Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
+
+# *.iml
+# modules.xml
+# .idea/misc.xml
+# *.ipr
+
+# Sonarlint plugin
+# https://plugins.jetbrains.com/plugin/7973-sonarlint
+.idea/**/sonarlint/
+
+# SonarQube Plugin
+# https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
+.idea/**/sonarIssues.xml
+
+# Markdown Navigator plugin
+# https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
+.idea/**/markdown-navigator.xml
+.idea/**/markdown-navigator-enh.xml
+.idea/**/markdown-navigator/
+
+# Cache file creation bug
+# See https://youtrack.jetbrains.com/issue/JBR-2257
+.idea/$CACHE_FILE$
+
+# CodeStream plugin
+# https://plugins.jetbrains.com/plugin/12206-codestream
+.idea/codestream.xml
+
+# Azure Toolkit for IntelliJ plugin
+# https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
+.idea/**/azureSettings.xml
+
+### Linux ###
+
+# temporary files which can be created if a process still has a handle open of a deleted file
+.fuse_hidden*
+
+# KDE directory preferences
+.directory
+
+# Linux trash folder which might appear on any partition or disk
+.Trash-*
+
+# .nfs files are created when an open file is removed but is still being accessed
+.nfs*
+
+### macOS ###
+# General
+.DS_Store
+.AppleDouble
+.LSOverride
+
+# Icon must end with two \r
+Icon
+
+
+# Thumbnails
+._*
+
+# Files that might appear in the root of a volume
+.DocumentRevisions-V100
+.fseventsd
+.Spotlight-V100
+.TemporaryItems
+.Trashes
+.VolumeIcon.icns
+.com.apple.timemachine.donotpresent
+
+# Directories potentially created on remote AFP share
+.AppleDB
+.AppleDesktop
+Network Trash Folder
+Temporary Items
+.apdisk
+
+### macOS Patch ###
+# iCloud generated files
+*.icloud
+
+### Mercurial ###
+.hg/
+.hgignore
+.hgsigs
+.hgsub
+.hgsubstate
+.hgtags
+
+### Node ###
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+lerna-debug.log*
+.pnpm-debug.log*
+
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+
+# nyc test coverage
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
+
+# Dependency directories
+node_modules/
+jspm_packages/
+
+# Snowpack dependency directory (https://snowpack.dev/)
+web_modules/
+
+# TypeScript cache
+*.tsbuildinfo
+
+# Optional npm cache directory
+.npm
+
+# Optional eslint cache
+.eslintcache
+
+# Optional stylelint cache
+.stylelintcache
+
+# Microbundle cache
+.rpt2_cache/
+.rts2_cache_cjs/
+.rts2_cache_es/
+.rts2_cache_umd/
+
+# Optional REPL history
+.node_repl_history
+
+# Output of 'npm pack'
+
+# Yarn Integrity file
+.yarn-integrity
+
+# dotenv environment variable files
+.env.development.local
+.env.test.local
+.env.production.local
+.env.local
+
+# parcel-bundler cache (https://parceljs.org/)
+.cache
+.parcel-cache
+
+# Next.js build output
+.next
+out
+
+# Nuxt.js build / generate output
+.nuxt
+dist
+
+# Gatsby files
+.cache/
+# Comment in the public line in if your project uses Gatsby and not Next.js
+# https://nextjs.org/blog/next-9-1#public-directory-support
+# public
+
+# vuepress build output
+.vuepress/dist
+
+# vuepress v2.x temp and cache directory
+.temp
+
+# Docusaurus cache and generated files
+.docusaurus
+
+# Serverless directories
+.serverless/
+
+# FuseBox cache
+.fusebox/
+
+# DynamoDB Local files
+.dynamodb/
+
+# TernJS port file
+.tern-port
+
+# Stores VSCode versions used for testing VSCode extensions
+.vscode-test
+
+# yarn v2
+.yarn/cache
+.yarn/unplugged
+.yarn/build-state.yml
+.yarn/install-state.gz
+.pnp.*
+
+### Node Patch ###
+# Serverless Webpack directories
+.webpack/
+
+# Optional stylelint cache
+
+# SvelteKit build / generate output
+.svelte-kit
+
+### PowerShell ###
+# Exclude packaged modules
+
+# Exclude .NET assemblies from source
+
+### Vagrant ###
+# General
+.vagrant/
+
+# Log files (if you are creating logs in debug mode, uncomment this)
+# *.log
+
+### Vagrant Patch ###
+*.box
+
+### VisualStudioCode ###
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+!.vscode/*.code-snippets
+
+# Local History for Visual Studio Code
+.history/
+
+# Built Visual Studio Code Extensions
+*.vsix
+
+### VisualStudioCode Patch ###
+# Ignore all local history of files
+.history
+.ionide
+
+### Windows ###
+# Windows thumbnail cache files
+Thumbs.db
+Thumbs.db:encryptable
+ehthumbs.db
+ehthumbs_vista.db
+
+# Dump file
+*.stackdump
+
+# Folder config file
+[Dd]esktop.ini
+
+# Recycle Bin used on file shares
+$RECYCLE.BIN/
+
+# Windows Installer files
+*.msi
+*.msix
+*.msm
+*.msp
+
+# Windows shortcuts
+*.lnk
+
+# End of https://www.toptal.com/developers/gitignore/api/git,linux,macos,dotenv,windows,powershell,visualstudiocode,compressedarchive,node,go,eclipse,jetbrains,emacs,mercurial,vagrant
diff --git a/.markdownlint.jsonc b/.markdownlint.jsonc
new file mode 100644
index 0000000..894f024
--- /dev/null
+++ b/.markdownlint.jsonc
@@ -0,0 +1,22 @@
+{
+ // Linter rules doc:
+ // - https://github.com/DavidAnson/markdownlint
+ "default": true,
+ "MD004": false, // Unordered list style
+ "MD007": {
+ "indent": 2 // Unordered list indentation
+ },
+ "MD009": false, // Trailing spaces
+ "MD013": {
+ "line_length": 120, // Line length
+ "tables": false // Ignore tables
+ },
+ "MD026": {
+ "punctuation": ".,;:!。,;:" // List of not allowed
+ },
+ "MD029": false, // Ordered list item prefix
+ "MD033": false, // Allow inline HTML
+ "MD036": false, // Emphasis used instead of a heading
+ "MD041": false, // First line in a file should be a top-level heading
+ "blank_lines": false // Error on blank lines
+}
\ No newline at end of file
diff --git a/.vscode/settings.json b/.vscode/settings.json
new file mode 100644
index 0000000..16b7aeb
--- /dev/null
+++ b/.vscode/settings.json
@@ -0,0 +1,10 @@
+{
+ "json.schemas": [
+ {
+ "fileMatch": [
+ "*/devcontainer-feature.json"
+ ],
+ "url": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainerFeature.schema.json"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..2151884
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright © Julian Pawlowski
+
+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.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..2afcf65
--- /dev/null
+++ b/README.md
@@ -0,0 +1,67 @@
+# Julian's Development Container Features
+
+
+  |
+
+ Julian's Development Container 'Features'
+ A fine selection of new or enhanced Features.
+ |
+
+
+Welcome to yet another DevContainer Features repository! This repository extends the official [`ghcr.io/devcontainers/features`](https://github.com/orgs/devcontainers/packages?repo_name=features) main repository
+and contains a collection of features to enhance your development environment within a [DevContainer](https://containers.dev/).
+
+You may learn about Features at [containers.dev](https://containers.dev/implementors/features/), which is the website for the dev container specification.
+
+## Features
+
+Below are the features currently available in this repository:
+
+| Feature Name | Description | Documentation |
+| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------: |
+| CLI Microsoft 365 | CLI for Microsoft 365 is a cross-platform CLI that allows users on any platform to manage various configuration settings of Microsoft 365. | [📚 Link](./src/cli-microsoft365/) |
+| PnP.PowerShell | PnP PowerShell is a cross-platform PowerShell module that allows users on any platform to manage various configuration settings of Microsoft 365. | [📚 Link](./src/cli-microsoft365/) |
+| PowerShell Extended | Installs PowerShell on AMD64 and ARM64 machines, and optional additional resources from the PowerShell Gallery using PSResourceGet. It also supports advanced installation options. | [📚 Link](./src/cli-microsoft365/) |
+
+'Features' are self-contained units of installation code and development container configuration. Features are designed
+to install atop a wide-range of base container images.
+
+## Usage
+
+To reference a Feature from this repository, add the desired Features to a `devcontainer.json`. Each Feature has a `README.md` that shows how to reference the Feature and which options are available for that Feature.
+
+The example below installs the`powershell-extended` feature declared in the [`./src`](./src) directory of this
+repository.
+
+See the relevant Feature's README for supported options.
+
+```jsonc
+"name": "my-project-devcontainer",
+"image": "mcr.microsoft.com/devcontainers/base:ubuntu", // Any generic, debian-based image.
+"features": {
+ "ghcr.io/jpawlowski/devcontainer-features/powershell-extended:2": {
+ "version": "7.4"
+ }
+}
+```
+
+The `:latest` version annotation is added implicitly if omitted. To pin to a specific package version
+([example](https://github.com/jpawlowski/devcontainer-features/pkgs/container/features/powershell-extended/versions)), append it to the end of the
+Feature. Features follow semantic versioning conventions, so you can pin to a major version `:2`, minor version `:2.0`, or patch version `:2.0.0` by specifying the appropriate label.
+
+```jsonc
+"features": {
+ "ghcr.io/jpawlowski/devcontainer-features/powershell-extended:2.0.0": {
+ "version": "7.4"
+ }
+}
+```
+
+## Contributing to this repository
+
+This repository will accept improvement and bug fix contributions related to the
+[current set of maintained Features](./src).
+
+🤝 You can read more about how to contribute in [`CONTRIBUTING.md`]. ❤️
+
+[`CONTRIBUTING.md`]: CONTRIBUTING.md
diff --git a/scripts/test-feature-autogenerated.sh b/scripts/test-feature-autogenerated.sh
new file mode 100755
index 0000000..0489d3b
--- /dev/null
+++ b/scripts/test-feature-autogenerated.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+# Description: Run the autogenerated tests for the specified features
+
+set -e
+current_dir=$(pwd)
+trap 'cd "$current_dir"' EXIT
+cd "$(dirname "$0")/.." || exit 1
+
+# Run the tests
+if [ -n "$1" ]; then
+ devcontainer features test --skip-scenarios --skip-duplicated --features "$@" | tee /dev/null
+else
+ devcontainer features test --skip-scenarios --skip-duplicated | tee /dev/null
+fi
diff --git a/scripts/test-feature-scenarios.sh b/scripts/test-feature-scenarios.sh
new file mode 100755
index 0000000..10257d2
--- /dev/null
+++ b/scripts/test-feature-scenarios.sh
@@ -0,0 +1,17 @@
+#!/usr/bin/env bash
+
+# Description: Run the autogenerated tests for the specified features
+
+set -e
+current_dir=$(pwd)
+trap 'cd "$current_dir"' EXIT
+cd "$(dirname "$0")/.." || exit 1
+
+# Run the tests
+if [ -n "$2" ]; then
+ devcontainer features test --skip-autogenerated --skip-duplicated --log-level trace --features "$1" --filter "$2" | tee /dev/null
+elif [ -n "$1" ]; then
+ devcontainer features test --skip-autogenerated --skip-duplicated --log-level trace --features "$@" | tee /dev/null
+else
+ devcontainer features test --skip-autogenerated --skip-duplicated --log-level trace | tee /dev/null
+fi
diff --git a/scripts/test-feature.sh b/scripts/test-feature.sh
new file mode 100755
index 0000000..532ff27
--- /dev/null
+++ b/scripts/test-feature.sh
@@ -0,0 +1,15 @@
+#!/usr/bin/env bash
+
+# Description: Run the tests for the specified features
+
+set -e
+current_dir=$(pwd)
+trap 'cd "$current_dir"' EXIT
+cd "$(dirname "$0")/.." || exit 1
+
+# Run the tests
+if [ -n "$1" ]; then
+ devcontainer features test --features "$@" | tee /dev/null
+else
+ devcontainer features test | tee /dev/null
+fi
diff --git a/src/cli-microsoft365/NOTES.md b/src/cli-microsoft365/NOTES.md
new file mode 100644
index 0000000..e69de29
diff --git a/src/cli-microsoft365/devcontainer-feature.json b/src/cli-microsoft365/devcontainer-feature.json
new file mode 100644
index 0000000..d2f897f
--- /dev/null
+++ b/src/cli-microsoft365/devcontainer-feature.json
@@ -0,0 +1,68 @@
+{
+ "name": "CLI for Microsoft 365",
+ "id": "cli-microsoft365",
+ "version": "1.0.0",
+ "description": "CLI for Microsoft 365 is a cross-platform CLI that allows users on any platform to manage various configuration settings of Microsoft 365.",
+ "documentationURL": "https://github.com/jpawlowski/devcontainer-features/tree/main/src/cli-microsoft365",
+ "licenseURL": "https://github.com/jpawlowski/devcontainer-features/tree/main/LICENSE.txt",
+ "keywords": [
+ "microsoft",
+ "cli",
+ "azure",
+ "sharepoint",
+ "sharepoint-online",
+ "microsoft-entra",
+ "spfx",
+ "sharepoint-framework",
+ "microsoft-graph",
+ "microsoft-teams",
+ "microsoft-power-automate",
+ "microsoft-power-apps",
+ "microsoft-planner",
+ "microsoft-viva",
+ "microsoft-365",
+ "microsoft365",
+ "m365",
+ "pnp"
+ ],
+ "options": {
+ "version": {
+ "type": "string",
+ "proposals": [
+ "latest"
+ ],
+ "default": "latest",
+ "description": "Set the version of the CLI to install."
+ },
+ "commandCompletion": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable command completion in Bash, Zsh and Fish."
+ },
+ "commandCompletionPS": {
+ "type": "boolean",
+ "default": true,
+ "description": "Enable command completion in PowerShell."
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "adamwojcikit.cli-for-microsoft-365-extension"
+ ]
+ }
+ },
+ "dependsOn": {
+ "ghcr.io/devcontainers/features/node": {}
+ },
+ "installsAfter": [
+ "ghcr.io/devcontainers/features/common-utils",
+ "ghcr.io/devcontainers/features/node",
+ "ghcr.io/devcontainers/features/powershell",
+ "ghcr.io/jpawlowski/devcontainer-features/powershell-extended",
+ "ghcr.io/devcontainers-contrib/features/fish-apt-get"
+ ],
+ "containerEnv": {
+ "CLIMICROSOFT365_ENV": "docker"
+ }
+}
\ No newline at end of file
diff --git a/src/cli-microsoft365/install.sh b/src/cli-microsoft365/install.sh
new file mode 100755
index 0000000..8f706cf
--- /dev/null
+++ b/src/cli-microsoft365/install.sh
@@ -0,0 +1,136 @@
+#!/bin/bash
+
+export NVM_DIR="${NVMINSTALLPATH:-"/usr/local/share/nvm"}"
+USERNAME="${USERNAME:-"${_REMOTE_USER:-"automatic"}"}"
+CLI_VERSION="${VERSION:-"latest"}"
+COMMAND_COMPLETION="${COMMANDCOMPLETION:-"true"}"
+COMMAND_COMPLETIONPS="${COMMANDCOMPLETIONPS:-"true"}"
+
+set -e
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
+ exit 1
+fi
+
+# Bring in ID, ID_LIKE, VERSION_ID, VERSION_CODENAME
+. /etc/os-release
+# Get an adjusted ID independent of distro variants
+MAJOR_VERSION_ID=$(echo ${VERSION_ID} | cut -d . -f 1)
+if [ "${ID}" = "debian" ] || [ "${ID_LIKE}" = "debian" ]; then
+ ADJUSTED_ID="debian"
+elif [[ "${ID}" = "rhel" || "${ID}" = "fedora" || "${ID}" = "mariner" || "${ID_LIKE}" = *"rhel"* || "${ID_LIKE}" = *"fedora"* || "${ID_LIKE}" = *"mariner"* ]]; then
+ ADJUSTED_ID="rhel"
+ if [[ "${ID}" = "rhel" ]] || [[ "${ID}" = *"alma"* ]] || [[ "${ID}" = *"rocky"* ]]; then
+ VERSION_CODENAME="rhel${MAJOR_VERSION_ID}"
+ else
+ VERSION_CODENAME="${ID}${MAJOR_VERSION_ID}"
+ fi
+else
+ echo "Linux distro ${ID} not supported."
+ exit 1
+fi
+
+# Ensure that login shells get the correct path if the user updated the PATH using ENV.
+rm -f /etc/profile.d/00-restore-env.sh
+echo "export PATH=${PATH//$(sh -lc 'echo $PATH')/\$PATH}" >/etc/profile.d/00-restore-env.sh
+chmod +x /etc/profile.d/00-restore-env.sh
+
+# Determine the appropriate non-root user
+if [ "${USERNAME}" = "auto" ] || [ "${USERNAME}" = "automatic" ]; then
+ USERNAME=""
+ POSSIBLE_USERS=("vscode" "node" "codespace" "$(awk -v val=1000 -F ":" '$3==val{print $1}' /etc/passwd)")
+ for CURRENT_USER in "${POSSIBLE_USERS[@]}"; do
+ if id -u "${CURRENT_USER}" >/dev/null 2>&1; then
+ USERNAME=${CURRENT_USER}
+ break
+ fi
+ done
+ if [ "${USERNAME}" = "" ]; then
+ USERNAME=root
+ fi
+elif [ "${USERNAME}" = "none" ] || ! id -u "${USERNAME}" >/dev/null 2>&1; then
+ USERNAME=root
+fi
+
+# Install m365-cli
+umask 0002
+if bash -c ". '${NVM_DIR}/nvm.sh' && type npm >/dev/null 2>&1"; then
+ if ! bash -c ". '${NVM_DIR}/nvm.sh' && type m365 >/dev/null 2>&1"; then
+ . "${NVM_DIR}/nvm.sh"
+ [ ! -z "$http_proxy" ] && npm set proxy="$http_proxy"
+ [ ! -z "$https_proxy" ] && npm set https-proxy="$https_proxy"
+ [ ! -z "$no_proxy" ] && npm set noproxy="$no_proxy"
+ npm install -g @pnp/cli-microsoft365@${CLI_VERSION}
+ npm cache clean --force
+
+ # Install command completion in Bash and Zsh
+ if [ "${COMMAND_COMPLETION}" = "true" ]; then
+ echo "Installing shell completion"
+ case $ADJUSTED_ID in
+ debian)
+ apt-get update
+ export DEBIAN_FRONTEND=noninteractive
+ apt-get install --no-install-recommends -y bash-completion
+ apt-get clean -y
+ rm -rf /var/lib/apt/lists/*
+ ;;
+ rhel)
+ yum install -y bash-completion
+ yum clean all
+ ;;
+ esac
+
+ su "${USERNAME}" bash -c ". '${NVM_DIR}/nvm.sh' && m365 cli completion sh setup"
+ echo "Bash command completion activated"
+
+ if type zsh >/dev/null 2>&1; then
+ su "${USERNAME}" zsh -c ". '${NVM_DIR}/nvm.sh' && m365 cli completion sh setup"
+ echo "Zsh command completion activated"
+ fi
+
+ if type fish >/dev/null 2>&1; then
+ su "${USERNAME}" fish -c ". '${NVM_DIR}/nvm.sh' && m365 cli completion sh setup"
+ echo "Fish command completion activated"
+ fi
+ fi
+
+ # Enable PowerShell command completion
+ if [ "${COMMAND_COMPLETIONPS}" = "true" ]; then
+ # Find the path of pwsh if it exists in the PATH
+ set +e
+ pwsh_path=$(command -v pwsh)
+ set -e
+
+ if [ -z "$pwsh_path" ]; then
+ echo "PowerShell is not installed. Skipping PowerShell completion setup."
+ else
+ # Check if the path is a symlink
+ if [ -L "$pwsh_path" ]; then
+ # It's a symlink; resolve it to the actual file path
+ real_path=$(readlink -f "$pwsh_path")
+ else
+ # Not a symlink; use the path as is
+ real_path=$pwsh_path
+ fi
+
+ # Set the execution bit for the owner, group, and others
+ chmod 755 "$real_path"
+ echo "Execution bit set for $real_path"
+
+ # Install cli completion
+ . "${NVM_DIR}/nvm.sh"
+ pwsh -Command 'm365 cli completion pwsh setup --profile $PROFILE.AllUsersAllHosts'
+ pwsh -Command 'Add-Content -Path $PROFILE.AllUsersAllHosts -Value "`nSet-Alias -Name m365? -Value m365_chili"'
+ echo "PowerShell command completion activated"
+ fi
+ fi
+ else
+ echo "m365-cli is already installed. Skipping installation."
+ fi
+else
+ echo "ERROR: NPM is not installed. Please install NPM and try again."
+ exit 1
+fi
+
+echo "Done!"
diff --git a/src/pnp.powershell/devcontainer-feature.json b/src/pnp.powershell/devcontainer-feature.json
new file mode 100644
index 0000000..94d9022
--- /dev/null
+++ b/src/pnp.powershell/devcontainer-feature.json
@@ -0,0 +1,44 @@
+{
+ "name": "PnP PowerShell",
+ "id": "pnp.powershell",
+ "version": "1.0.0",
+ "description": "PnP PowerShell is a cross-platform PowerShell module that allows users on any platform to manage various configuration settings of Microsoft 365.",
+ "documentationURL": "https://github.com/jpawlowski/devcontainer-features/tree/main/src/pnp.powershell",
+ "licenseURL": "https://github.com/jpawlowski/devcontainer-features/tree/main/LICENSE.txt",
+ "keywords": [
+ "microsoft",
+ "powershell",
+ "microsoft-teams",
+ "sharepoint",
+ "sharepoint-online",
+ "microsoft-graph",
+ "microsoft-365",
+ "microsoft365",
+ "m365",
+ "pnp"
+ ],
+ "options": {
+ "version": {
+ "type": "string",
+ "proposals": [
+ "latest"
+ ],
+ "default": "latest",
+ "description": "Set the version of PnP.PowerShell to install."
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "adamwojcikit.pnp-powershell-extension"
+ ]
+ }
+ },
+ "dependsOn": {
+ "ghcr.io/jpawlowski/devcontainer-features/powershell-extended": {}
+ },
+ "installsAfter": [
+ "ghcr.io/devcontainers/features/common-utils",
+ "ghcr.io/jpawlowski/devcontainer-features/powershell-extended"
+ ]
+}
\ No newline at end of file
diff --git a/src/pnp.powershell/install.sh b/src/pnp.powershell/install.sh
new file mode 100755
index 0000000..96f5f05
--- /dev/null
+++ b/src/pnp.powershell/install.sh
@@ -0,0 +1,45 @@
+#!/bin/bash
+
+PSPNP_VERSION="${VERSION:-"latest"}"
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
+ exit 1
+fi
+
+# Find the path of pwsh if it exists in the PATH
+set +e
+pwsh_path=$(command -v pwsh)
+set -e
+
+if [ -z "$pwsh_path" ]; then
+ echo "PowerShell (pwsh) is not installed. Please install PowerShell before running this script."
+ exit 1
+else
+ # Check if the path is a symlink
+ if [ -L "$pwsh_path" ]; then
+ # It's a symlink; resolve it to the actual file path
+ real_path=$(readlink -f "$pwsh_path")
+ else
+ # Not a symlink; use the path as is
+ real_path=$pwsh_path
+ fi
+
+ # Set the execution bit for the owner, group, and others
+ chmod +x "$real_path"
+ echo "Execution bit set for $real_path"
+
+ # Install PnP.PowerShell
+ if ! pwsh -Command "if (Get-Module -Name PnP.PowerShell -ListAvailable -ErrorAction SilentlyContinue) { exit 0 } else { exit 1 }"; then
+ if [ "$PSPNP_VERSION" = "latest" ]; then
+ pwsh -Command "Install-Module -Name PnP.PowerShell -Force -AllowClobber -Scope AllUsers"
+ else
+ pwsh -Command "Install-Module -Name PnP.PowerShell -RequiredVersion $PSPNP_VERSION -Force -AllowClobber -Scope AllUsers"
+ fi
+ else
+ echo "PnP.PowerShell is already installed. Skipping installation."
+ exit 0
+ fi
+fi
+
+echo "Done!"
diff --git a/src/powershell-extended/NOTES.md b/src/powershell-extended/NOTES.md
new file mode 100644
index 0000000..a0fd4f2
--- /dev/null
+++ b/src/powershell-extended/NOTES.md
@@ -0,0 +1,57 @@
+## Advanced Resource Installation Options
+
+This is a re-write of the original [ghcr.io/devcontainers/features/powershell](https://ghcr.io/devcontainers/features/powershell)
+package. Is uses [Microsoft.PowerShell.PSResourceGet](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.psresourceget/)
+instead of [PowerShellGet](https://learn.microsoft.com/en-us/powershell/gallery/overview) to install resources, which is
+included with PowerShell since version 7.4.0.
+
+The new configuration options support an advanced format for 3rd party installation repositories as well enhanced version
+defintion, including version ranges and pre-releases.
+
+### Setting a version for `resources`
+
+To use advanced options for resource installation, you may do so using the extended
+resource name syntax:
+
+`[]Resource-Name[@]`
+
+#### Version Examples
+
+| Notation | Description |
+|----------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------|
+| `Az` | Installs the latest version. |
+| `Az@12.1.0` | Install exactly version 12.1.0. |
+| `Az@[12.1.0,]` | Installs any version equal or greater than 12.1.0. |
+| `Az@[12.1,12.2)` | Installs the latest bugfix release within the 12.1.x range. |
+| `https://example.com/api/v2/MyPrivateModule` | Installs a module from a 3rd-party repository. The URI base is interpreted as resource name, while the rest is used as repository URI. |
+
+For a detailled description about version formats, see [`Install-PSResource -Version`](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.psresourceget/install-psresource?#-version)
+reference.
+
+Resource repositories are automatically created using their domain, unless they are pre-defined with their name
+(see below). Note that these repositories will be available also after the container was created, but they will
+**not be trusted** automatically after the initial installation of resources.
+
+> **IMPORTANT:** Please note that multiple items must be separated using a semicolon (`;`).
+> The comma is reserved to be used within version ranges as explained above.
+
+### Setting resource `repositories`
+
+To register a resource repository (or change PSGallery default repository), you make use this syntax:
+
+`[=]Repository-URI[^]`
+
+#### Resource Repository Examples
+
+| Notation | Description | Resulting Repository Name |
+|-------------------------------------|---------------------------------------|---------------------------|
+| `https://example.com/api/v2` | Minimum example. | `example.com` |
+| `https://example.com/api/v2^40` | Explicitly set priority to 40. | `example.com` |
+| `MyRepo=https://example.com/api/v2` | Setting an explicit repository name. | `MyRepo` |
+| `PSGallery^60` | Decrease priority of PSGallery to 60. | `PSGallery` |
+| `PSGallery` | Set PSGallery as trusted. | `PSGallery` |
+
+Note that every repository you explicitly set in the configuration will automatically be configured as a **trusted resource**.
+
+> **IMPORTANT:** Please note that multiple items must be separated using a semicolon (`;`).
+> It follows the principle used to separate items in the `resources` option.
diff --git a/src/powershell-extended/devcontainer-feature.json b/src/powershell-extended/devcontainer-feature.json
new file mode 100644
index 0000000..944a955
--- /dev/null
+++ b/src/powershell-extended/devcontainer-feature.json
@@ -0,0 +1,61 @@
+{
+ "id": "powershell-extended",
+ "legacyIds": [
+ "powershell"
+ ],
+ "version": "2.0.0",
+ "name": "PowerShell Extended [PSResourceGet / NuGet Versioning]",
+ "documentationURL": "https://github.com/jpawlowski/devcontainer-features/tree/main/src/powershell",
+ "description": "Installs PowerShell on AMD64 and ARM64 machines, and optional additional resources from the PowerShell Gallery using PSResourceGet. It also supports advanced installation options.",
+ "options": {
+ "version": {
+ "type": "string",
+ "proposals": [
+ "latest",
+ "7.4",
+ "7.3"
+ ],
+ "default": "latest",
+ "description": "Select or enter a version of PowerShell."
+ },
+ "installMethod": {
+ "type": "string",
+ "enum": [
+ "package",
+ "github"
+ ],
+ "default": "package",
+ "description": "Select the installation method for PowerShell. If you choose `package`, PowerShell will be installed using the package manager with a fallback to 'github'. If you choose `github`, PowerShell will be installed from GitHub releases."
+ },
+ "updatePSResourceGet": {
+ "type": "boolean",
+ "default": true,
+ "description": "Update `Microsoft.PowerShell.PSResourceGet` module to the latest version."
+ },
+ "repositories": {
+ "type": "string",
+ "default": "",
+ "description": "Optional semicolon separated list of PowerShell repositories to register. To set a specific name for a repository, use the format `name=url`, otherwise the name will be the base URL. See feature documentation for more information about advanced installation options."
+ },
+ "resources": {
+ "type": "string",
+ "default": "",
+ "description": "Optional semicolon separated list of PowerShell resources to install. If you need to install a specific version, use `@` to specify the version (e.g. `Az.Accounts@3.1.0`). See feature documentation for more information about advanced installation options."
+ },
+ "profileURLAllUsersAllHosts": {
+ "type": "string",
+ "default": "",
+ "description": "Optional (publicly accessible) URL to download global PowerShell profile (AllUsersAllHosts)."
+ }
+ },
+ "customizations": {
+ "vscode": {
+ "extensions": [
+ "ms-vscode.powershell"
+ ]
+ }
+ },
+ "installsAfter": [
+ "ghcr.io/devcontainers/features/common-utils"
+ ]
+}
\ No newline at end of file
diff --git a/src/powershell-extended/install.sh b/src/powershell-extended/install.sh
new file mode 100755
index 0000000..63880ad
--- /dev/null
+++ b/src/powershell-extended/install.sh
@@ -0,0 +1,322 @@
+#!/bin/bash
+
+# Original version from: https://github.com/devcontainers/features/blob/main/src/powershell/
+
+set -e
+
+# load common functions
+# shellcheck source=/dev/null
+source "$(dirname "$0")/lib.sh" # Input variables are exported from here
+
+# Clean up
+rm -rf /var/lib/apt/lists/*
+
+if [ "$(id -u)" -ne 0 ]; then
+ echo -e 'Script must be run as root. Use sudo, su, or add "USER root" to your Dockerfile before running this script.'
+ exit 1
+fi
+
+# Define PowerShell preferences
+prefs="\$ProgressPreference='SilentlyContinue'; \$InformationPreference='Continue'; \$VerbosePreference='SilentlyContinue'; \$ConfirmPreference='None'; \$ErrorActionPreference='Stop';"
+
+# Install PowerShell if not already installed
+if ! type pwsh >/dev/null 2>&1; then
+ if [ "$POWERSHELL_INSTALLATION_METHOD" = 'package' ]; then
+ export DEBIAN_FRONTEND=noninteractive
+
+ # Source /etc/os-release to get OS info
+ # shellcheck source=/dev/null
+ . /etc/os-release
+ architecture="$(dpkg --print-architecture)"
+
+ if [[ "${POWERSHELL_ARCHIVE_ARCHITECTURES}" = *"${architecture}"* ]] && [[ "${POWERSHELL_ARCHIVE_VERSION_CODENAMES}" = *"${VERSION_CODENAME}"* ]]; then
+ install_using_apt || use_github="true"
+ else
+ use_github="true"
+ fi
+ else
+ use_github="true"
+ fi
+
+ if [ "${use_github}" = "true" ]; then
+ echo "Attempting install from GitHub release..."
+ install_using_github
+ fi
+
+ if [ "$POWERSHELL_UPDATE_PSRESOURCEGET" = 'true' ]; then
+
+ if [ "$POWERSHELL_VERSION" = 'latest' ] || ! version_compare 'ge' "$POWERSHELL_VERSION" '7.4.0'; then
+ # Update Microsoft.PowerShell.PSResourceGet
+ currentVersion=$(pwsh -NoProfile -Command "(Get-Module -ListAvailable -Name Microsoft.PowerShell.PSResourceGet).Version.ToString()")
+ latestVersion=$(pwsh -NoProfile -Command "(Find-Module -Name Microsoft.PowerShell.PSResourceGet -Repository PSGallery -AllVersions | Sort-Object { [version]\$_.Version } -Descending | Select-Object -First 1).Version.ToString()")
+ if version_compare 'gt' "$latestVersion" "$currentVersion"; then
+ pwsh -NoProfile -Command "$prefs; Install-PSResource -Verbose -Repository PSGallery -TrustRepository -Scope AllUsers -Name Microsoft.PowerShell.PSResourceGet"
+ echo "Updated Microsoft.PowerShell.PSResourceGet"
+ fi
+ else
+ # Installing Microsoft.PowerShell.PSResourceGet
+ pwsh -NoProfile -Command "$prefs; Set-PSRepository -Name PSGallery -InstallationPolicy Trusted; Install-Module -Repository PSGallery -Scope AllUsers -Name Microsoft.PowerShell.PSResourceGet -Force -AllowClobber; Set-PSRepository -Name PSGallery -InstallationPolicy Untrusted" || exit 1
+ echo "Installed Microsoft.PowerShell.PSResourceGet"
+ fi
+ fi
+else
+ echo "PowerShell is already installed."
+fi
+
+# Get existing repositories
+IFS=';' read -r -a repos <<<"$(pwsh -NoProfile -Command "(Get-PSResourceRepository).Uri.OriginalString -join ';'")"
+
+# If PowerShell repositories are requested, loop through and register
+if [ "$POWERSHELL_REPOSITORIES" != '' ]; then
+ echo "Registering PowerShell Repositories:"
+
+ IFS=';' read -r -a repositories <<<"$(echo "$POWERSHELL_REPOSITORIES" | tr -d '[:space:]')"
+ for item in "${repositories[@]}"; do
+ # Handle priority for PSGallery
+ if [[ "$item" =~ ^PSGallery(\^[0-9]+)?$|^(PSGallery=)?https://www\.powershellgallery\.com ]]; then
+ IFS='=' read -r repoName repoFullUri <<<"$item"
+ if [ "$repoFullUri" != '' ]; then
+ IFS='^' read -r repoUri repoPrio <<<"$repoFullUri"
+ else
+ IFS='^' read -r repoName2 repoPrio <<<"$repoName"
+ fi
+
+ if [ -z "$repoPrio" ]; then
+ # Set PSGallery to trusted only
+ echo "[root] Set PSGallery as trusted repository"
+ pwsh -NoProfile -Command "$prefs; Set-PSResourceRepository -Name PSGallery -Trusted"
+ if [ -n "$_REMOTE_USER" ] && [ "$_REMOTE_USER" != 'root' ]; then
+ echo "[$_REMOTE_USER] Set PSGallery as trusted repository"
+ su "$_REMOTE_USER" bash -c "pwsh -NoProfile -Command \"$prefs; Set-PSResourceRepository -Name PSGallery -Trusted\""
+ fi
+
+ elif [[ "$repoPrio" =~ ^[0-9]+$ ]] && [ "$repoPrio" -ge 0 ] && [ "$repoPrio" -le 100 ]; then
+ # Update priority and set to trusted
+ echo "[root] Set PSGallery as trusted repository and update priority to '$repoPrio'"
+ pwsh -NoProfile -Command "$prefs; Set-PSResourceRepository -Name PSGallery -Trusted -Priority $repoPrio"
+ if [ -n "$_REMOTE_USER" ] && [ "$_REMOTE_USER" != 'root' ]; then
+ echo "[$_REMOTE_USER] Set PSGallery as trusted repository and update priority to '$repoPrio'"
+ su "$_REMOTE_USER" bash -c "pwsh -NoProfile -Command \"$prefs; Set-PSResourceRepository -Name PSGallery -Trusted -Priority $repoPrio\""
+ fi
+ else
+ echo "Invalid priority for 'PSGallery': $repoPrio"
+ exit 1
+ fi
+
+ continue
+ fi
+
+ # Extract repository name, URI, and priority
+ IFS='=' read -r repoName repoFullUri <<<"$item"
+ IFS='^' read -r repoUri repoPrio <<<"$repoFullUri"
+
+ # Exit if repository URI is empty
+ if [ "$repoUri" = '' ]; then
+ echo "Invalid repository: $item"
+ exit 1
+ fi
+
+ # Check if repository is already registered
+ if [[ ! "${repos[*]}" =~ $repoUri ]]; then
+ # Validate if repository is a valid URI
+ if [[ ! "$repoUri" =~ ^https?:// ]]; then
+ echo "Invalid repository URI: $repoUri"
+ exit 1
+ fi
+
+ # Use domain name as repository name if not provided
+ if [ -z "$repoName" ]; then
+ repoName=$(echo "$repoUri" | sed -E 's|^[a-zA-Z]+://([^:/]+).*|\1|')
+ fi
+
+ repoargs="-Name '$repoName' -Uri '$repoUri' -Trusted"
+
+ # Add priority if provided
+ if [ -n "$repoPrio" ]; then
+ if [[ "$repoPrio" =~ ^[0-9]+$ ]] && [ "$repoPrio" -ge 0 ] && [ "$repoPrio" -le 100 ]; then
+ repoargs+=" -Priority $repoPrio"
+ else
+ echo "Invalid priority for '$repoName': $repoPrio"
+ exit 1
+ fi
+ fi
+
+ # Register repository
+ echo "[root] Register-PSResourceRepository $repoargs"
+ pwsh -NoProfile -Command "$prefs; Register-PSResourceRepository $repoargs"
+ if [ -n "$_REMOTE_USER" ] && [ "$_REMOTE_USER" != 'root' ]; then
+ echo "[$_REMOTE_USER] Register-PSResourceRepository $repoargs"
+ su "$_REMOTE_USER" bash -c "pwsh -NoProfile -Command \"$prefs; Register-PSResourceRepository $repoargs\""
+ fi
+
+ # Add to list of repositories
+ repos+=("$repoUri")
+ echo "Registered repository: $repoName"
+ else
+ echo "Repository already registered: $item"
+ fi
+ done
+fi
+
+# If PowerShell resources are requested, loop through and install
+if [ "$POWERSHELL_RESOURCES" != '' ]; then
+ echo "Installing PowerShell Resources:"
+
+ IFS=';' read -r -a resources <<<"$(echo "$POWERSHELL_RESOURCES" | tr -d '[:space:]')"
+ for item in "${resources[@]}"; do
+ args="-Scope AllUsers -TrustRepository -AcceptLicense"
+ repoName=""
+ repoUri=""
+ repoPrio=""
+ resourceName=""
+ fullVersion=""
+ version=""
+ prerelease=""
+ versionStart=""
+ prereleaseStart=""
+ versionEnd=""
+ prereleaseEnd=""
+
+ # Split item at '@' if present
+ if [[ "$item" == *"@"* ]]; then
+ IFS='@' read -r uri fullVersion <<<"$item"
+ args+=" -Version '$fullVersion'"
+
+ # Check for version NuGet format
+ if [[ "$fullVersion" =~ (\[|\().*(\]|\)) ]]; then
+ # Trim brackets
+ versionContent=${fullVersion:1:-1}
+
+ # Check for version range
+ if [[ "$versionContent" == *","* ]]; then
+ IFS=',' read -r versionStart versionEnd <<<"$versionContent"
+ # Handle potential prerelease for each version
+ if [[ "$versionStart" == *"-"* ]]; then
+ IFS='-' read -r versionStart prereleaseStart <<<"$versionStart"
+ args+=" -Prerelease"
+ fi
+ if [[ "$versionEnd" == *"-"* ]]; then
+ IFS='-' read -r versionEnd prereleaseEnd <<<"$versionEnd"
+ args+=" -Prerelease"
+ fi
+ else
+ # Single version, possibly with prerelease
+ if [[ "$versionContent" == *"-"* ]]; then
+ IFS='-' read -r version prerelease <<<"$versionContent"
+ args+=" -Prerelease"
+ else
+ version="$fullVersion"
+ fi
+ fi
+ # SemVer format
+ elif [[ "$fullVersion" == *"-"* ]]; then
+ IFS='-' read -r version prerelease <<<"$fullVersion"
+ args+=" -Prerelease"
+ else
+ version="$fullVersion"
+ fi
+
+ # Set item without version
+ item="$uri"
+ fi
+
+ # Extract resourceName
+ resourceName=${item##*/}
+ args+=" -Name '$resourceName'"
+
+ # Extract original repository URI
+ if [[ "$item" == */* ]]; then
+ repoOrigUri=${item%/*}
+ else
+ repoOrigUri=""
+ fi
+
+ # Extract repository name and URI
+ IFS='=' read -r repoName repoFullUri <<<"$repoOrigUri"
+ IFS='^' read -r repoUri repoPrio <<<"$repoFullUri"
+
+ # If provided, check if repository is already registered
+ if [ "$repoUri" != '' ]; then
+ if [[ ! "${repos[*]}" =~ $repoUri ]]; then
+ # Validate if repository is a valid URI
+ if [[ ! "$repoUri" =~ ^https?:// ]]; then
+ echo "Invalid repository URI: $repoUri"
+ exit 1
+ fi
+
+ # Use domain name as repository name if not provided
+ if [ -z "$repoName" ]; then
+ repoName=$(echo "$repoUri" | sed -E 's|^[a-zA-Z]+://([^:/]+).*|\1|')
+ fi
+
+ repoargs="-Name '$repoName' -Uri '$repoUri'"
+
+ # Add priority if provided
+ if [ -n "$repoPrio" ]; then
+ if [[ "$repoPrio" =~ ^\d+$ ]] && [ "$repoPrio" -ge 0 ] && [ "$repoPrio" -le 100 ]; then
+ repoargs+=" -Priority $repoPrio"
+ else
+ echo "Invalid priority for '$repoName': $repoPrio"
+ exit 1
+ fi
+ fi
+
+ # Register repository
+ echo "[root] Register-PSResourceRepository $repoargs"
+ pwsh -NoProfile -Command "$prefs; Register-PSResourceRepository $repoargs"
+ if [ -n "$_REMOTE_USER" ] && [ "$_REMOTE_USER" != 'root' ]; then
+ echo "[$_REMOTE_USER] Register-PSResourceRepository $repoargs"
+ su "$_REMOTE_USER" bash -c "pwsh -NoProfile -Command \"$prefs; Register-PSResourceRepository $repoargs\""
+ fi
+
+ # Add to list of repositories
+ repos+=("$repoUri")
+ echo "Registered repository: $repoName"
+ else
+ echo "Repository already registered: $repoName"
+ fi
+ fi
+
+ # If provided, add repository name to args
+ if [ -n "$repoName" ]; then
+ args+=" -Repository '$repoName'"
+ fi
+
+ # Install the resource
+ echo "---------------------------"
+ echo "| Installing $resourceName"
+ echo "---------------------------"
+ echo "Repository Name: $repoName"
+ echo "Repository URI: $repoUri"
+ echo "Repository Priority: $repoPrio"
+ echo "Resource Name: $resourceName"
+ echo "Version: $version - Prerelease: $prerelease"
+ echo "Version Range Start: $versionStart - Prerelease: $prereleaseStart"
+ echo "Version Range End: $versionEnd - Prerelease: $prereleaseEnd"
+ echo "---------------------------"
+ echo ""
+
+ echo "Install-PSResource -Verbose $args"
+ pwsh -NoProfile -Command "$prefs; Install-PSResource $args"
+ done
+fi
+
+# If URL for PowerShell profile is provided, download it to '/opt/microsoft/powershell/7/profile.ps1'
+if [ -n "$POWERSHELL_PROFILE_URL" ]; then
+ # Get profile path from currently installed pwsh
+ profilePath=$(pwsh -NoProfile -Command "\$PROFILE.AllUsersAllHosts")
+
+ # If file is not existing yet, download it
+ if [ ! -f "$profilePath" ]; then
+ echo "Downloading PowerShell Profile from: $POWERSHELL_PROFILE_URL"
+ curl -sSL -o "$profilePath" "$POWERSHELL_PROFILE_URL"
+ else
+ echo "PowerShell Profile already exists at: $profilePath"
+ fi
+fi
+
+# Clean up
+apt-get clean -y
+rm -rf /var/lib/apt/lists/*
+
+echo "Done!"
diff --git a/src/powershell-extended/lib.sh b/src/powershell-extended/lib.sh
new file mode 100644
index 0000000..b81a299
--- /dev/null
+++ b/src/powershell-extended/lib.sh
@@ -0,0 +1,286 @@
+#!/bin/bash
+
+export POWERSHELL_VERSION=${VERSION:-"latest"}
+export POWERSHELL_INSTALLATION_METHOD=${INSTALLATIONMETHOD:-"package"}
+export POWERSHELL_RESOURCES="${RESOURCES:-""}"
+export POWERSHELL_REPOSITORIES="${REPOSITORIES:-""}"
+export POWERSHELL_UPDATE_PSRESOURCEGET=${UPDATEPSRESOURCEGET:-"true"}
+export POWERSHELL_PROFILE_URL="${PROFILEURLALLUSERSALLHOSTS}"
+
+export MICROSOFT_GPG_KEYS_URI="https://packages.microsoft.com/keys/microsoft.asc"
+export POWERSHELL_ARCHIVE_ARCHITECTURES="amd64 arm64"
+export POWERSHELL_ARCHIVE_VERSION_CODENAMES="bionic focal bullseye jammy bookworm noble"
+export GPG_KEY_SERVERS="keyserver hkp://keyserver.ubuntu.com
+keyserver hkp://keyserver.ubuntu.com:80
+keyserver hkps://keys.openpgp.org
+keyserver hkp://keyserver.pgp.com"
+
+# Figure out correct version if a three part version number is not passed
+function find_version_from_git_tags() {
+ local variable_name=$1
+ local requested_version=${!variable_name}
+ if [ "${requested_version}" = "none" ]; then return; fi
+ local repository=$2
+ local prefix=${3:-"tags/v"}
+ local separator=${4:-"."}
+ local last_part_optional=${5:-"false"}
+ if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
+ local escaped_separator=${separator//./\\.}
+ local last_part
+ if [ "${last_part_optional}" = "true" ]; then
+ last_part="(${escaped_separator}[0-9]+)?"
+ else
+ last_part="${escaped_separator}[0-9]+"
+ fi
+ local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
+ local version_list
+ version_list="$(git ls-remote --tags "${repository}" | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
+ if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
+ declare -g "${variable_name}"="$(echo "${version_list}" | head -n 1)"
+ else
+ set +e
+ declare -g "${variable_name}"="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
+ set -e
+ fi
+ fi
+ if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
+ echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
+ exit 1
+ fi
+ echo "${variable_name}=${!variable_name}"
+}
+
+function apt_get_update() {
+ if [ "$(find /var/lib/apt/lists/* | wc -l)" = "0" ]; then
+ echo "Running apt-get update..."
+ apt-get update -y
+ fi
+}
+
+# Checks if packages are installed and installs them if not
+function check_packages() {
+ if ! dpkg -s "$@" > /dev/null 2>&1; then
+ apt_get_update
+ apt-get -y install --no-install-recommends "$@"
+ fi
+}
+
+function install_using_apt() {
+ # Install dependencies
+ check_packages apt-transport-https curl ca-certificates gnupg2 dirmngr
+ # Import key safely (new 'signed-by' method rather than deprecated apt-key approach) and install
+ curl -sSL ${MICROSOFT_GPG_KEYS_URI} | gpg --dearmor > /usr/share/keyrings/microsoft-archive-keyring.gpg
+ echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/microsoft-archive-keyring.gpg] https://packages.microsoft.com/repos/microsoft-${ID}-${VERSION_CODENAME}-prod ${VERSION_CODENAME} main" > /etc/apt/sources.list.d/microsoft.list
+
+ # Update lists
+ apt-get update -yq
+
+ # Soft version matching for CLI
+ if [ "${POWERSHELL_VERSION}" = "latest" ] || [ "${POWERSHELL_VERSION}" = "lts" ] || [ "${POWERSHELL_VERSION}" = "stable" ]; then
+ # Empty, meaning grab whatever "latest" is in apt repo
+ version_suffix=""
+ else
+ version_suffix="=$(apt-cache madison powershell | awk -F"|" '{print $2}' | sed -e 's/^[ \t]*//' | grep -E -m 1 "^(${POWERSHELL_VERSION})(\.|$|\+.*|-.*)")"
+
+ if [ -z "${version_suffix}" ] || [ "${version_suffix}" = '=' ]; then
+ echo "Provided POWERSHELL_VERSION (${POWERSHELL_VERSION}) was not found in the apt-cache for this package+distribution combo";
+ return 1
+ fi
+ echo "version_suffix ${version_suffix}"
+ fi
+
+ apt-get install -yq "powershell${version_suffix}" || return 1
+}
+
+# Use semver logic to decrement a version number then look for the closest match
+function find_prev_version_from_git_tags() {
+ local variable_name=$1
+ local current_version=${!variable_name}
+ local repository=$2
+ # Normally a "v" is used before the version number, but support alternate cases
+ local prefix=${3:-"tags/v"}
+ # Some repositories use "_" instead of "." for version number part separation, support that
+ local separator=${4:-"."}
+ # Some tools release versions that omit the last digit (e.g. go)
+ local last_part_optional=${5:-"false"}
+ # Some repositories may have tags that include a suffix (e.g. actions/node-versions)
+ # shellcheck disable=SC2034 # Unused variables left for readability
+ local version_suffix_regex=$6
+ # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios.
+ set +e
+ major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')"
+ minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')"
+ breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')"
+
+ if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then
+ ((major=major-1))
+ declare -g "${variable_name}"="${major}"
+ # Look for latest version from previous major release
+ find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}"
+ # Handle situations like Go's odd version pattern where "0" releases omit the last part
+ elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then
+ ((minor=minor-1))
+ declare -g "${variable_name}"="${major}.${minor}"
+ # Look for latest version from previous minor release
+ find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}"
+ else
+ ((breakfix=breakfix-1))
+ if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then
+ declare -g "${variable_name}"="${major}.${minor}"
+ else
+ declare -g "${variable_name}"="${major}.${minor}.${breakfix}"
+ fi
+ fi
+ set -e
+}
+
+# Function to fetch the version released prior to the latest version
+function get_previous_version() {
+ local url=$1
+ local repo_url=$2
+ local variable_name=$3
+ prev_version=${!variable_name}
+
+ output=$(curl -s "$repo_url");
+ check_packages jq
+ message=$(echo "$output" | jq -r '.message')
+
+ if [[ $message == "API rate limit exceeded"* ]]; then
+ echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}"
+ echo -e "\nAttempting to find latest version using GitHub tags."
+ find_prev_version_from_git_tags prev_version "$url" "tags/v"
+ declare -g "${variable_name}"="${prev_version}"
+ else
+ echo -e "\nAttempting to find latest version using GitHub Api."
+ version=$(echo "$output" | jq -r '.tag_name')
+ declare -g "${variable_name}"="${version#v}"
+ fi
+ echo "${variable_name}=${!variable_name}"
+}
+
+function get_github_api_repo_url() {
+ local url=$1
+ echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases/latest"
+}
+
+
+function install_prev_pwsh() {
+ pwsh_url=$1
+ repo_url=$(get_github_api_repo_url "$pwsh_url")
+ echo -e "\n(!) Failed to fetch the latest artifacts for powershell v${POWERSHELL_VERSION}..."
+ get_previous_version "$pwsh_url" "$repo_url" POWERSHELL_VERSION
+ echo -e "\nAttempting to install v${POWERSHELL_VERSION}"
+ install_pwsh "${POWERSHELL_VERSION}"
+}
+
+function install_pwsh() {
+ POWERSHELL_VERSION=$1
+ local architecture
+ architecture="$(dpkg --print-architecture)"
+ if [ "${architecture}" = "amd64" ]; then
+ architecture="x64"
+ fi
+ powershell_filename="powershell-${POWERSHELL_VERSION}-linux-${architecture}.tar.gz"
+ powershell_target_path="/opt/microsoft/powershell/$(echo "${POWERSHELL_VERSION}" | grep -oE '[^\.]+' | head -n 1)"
+ mkdir -p /tmp/pwsh "${powershell_target_path}"
+ cd /tmp/pwsh
+ curl -sSL -o "${powershell_filename}" "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/${powershell_filename}"
+}
+
+function install_using_github() {
+ # Fall back on direct download if no apt package exists in microsoft pool
+ check_packages curl ca-certificates gnupg2 dirmngr libc6 libgcc1 libgssapi-krb5-2 libstdc++6 libunwind8 libuuid1 zlib1g libicu[0-9][0-9]
+ if ! type git > /dev/null 2>&1; then
+ check_packages git
+ fi
+ pwsh_url="https://github.com/PowerShell/PowerShell"
+ find_version_from_git_tags POWERSHELL_VERSION $pwsh_url
+ install_pwsh "${POWERSHELL_VERSION}"
+ if grep -q "Not Found" "${powershell_filename}"; then
+ install_prev_pwsh $pwsh_url
+ fi
+
+ # Ugly - but only way to get sha256 is to parse release HTML. Remove newlines and tags, then look for filename followed by 64 hex characters.
+ curl -sSL -o "release.html" "https://github.com/PowerShell/PowerShell/releases/tag/v${POWERSHELL_VERSION}"
+ powershell_archive_sha256="$(tr '\n' ' ' < release.html | sed 's|<[^>]*>||g' | grep -oP "${powershell_filename}\s+\K[0-9a-fA-F]{64}" || echo '')"
+ if [ -z "${powershell_archive_sha256}" ]; then
+ echo "(!) WARNING: Failed to retrieve SHA256 for archive. Skipping validaiton."
+ else
+ echo "SHA256: ${powershell_archive_sha256}"
+ echo "${powershell_archive_sha256} *${powershell_filename}" | sha256sum -c -
+ fi
+ tar xf "${powershell_filename}" -C "${powershell_target_path}"
+ chmod +x "${powershell_target_path}/pwsh"
+ ln -sf "${powershell_target_path}/pwsh" /usr/bin/pwsh
+ add-shell "/usr/bin/pwsh"
+ cd ~
+ rm -rf /tmp/pwsh
+}
+
+function version_compare() {
+ local comparison_type="$1"
+ local version1="$2"
+ local version2="$3"
+
+ # Function to compare two versions
+ compare_versions() {
+ local v1="$1"
+ local v2="$2"
+
+ # Split versions into arrays
+ IFS='.-' read -r -a v1_parts <<< "$v1"
+ IFS='.-' read -r -a v2_parts <<< "$v2"
+
+ # Compare each part
+ for ((i=0; i<${#v1_parts[@]}; i++)); do
+ if [[ -z "${v2_parts[i]}" ]]; then
+ # If v2 part is missing, v1 is greater
+ return 0
+ fi
+
+ if [[ "${v1_parts[i]}" =~ ^[0-9]+$ && "${v2_parts[i]}" =~ ^[0-9]+$ ]]; then
+ # Numeric comparison
+ if ((10#${v1_parts[i]} > 10#${v2_parts[i]})); then
+ return 0
+ elif ((10#${v1_parts[i]} < 10#${v2_parts[i]})); then
+ return 1
+ fi
+ else
+ # Lexical comparison
+ if [[ "${v1_parts[i]}" > "${v2_parts[i]}" ]]; then
+ return 0
+ elif [[ "${v1_parts[i]}" < "${v2_parts[i]}" ]]; then
+ return 1
+ fi
+ fi
+ done
+
+ # If we reach here, all parts are equal, so compare lengths
+ if (( ${#v1_parts[@]} > ${#v2_parts[@]} )); then
+ return 0
+ elif (( ${#v1_parts[@]} < ${#v2_parts[@]} )); then
+ return 1
+ else
+ return 0
+ fi
+ }
+
+ # Compare the versions
+ compare_versions "$version1" "$version2"
+ local result=$?
+
+ # Determine the final result based on comparison type
+ if [[ "$comparison_type" == "ge" ]]; then
+ return $result
+ elif [[ "$comparison_type" == "gt" ]]; then
+ if [[ $result -eq 0 ]]; then
+ # If versions are equal, return false for "gt"
+ return 1
+ else
+ return $result
+ fi
+ else
+ echo "Invalid comparison type: $comparison_type"
+ return 2
+ fi
+}
diff --git a/test/_global/.gitkeep b/test/_global/.gitkeep
new file mode 100644
index 0000000..e69de29
diff --git a/test/cli-microsoft365/test.sh b/test/cli-microsoft365/test.sh
new file mode 100755
index 0000000..4acf220
--- /dev/null
+++ b/test/cli-microsoft365/test.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -e
+
+# Optional: Import test library
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Feature-specific tests
+check "m365 is available" bash -c ". /usr/local/share/nvm/nvm.sh && m365 version"
+
+# Report result
+reportResults
diff --git a/test/pnp.powershell/test.sh b/test/pnp.powershell/test.sh
new file mode 100755
index 0000000..dc76416
--- /dev/null
+++ b/test/pnp.powershell/test.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -e
+
+# Optional: Import test library
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Feature-specific tests
+check "PnP.PowerShell is available" pwsh -Command "(Get-Module -Name PnP.PowerShell -ListAvailable -ErrorAction SilentlyContinue).Version.ToString()"
+
+# Report result
+reportResults
diff --git a/test/powershell/Test-Profile.ps1 b/test/powershell/Test-Profile.ps1
new file mode 100644
index 0000000..e4519cf
--- /dev/null
+++ b/test/powershell/Test-Profile.ps1
@@ -0,0 +1 @@
+$env:ProfileLoaded = $true
diff --git a/test/powershell/install_powershell_fallback_test.sh b/test/powershell/install_powershell_fallback_test.sh
new file mode 100755
index 0000000..9cf10c5
--- /dev/null
+++ b/test/powershell/install_powershell_fallback_test.sh
@@ -0,0 +1,26 @@
+#!/bin/bash
+
+set -e
+
+# Import test library for `check` command
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Extension-specific tests
+check "Powershell version as installed by feature" bash -c "pwsh --version"
+
+# shellcheck source=/dev/null
+source lib.sh
+
+sudo mkdir -p /var/lib/apt/lists/
+
+echo -e "\nInstalling Powershell with find_prev_version_from_git_tags() fn 👈🏻"
+install_using_github "mode1"
+check "Powershell version as installed by test (find_prev_version_from_git_tags() fn)" bash -c "pwsh --version"
+
+echo -e "\nInstalling Powershell with GitHub Api 👈🏻"
+install_using_github "mode2"
+check "Powershell version as installed by test (GitHub Api)" bash -c "pwsh --version"
+
+# Report result
+reportResults
diff --git a/test/powershell/install_powershell_profile.sh b/test/powershell/install_powershell_profile.sh
new file mode 100755
index 0000000..05653e6
--- /dev/null
+++ b/test/powershell/install_powershell_profile.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -e
+
+# Import test library for `check` command
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Extension-specific tests
+check "profile" pwsh -Command "if (\$null -eq \$env:ProfileLoaded) { echo 'Not set!'; exit 1 } else { if ( [bool]\$env:ProfileLoaded ) { echo 'Profile loaded.'; exit 0 } else { echo 'False value!'; exit 1 } }"
+
+# Report result
+reportResults
diff --git a/test/powershell/install_resources.sh b/test/powershell/install_resources.sh
new file mode 100755
index 0000000..b58a2ff
--- /dev/null
+++ b/test/powershell/install_resources.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+set -e
+
+# Import test library for `check` command
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Extension-specific tests
+check "Az.Accounts" pwsh -Command "[string](Get-Module -ListAvailable -Name Az.Accounts -ErrorAction Stop).Version"
+check "Az.Resources" pwsh -Command "[string](Get-Module -ListAvailable -Name Az.Resources -ErrorAction Stop).Version"
+
+# Report result
+reportResults
diff --git a/test/powershell/install_resources_version.sh b/test/powershell/install_resources_version.sh
new file mode 100755
index 0000000..38c6b4a
--- /dev/null
+++ b/test/powershell/install_resources_version.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+set -e
+
+# Import test library for `check` command
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Extension-specific tests
+check "Az.Accounts" pwsh -Command "if (Get-Module -ListAvailable -Name Az.Accounts -ErrorAction Stop | Where-Object { \$_.Version -eq '3.0.0' }) { echo '3.0.0'; exit 0 } else { echo 'Not found'; exit 1 }"
+check "Az.Resources" pwsh -Command "if (Get-Module -ListAvailable -Name Az.Resources -ErrorAction Stop | Where-Object { \$_.Version -eq '7.2.0' }) { echo '7.2.0'; exit 0 } else { echo 'Not found'; exit 1 }"
+
+# Report result
+reportResults
diff --git a/test/powershell/install_resources_version_range.sh b/test/powershell/install_resources_version_range.sh
new file mode 100755
index 0000000..b58a2ff
--- /dev/null
+++ b/test/powershell/install_resources_version_range.sh
@@ -0,0 +1,14 @@
+#!/bin/bash
+
+set -e
+
+# Import test library for `check` command
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Extension-specific tests
+check "Az.Accounts" pwsh -Command "[string](Get-Module -ListAvailable -Name Az.Accounts -ErrorAction Stop).Version"
+check "Az.Resources" pwsh -Command "[string](Get-Module -ListAvailable -Name Az.Resources -ErrorAction Stop).Version"
+
+# Report result
+reportResults
diff --git a/test/powershell/lib.sh b/test/powershell/lib.sh
new file mode 100644
index 0000000..5d96936
--- /dev/null
+++ b/test/powershell/lib.sh
@@ -0,0 +1,168 @@
+#!/bin/bash
+
+# Figure out correct version of a three part version number is not passed
+function find_version_from_git_tags() {
+ local variable_name=$1
+ local requested_version=${!variable_name}
+ if [ "${requested_version}" = "none" ]; then return; fi
+ local repository=$2
+ local prefix=${3:-"tags/v"}
+ local separator=${4:-"."}
+ local last_part_optional=${5:-"false"}
+ if [ "$(echo "${requested_version}" | grep -o "." | wc -l)" != "2" ]; then
+ local escaped_separator=${separator//./\\.}
+ local last_part
+ if [ "${last_part_optional}" = "true" ]; then
+ last_part="(${escaped_separator}[0-9]+)?"
+ else
+ last_part="${escaped_separator}[0-9]+"
+ fi
+ local regex="${prefix}\\K[0-9]+${escaped_separator}[0-9]+${last_part}$"
+ local version_list
+ version_list="$(git ls-remote --tags "${repository}" | grep -oP "${regex}" | tr -d ' ' | tr "${separator}" "." | sort -rV)"
+ if [ "${requested_version}" = "latest" ] || [ "${requested_version}" = "current" ] || [ "${requested_version}" = "lts" ]; then
+ declare -g "${variable_name}"="$(echo "${version_list}" | head -n 1)"
+ else
+ set +e
+ declare -g "${variable_name}"="$(echo "${version_list}" | grep -E -m 1 "^${requested_version//./\\.}([\\.\\s]|$)")"
+ set -e
+ fi
+ fi
+ if [ -z "${!variable_name}" ] || ! echo "${version_list}" | grep "^${!variable_name//./\\.}$" > /dev/null 2>&1; then
+ echo -e "Invalid ${variable_name} value: ${requested_version}\nValid values:\n${version_list}" >&2
+ exit 1
+ fi
+ echo "${variable_name}=${!variable_name}"
+}
+
+# Use semver logic to decrement a version number then look for the closest match
+function find_prev_version_from_git_tags() {
+ local variable_name=$1
+ local current_version=${!variable_name}
+ local repository=$2
+ # Normally a "v" is used before the version number, but support alternate cases
+ local prefix=${3:-"tags/v"}
+ # Some repositories use "_" instead of "." for version number part separation, support that
+ local separator=${4:-"."}
+ # Some tools release versions that omit the last digit (e.g. go)
+ local last_part_optional=${5:-"false"}
+ # Some repositories may have tags that include a suffix (e.g. actions/node-versions)
+ # local version_suffix_regex=$6
+ # Try one break fix version number less if we get a failure. Use "set +e" since "set -e" can cause failures in valid scenarios.
+ set +e
+ major="$(echo "${current_version}" | grep -oE '^[0-9]+' || echo '')"
+ minor="$(echo "${current_version}" | grep -oP '^[0-9]+\.\K[0-9]+' || echo '')"
+ breakfix="$(echo "${current_version}" | grep -oP '^[0-9]+\.[0-9]+\.\K[0-9]+' 2>/dev/null || echo '')"
+
+ if [ "${minor}" = "0" ] && [ "${breakfix}" = "0" ]; then
+ ((major=major-1))
+ declare -g "${variable_name}"="${major}"
+ # Look for latest version from previous major release
+ find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}"
+ # Handle situations like Go's odd version pattern where "0" releases omit the last part
+ elif [ "${breakfix}" = "" ] || [ "${breakfix}" = "0" ]; then
+ ((minor=minor-1))
+ declare -g "${variable_name}"="${major}.${minor}"
+ # Look for latest version from previous minor release
+ find_version_from_git_tags "${variable_name}" "${repository}" "${prefix}" "${separator}" "${last_part_optional}"
+ else
+ ((breakfix=breakfix-1))
+ if [ "${breakfix}" = "0" ] && [ "${last_part_optional}" = "true" ]; then
+ declare -g "${variable_name}"="${major}.${minor}"
+ else
+ declare -g "${variable_name}"="${major}.${minor}.${breakfix}"
+ fi
+ fi
+ set -e
+}
+
+# Function to fetch the version released prior to the latest version
+function get_previous_version() {
+ local url=$1
+ local repo_url=$2
+ local variable_name=$3
+ local mode=$4
+ prev_version=${!variable_name}
+
+ output=$(curl -s "$repo_url");
+ message=$(echo "$output" | jq -r '.message')
+
+ if [ "$mode" = "mode1" ]; then
+ message="API rate limit exceeded"
+ else
+ message=""
+ fi
+
+ if [[ $message == "API rate limit exceeded"* ]]; then
+ echo -e "\nAn attempt to find latest version using GitHub Api Failed... \nReason: ${message}"
+ echo -e "\nAttempting to find latest version using GitHub tags."
+ find_prev_version_from_git_tags prev_version "$url" "tags/v"
+ declare -g "${variable_name}"="${prev_version}"
+ else
+ echo -e "\nAttempting to find latest version using GitHub Api."
+ version=$(echo "$output" | jq -r '.tag_name')
+ declare -g "${variable_name}"="${version#v}"
+ fi
+ echo "${variable_name}=${!variable_name}"
+}
+
+function get_github_api_repo_url() {
+ local url=$1
+ echo "${url/https:\/\/github.com/https:\/\/api.github.com\/repos}/releases/latest"
+}
+
+function install_prev_pwsh() {
+ local pwsh_url=$1
+ local mode=$2
+ local repo_url
+ repo_url=$(get_github_api_repo_url "$pwsh_url")
+ echo -e "\n(!) Failed to fetch the latest artifacts for powershell v${POWERSHELL_VERSION}..."
+ get_previous_version "$pwsh_url" "$repo_url" POWERSHELL_VERSION "$mode"
+ echo -e "\nAttempting to install v${POWERSHELL_VERSION}"
+ install_pwsh "${POWERSHELL_VERSION}"
+}
+
+function install_pwsh() {
+ POWERSHELL_VERSION=$1
+ local architecture
+ architecture="$(dpkg --print-architecture)"
+ if [ "${architecture}" = "amd64" ]; then
+ architecture="x64"
+ fi
+ powershell_filename="powershell-${POWERSHELL_VERSION}-linux-${architecture}.tar.gz"
+ powershell_target_path="/opt/microsoft/powershell/$(echo "${POWERSHELL_VERSION}" | grep -oE '[^\.]+' | head -n 1)"
+ sudo mkdir -p /tmp/pwsh "${powershell_target_path}"
+ cd /tmp/pwsh
+ sudo curl -sSL -o "${powershell_filename}" "https://github.com/PowerShell/PowerShell/releases/download/v${POWERSHELL_VERSION}/${powershell_filename}"
+}
+
+function install_using_github() {
+ mode=$1
+ local architecture
+ architecture="$(dpkg --print-architecture)"
+ if [ "${architecture}" = "amd64" ]; then
+ architecture="x64"
+ fi
+ pwsh_url="https://github.com/PowerShell/PowerShell"
+ POWERSHELL_VERSION="7.4.xyz"
+ install_pwsh "${POWERSHELL_VERSION}"
+ if grep -q "Not Found" "${powershell_filename}"; then
+ install_prev_pwsh $pwsh_url "$mode"
+ fi
+
+ # Ugly - but only way to get sha256 is to parse release HTML. Remove newlines and tags, then look for filename followed by 64 hex characters.
+ sudo curl -sSL -o "release.html" "https://github.com/PowerShell/PowerShell/releases/tag/v${POWERSHELL_VERSION}"
+ powershell_archive_sha256="$(tr '\n' ' ' < release.html | sed 's|<[^>]*>||g' | grep -oP "${powershell_filename}\s+\K[0-9a-fA-F]{64}" || echo '')"
+ if [ -z "${powershell_archive_sha256}" ]; then
+ echo "(!) WARNING: Failed to retrieve SHA256 for archive. Skipping validaiton."
+ else
+ echo "SHA256: ${powershell_archive_sha256}"
+ echo "${powershell_archive_sha256} *${powershell_filename}" | sha256sum -c -
+ fi
+ sudo tar xf "${powershell_filename}" -C "${powershell_target_path}"
+ sudo chmod +x "${powershell_target_path}/pwsh"
+ sudo ln -sf "${powershell_target_path}/pwsh" /usr/bin/pwsh
+ sudo add-shell "/usr/bin/pwsh"
+ cd ~
+ sudo rm -rf /tmp/pwsh
+}
diff --git a/test/powershell/register_repositories.sh b/test/powershell/register_repositories.sh
new file mode 100755
index 0000000..88c8d3e
--- /dev/null
+++ b/test/powershell/register_repositories.sh
@@ -0,0 +1,21 @@
+#!/bin/bash
+
+set -e
+
+# Import test library for `check` command
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Extension-specific tests
+check "PSGallery registered (user)" pwsh -Command "(Get-PSResourceRepository -Name PSGallery -ErrorAction Stop).Uri.ToString()"
+check "PSGallery trust status (user)" pwsh -Command "if ((Get-PSResourceRepository -Name PSGallery).Trusted -eq \$true) { echo 'Trusted'; exit 0 } else { echo 'Untrusted'; exit 1 }"
+check "PoshTestGallery registered (user)" pwsh -Command "(Get-PSResourceRepository -Name PoshTestGallery -ErrorAction Stop).Uri.ToString()"
+check "PoshTestGallery trust status (user)" pwsh -Command "if ((Get-PSResourceRepository -Name PoshTestGallery).Trusted -eq \$true) { echo 'Trusted'; exit 0 } else { echo 'Untrusted'; exit 1 }"
+
+check "PSGallery registered (root)" sudo pwsh -Command "(Get-PSResourceRepository -Name PSGallery -ErrorAction Stop).Uri.ToString()"
+check "PSGallery trust status (root)" sudo pwsh -Command "if ((Get-PSResourceRepository -Name PSGallery).Trusted -eq \$true) { echo 'Trusted'; exit 0 } else { echo 'Untrusted'; exit 1 }"
+check "PoshTestGallery registered (root)" sudo pwsh -Command "(Get-PSResourceRepository -Name PoshTestGallery -ErrorAction Stop).Uri.ToString()"
+check "PoshTestGallery trust status (root)" sudo pwsh -Command "if ((Get-PSResourceRepository -Name PoshTestGallery).Trusted -eq \$true) { echo 'Trusted'; exit 0 } else { echo 'Untrusted'; exit 1 }"
+
+# Report result
+reportResults
diff --git a/test/powershell/scenarios.json b/test/powershell/scenarios.json
new file mode 100644
index 0000000..cc80dd5
--- /dev/null
+++ b/test/powershell/scenarios.json
@@ -0,0 +1,48 @@
+{
+ "install_powershell_fallback_test": {
+ "image": "mcr.microsoft.com/devcontainers/base:debian",
+ "features": {
+ "powershell": {}
+ }
+ },
+ "install_powershell_profile": {
+ "image": "mcr.microsoft.com/devcontainers/base:debian",
+ "features": {
+ "powershell": {
+ "profileURLAllUsersAllHosts": "https://raw.githubusercontent.com/jpawlowski/devcontainer-features/main/test/powershell/Test-Profile.ps1"
+ }
+ }
+ },
+ "register_repositories": {
+ "image": "mcr.microsoft.com/devcontainers/base:debian",
+ "features": {
+ "powershell": {
+ "repositories": "PSGallery; PoshTestGallery=https://www.poshtestgallery.com/api/v2^40"
+ }
+ }
+ },
+ "install_resources": {
+ "image": "mcr.microsoft.com/devcontainers/base:debian",
+ "features": {
+ "powershell": {
+ "resources": "Az.Accounts; Az.Resources"
+ }
+ }
+ },
+ "install_resources_version": {
+ "image": "mcr.microsoft.com/devcontainers/base:debian",
+ "features": {
+ "powershell": {
+ "resources": "Az.Accounts@3.0.0; Az.Resources@7.2.0"
+ }
+ }
+ },
+ "install_resources_version_range": {
+ "image": "mcr.microsoft.com/devcontainers/base:debian",
+ "features": {
+ "powershell": {
+ "resources": "Az.Accounts@[3.0,3.1); Az.Resources@[6.16,6.17)"
+ }
+ }
+ }
+}
diff --git a/test/powershell/test.sh b/test/powershell/test.sh
new file mode 100755
index 0000000..fca7a35
--- /dev/null
+++ b/test/powershell/test.sh
@@ -0,0 +1,13 @@
+#!/bin/bash
+
+set -e
+
+# Optional: Import test library
+# shellcheck source=/dev/null
+source dev-container-features-test-lib
+
+# Definition specific tests
+check "PowerShell is available" pwsh --version
+
+# Report result
+reportResults