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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions .github/actions/test/nix/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,48 @@ runs:
with:
global-json-file: ./global.json

- name: Set Package Name by Platform
id: set_package_name
shell: pwsh
run: |-
Import-Module ./.github/workflows/GHWorkflowHelper/GHWorkflowHelper.psm1
$platform = $env:RUNNER_OS
Write-Host "Runner platform: $platform"
if ($platform -eq 'Linux') {
$packageName = 'DSC-*-x86_64-linux.tar.gz'
} elseif ($platform -eq 'macOS') {
$packageName = 'DSC-*-x86_64-apple-darwin.tar.gz'
} else {
throw "Unsupported platform: $platform"
}

Set-GWVariable -Name "DSC_PACKAGE_NAME" -Value $packageName

- name: Get Latest DSC Package Version
shell: pwsh
run: |-
Import-Module ./.github/workflows/GHWorkflowHelper/GHWorkflowHelper.psm1
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/PowerShell/Dsc/releases"
$latestRelease = $releases | Where-Object { $v = $_.name.trim("v"); $semVer = [System.Management.Automation.SemanticVersion]::new($v); if ($semVer.Major -eq 3 -and $semVer.Minor -ge 2) { $_ } } | Select-Object -First 1
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

If no releases match the filter criteria (Major = 3, Minor >= 2), $latestRelease will be $null, causing subsequent property accesses to fail. Add validation to handle this case gracefully.

Add error handling:

$latestRelease = $releases | Where-Object { $v = $_.name.trim("v"); $semVer = [System.Management.Automation.SemanticVersion]::new($v); if ($semVer.Major -eq 3 -and $semVer.Minor -ge 2) { $_ } } | Select-Object -First 1
if (-not $latestRelease) {
    throw "No DSC v3.2+ release found"
}
$latestVersion = $latestRelease.tag_name.TrimStart("v")

This prevents cryptic errors when the expected DSC version isn't available.

Suggested change
$latestRelease = $releases | Where-Object { $v = $_.name.trim("v"); $semVer = [System.Management.Automation.SemanticVersion]::new($v); if ($semVer.Major -eq 3 -and $semVer.Minor -ge 2) { $_ } } | Select-Object -First 1
$latestRelease = $releases | Where-Object { $v = $_.name.trim("v"); $semVer = [System.Management.Automation.SemanticVersion]::new($v); if ($semVer.Major -eq 3 -and $semVer.Minor -ge 2) { $_ } } | Select-Object -First 1
if (-not $latestRelease) {
throw "No DSC v3.2+ release found"
}

Copilot uses AI. Check for mistakes.
$latestVersion = $latestRelease.tag_name.TrimStart("v")
Write-Host "Latest DSC Version: $latestVersion"

$packageName = "$env:DSC_PACKAGE_NAME"

Write-Host "Package Name: $packageName"

$downloadUrl = $latestRelease.assets | Where-Object { $_.name -like "*$packageName*" } | Select-Object -First 1 | Select-Object -ExpandProperty browser_download_url
Comment thread
TravisEz13 marked this conversation as resolved.
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

The -like operator is used with a wildcard pattern that should match exactly, but the pattern contains a wildcard at the beginning (DSC-*) which is then wrapped in another wildcard match (*$packageName*). This creates a double-wildcard pattern like *DSC-*-x86_64-linux.tar.gz* which is redundant.

Simplify by removing the outer wildcard since the pattern already contains wildcards:

$downloadUrl = $latestRelease.assets | Where-Object { $_.name -like $packageName } | Select-Object -First 1 | Select-Object -ExpandProperty browser_download_url

This makes the matching logic clearer and more maintainable.

Suggested change
$downloadUrl = $latestRelease.assets | Where-Object { $_.name -like "*$packageName*" } | Select-Object -First 1 | Select-Object -ExpandProperty browser_download_url
$downloadUrl = $latestRelease.assets | Where-Object { $_.name -like $packageName } | Select-Object -First 1 | Select-Object -ExpandProperty browser_download_url

Copilot uses AI. Check for mistakes.
Write-Host "Download URL: $downloadUrl"

$tempPath = Get-GWTempPath

Invoke-RestMethod -Uri $downloadUrl -OutFile "$tempPath/DSC.tar.gz" -Verbose
New-Item -ItemType Directory -Path "$tempPath/DSC" -Force -Verbose
tar xvf "$tempPath/DSC.tar.gz" -C "$tempPath/DSC"
$dscRoot = "$tempPath/DSC"
Write-Host "DSC Root: $dscRoot"
Set-GWVariable -Name "DSC_ROOT" -Value $dscRoot

- name: Bootstrap
shell: pwsh
run: |-
Expand Down
20 changes: 20 additions & 0 deletions .github/actions/test/windows/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,26 @@ runs:
with:
global-json-file: .\global.json

- name: Get Latest DSC Package Version
shell: pwsh
run: |-
Import-Module .\.github\workflows\GHWorkflowHelper\GHWorkflowHelper.psm1
$releases = Invoke-RestMethod -Uri "https://api.github.com/repos/PowerShell/Dsc/releases"
$latestRelease = $releases | Where-Object { $v = $_.name.trim("v"); $semVer = [System.Management.Automation.SemanticVersion]::new($v); if ($semVer.Major -eq 3 -and $semVer.Minor -ge 2) { $_ } } | Select-Object -First 1
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

If no releases match the filter criteria (Major = 3, Minor >= 2), $latestRelease will be $null, causing subsequent property accesses to fail. Add validation to handle this case gracefully.

Add error handling:

$latestRelease = $releases | Where-Object { $v = $_.name.trim("v"); $semVer = [System.Management.Automation.SemanticVersion]::new($v); if ($semVer.Major -eq 3 -and $semVer.Minor -ge 2) { $_ } } | Select-Object -First 1
if (-not $latestRelease) {
    throw "No DSC v3.2+ release found"
}
$latestVersion = $latestRelease.tag_name.TrimStart("v")

This prevents cryptic errors when the expected DSC version isn't available.

Suggested change
$latestRelease = $releases | Where-Object { $v = $_.name.trim("v"); $semVer = [System.Management.Automation.SemanticVersion]::new($v); if ($semVer.Major -eq 3 -and $semVer.Minor -ge 2) { $_ } } | Select-Object -First 1
$latestRelease = $releases | Where-Object { $v = $_.name.trim("v"); $semVer = [System.Management.Automation.SemanticVersion]::new($v); if ($semVer.Major -eq 3 -and $semVer.Minor -ge 2) { $_ } } | Select-Object -First 1
if (-not $latestRelease) {
throw "No DSC v3.2+ release found"
}

Copilot uses AI. Check for mistakes.
$latestVersion = $latestRelease.tag_name.TrimStart("v")
Write-Host "Latest DSC Version: $latestVersion"

$downloadUrl = $latestRelease.assets | Where-Object { $_.name -like "DSC-*-x86_64-pc-windows-msvc.zip" } | Select-Object -First 1 | Select-Object -ExpandProperty browser_download_url
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

If no asset matches the filter pattern, $downloadUrl will be $null, causing Invoke-RestMethod to fail with an unclear error. Add validation to handle this case.

Add error handling:

$downloadUrl = $latestRelease.assets | Where-Object { $_.name -like "DSC-*-x86_64-pc-windows-msvc.zip" } | Select-Object -First 1 | Select-Object -ExpandProperty browser_download_url
if (-not $downloadUrl) {
    throw "No matching DSC package found for Windows x86_64"
}
Write-Host "Download URL: $downloadUrl"

This provides a clear error message if the expected package isn't found in the release.

Suggested change
$downloadUrl = $latestRelease.assets | Where-Object { $_.name -like "DSC-*-x86_64-pc-windows-msvc.zip" } | Select-Object -First 1 | Select-Object -ExpandProperty browser_download_url
$downloadUrl = $latestRelease.assets | Where-Object { $_.name -like "DSC-*-x86_64-pc-windows-msvc.zip" } | Select-Object -First 1 | Select-Object -ExpandProperty browser_download_url
if (-not $downloadUrl) {
throw "No matching DSC package found for Windows x86_64"
}

Copilot uses AI. Check for mistakes.
Write-Host "Download URL: $downloadUrl"
$tempPath = Get-GWTempPath
Invoke-RestMethod -Uri $downloadUrl -OutFile "$tempPath\DSC.zip"

$null = New-Item -ItemType Directory -Path "$tempPath\DSC" -Force
Expand-Archive -Path "$tempPath\DSC.zip" -DestinationPath "$tempPath\DSC" -Force
$dscRoot = "$tempPath\DSC"
Write-Host "DSC Root: $dscRoot"
Set-GWVariable -Name "DSC_ROOT" -Value $dscRoot

- name: Bootstrap
shell: powershell
run: |-
Expand Down
126 changes: 126 additions & 0 deletions dsc/pwsh.profile.dsc.resource.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
{
"$schema": "https://aka.ms/dsc/schemas/v3/bundled/resource/manifest.json",
"description": "Manage PowerShell profiles.",
"tags": [
"Linux",
"Windows",
"macOS",
"PowerShell"
],
"type": "Microsoft.PowerShell/Profile",
"version": "0.1.0",
"get": {
"executable": "pwsh",
"args": [
"-NoLogo",
"-NonInteractive",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"./pwsh.profile.resource.ps1",
"-operation",
"get"
],
"input": "stdin"
},
"set": {
"executable": "pwsh",
"args": [
"-NoLogo",
"-NonInteractive",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"./pwsh.profile.resource.ps1",
"-operation",
"set"
],
"input": "stdin"
},
"export": {
"executable": "pwsh",
"args": [
"-NoLogo",
"-NonInteractive",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-File",
"./pwsh.profile.resource.ps1",
"-operation",
"export"
],
"input": "stdin"
},
"exitCodes": {
"0": "Success",
"1": "Error",
"2": "Input not supported for export operation"
},
"schema": {
"embedded": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Profile",
"description": "Manage PowerShell profiles.",
"type": "object",
"unevaluatedProperties": false,
"required": [
"profileType"
],
"properties": {
"profileType": {
"type": "string",
"title": "Profile Type",
"description": "Defines which profile to manage. Valid values are: 'AllUsersCurrentHost', 'AllUsersAllHosts', 'CurrentUserAllHosts', and 'CurrentUserCurrentHost'.",
"enum": [
"AllUsersCurrentHost",
"AllUsersAllHosts",
"CurrentUserAllHosts",
"CurrentUserCurrentHost"
]
},
"profilePath": {
"title": "Profile Path",
"description": "The full path to the profile file.",
"type": "string",
"readOnly": true
},
"content": {
"title": "Content",
"description": "Defines the content of the profile. If you don't specify this property, the resource doesn't manage the file contents. If you specify this property as an empty string, the resource removes all content from the file. If you specify this property as a non-empty string, the resource sets the file contents to the specified string. The resources retains newlines from this property without any modification.",
"type": "string"
},
"_exist": {
"$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/exist.json"
},
"_name": {
"$ref": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/name.json"
}
},
"$defs": {
"https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/exist.json": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/exist.json",
"title": "Instance should exist",
"description": "Indicates whether the DSC resource instance should exist.",
"type": "boolean",
"default": true,
"enum": [
false,
true
]
},
"https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/name.json": {
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://raw.githubusercontent.com/PowerShell/DSC/main/schemas/v3/resource/properties/name.json",
"title": "Exported instance name",
"description": "Returns a generated name for the resource instance from an export operation.",
"readOnly": true,
"type": "string"
}
}
}
}
}
179 changes: 179 additions & 0 deletions dsc/pwsh.profile.resource.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
## Copyright (c) Microsoft Corporation. All rights reserved.
## Licensed under the MIT License.

[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[ValidateSet('get', 'set', 'export')]
[string]$Operation,
[Parameter(ValueFromPipeline)]
[string[]]$UserInput
)

Begin {
enum ProfileType {
AllUsersCurrentHost
AllUsersAllHosts
CurrentUserAllHosts
CurrentUserCurrentHost
}

function New-PwshResource {
param(
[Parameter(Mandatory = $true)]
[ProfileType] $ProfileType,

[Parameter(ParameterSetName = 'WithContent')]
[string] $Content,

[Parameter(ParameterSetName = 'WithContent')]
[bool] $Exist
)

# Create the PSCustomObject with properties
$resource = [PSCustomObject]@{
profileType = $ProfileType
content = $null
profilePath = GetProfilePath -profileType $ProfileType
_exist = $false
}

# Add ToJson method
$resource | Add-Member -MemberType ScriptMethod -Name 'ToJson' -Value {
return ([ordered] @{
profileType = $this.profileType
content = $this.content
profilePath = $this.profilePath
_exist = $this._exist
}) | ConvertTo-Json -Compress -EnumsAsStrings
}

# Constructor logic - if Content and Exist parameters are provided (WithContent parameter set)
if ($PSCmdlet.ParameterSetName -eq 'WithContent') {
$resource.content = $Content
$resource._exist = $Exist
} else {
# Default constructor logic - read from file system
$fileExists = Test-Path $resource.profilePath
if ($fileExists) {
$resource.content = Get-Content -Path $resource.profilePath
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

The Get-Content call returns an array of strings (one per line) by default, but this should return the entire content as a single string to match the expected behavior based on the test expectations and the Set-Content usage in line 147.

Add the -Raw parameter to return the content as a single string:

$resource.content = Get-Content -Path $resource.profilePath -Raw

This ensures consistency with how profile content is expected to be handled throughout the resource (as a single string, not an array).

Suggested change
$resource.content = Get-Content -Path $resource.profilePath
$resource.content = Get-Content -Path $resource.profilePath -Raw

Copilot uses AI. Check for mistakes.
} else {
$resource.content = $null
}
$resource._exist = $fileExists
}

return $resource
}

function GetProfilePath {
param (
[ProfileType] $profileType
)

$path = switch ($profileType) {
'AllUsersCurrentHost' { $PROFILE.AllUsersCurrentHost }
'AllUsersAllHosts' { $PROFILE.AllUsersAllHosts }
'CurrentUserAllHosts' { $PROFILE.CurrentUserAllHosts }
'CurrentUserCurrentHost' { $PROFILE.CurrentUserCurrentHost }
}

return $path
}

function ExportOperation {
$allUserCurrentHost = New-PwshResource -ProfileType 'AllUsersCurrentHost'
$allUsersAllHost = New-PwshResource -ProfileType 'AllUsersAllHosts'
$currentUserAllHost = New-PwshResource -ProfileType 'CurrentUserAllHosts'
$currentUserCurrentHost = New-PwshResource -ProfileType 'CurrentUserCurrentHost'

# Cannot use the ToJson() method here as we are adding a note property
$allUserCurrentHost | Add-Member -NotePropertyName '_name' -NotePropertyValue 'AllUsersCurrentHost' -PassThru | ConvertTo-Json -Compress -EnumsAsStrings
$allUsersAllHost | Add-Member -NotePropertyName '_name' -NotePropertyValue 'AllUsersAllHosts' -PassThru | ConvertTo-Json -Compress -EnumsAsStrings
$currentUserAllHost | Add-Member -NotePropertyName '_name' -NotePropertyValue 'CurrentUserAllHosts' -PassThru | ConvertTo-Json -Compress -EnumsAsStrings
$currentUserCurrentHost | Add-Member -NotePropertyName '_name' -NotePropertyValue 'CurrentUserCurrentHost' -PassThru | ConvertTo-Json -Compress -EnumsAsStrings
}

function GetOperation {
param (
[Parameter(Mandatory = $true)]
$InputResource,
[Parameter()]
[switch] $AsJson
)

$profilePath = GetProfilePath -profileType $InputResource.profileType.ToString()

$actualState = New-PwshResource -ProfileType $InputResource.profileType

$actualState.profilePath = $profilePath

$exists = Test-Path $profilePath

if ($InputResource._exist -and $exists) {
$content = Get-Content -Path $profilePath
$actualState.Content = $content
} elseif ($InputResource._exist -and -not $exists) {
$actualState.Content = $null
$actualState._exist = $false
} elseif (-not $InputResource._exist -and $exists) {
$actualState.Content = Get-Content -Path $profilePath
Comment thread
TravisEz13 marked this conversation as resolved.
Comment on lines +114 to +120
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

The Get-Content call returns an array of strings (one per line) by default, but should return the entire content as a single string to match the Set-Content behavior in line 147 and test expectations.

Add the -Raw parameter:

$content = Get-Content -Path $profilePath -Raw

Without -Raw, this returns an array which could cause mismatches when comparing with expected string values.

Suggested change
$content = Get-Content -Path $profilePath
$actualState.Content = $content
} elseif ($InputResource._exist -and -not $exists) {
$actualState.Content = $null
$actualState._exist = $false
} elseif (-not $InputResource._exist -and $exists) {
$actualState.Content = Get-Content -Path $profilePath
$content = Get-Content -Path $profilePath -Raw
$actualState.Content = $content
} elseif ($InputResource._exist -and -not $exists) {
$actualState.Content = $null
$actualState._exist = $false
} elseif (-not $InputResource._exist -and $exists) {
$actualState.Content = Get-Content -Path $profilePath -Raw

Copilot uses AI. Check for mistakes.
$actualState._exist = $true
} else {
$actualState.Content = $null
$actualState._exist = $false
}

if ($AsJson) {
return $actualState.ToJson()
} else {
return $actualState
}
}

function SetOperation {
param (
$InputResource
)

$actualState = GetOperation -InputResource $InputResource

if ($InputResource._exist) {
if (-not $actualState._exist) {
$null = New-Item -Path $actualState.profilePath -ItemType File -Force
}

if ($null -ne $InputResource.content) {
Set-Content -Path $actualState.profilePath -Value $InputResource.content
}
} elseif ($actualState._exist) {
Remove-Item -Path $actualState.profilePath -Force
}
}
}
End {
$inputJson = $input | ConvertFrom-Json

if ($inputJson) {
$InputResource = New-PwshResource -ProfileType $inputJson.profileType -Content $inputJson.content -Exist $inputJson._exist
}

switch ($Operation) {
'get' {
GetOperation -InputResource $InputResource -AsJson
}
'set' {
SetOperation -InputResource $InputResource
Copy link

Copilot AI Nov 13, 2025

Choose a reason for hiding this comment

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

[nitpick] The 'set' operation should return the resource state after making changes. While DSC v3 can call 'get' automatically after 'set' if no output is provided, returning the state directly is more efficient and provides immediate feedback.

Update the 'set' case to return the final state:

'set' {
    SetOperation -InputResource $InputResource
    GetOperation -InputResource $InputResource -AsJson
}

This aligns with DSC v3 best practices and ensures the test expectations in lines 78-80 are properly supported without requiring an additional 'get' operation.

Suggested change
SetOperation -InputResource $InputResource
SetOperation -InputResource $InputResource
GetOperation -InputResource $InputResource -AsJson

Copilot uses AI. Check for mistakes.
}
'export' {
if ($inputJson) {
Write-Error "Input not supported for export operation"
exit 2
}

ExportOperation
}
}

exit 0
}
1 change: 1 addition & 0 deletions experimental-feature-linux.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"PSFeedbackProvider",
"PSLoadAssemblyFromNativeCode",
"PSNativeWindowsTildeExpansion",
"PSProfileDSCResource",
"PSSerializeJSONLongEnumAsNumber",
"PSRedirectToVariable",
"PSSubsystemPluginModel"
Expand Down
1 change: 1 addition & 0 deletions experimental-feature-windows.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
"PSFeedbackProvider",
"PSLoadAssemblyFromNativeCode",
"PSNativeWindowsTildeExpansion",
"PSProfileDSCResource",
"PSSerializeJSONLongEnumAsNumber",
"PSRedirectToVariable",
"PSSubsystemPluginModel"
Expand Down
Loading
Loading