microsoft/hve-core

Public

mirrored from https://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v3.0.1

Branches

Tags

  • No tags available.
0Branches0Tags
Go to file
Add file
Code

Clone

HTTPS

Download ZIP

scripts/extension/Package-Extension.ps1

1078lines · modecode

1#!/usr/bin/env pwsh
2# Copyright (c) Microsoft Corporation.
3# SPDX-License-Identifier: MIT
4#Requires -Version 7.0
5
6<#
7.SYNOPSIS
8 Packages the HVE Core VS Code extension.
9
10.DESCRIPTION
11 This script packages the VS Code extension into a .vsix file.
12 It uses the version from package.json or a specified version.
13 Optionally adds a dev patch number for pre-release builds.
14 Supports VS Code Marketplace pre-release channel with -PreRelease switch.
15
16.PARAMETER Version
17 Optional. The version to use for the package.
18 If not specified, uses the version from package.json.
19
20.PARAMETER DevPatchNumber
21 Optional. Dev patch number to append (e.g., "123" creates "1.0.0-dev.123").
22
23.PARAMETER ChangelogPath
24 Optional. Path to a changelog file to include in the package.
25
26.PARAMETER PreRelease
27 Optional. When specified, packages the extension for VS Code Marketplace pre-release channel.
28 Uses vsce --pre-release flag which marks the extension for the pre-release track.
29
30.PARAMETER Collection
31 Optional. Path to a collection manifest file (YAML or JSON). When specified, only
32 collection-filtered artifacts are copied and the output filename uses the
33 collection ID.
34
35.PARAMETER DryRun
36 Optional. Validates packaging orchestration without invoking vsce.
37
38.EXAMPLE
39 ./Package-Extension.ps1
40 # Packages using version from package.json
41
42.EXAMPLE
43 ./Package-Extension.ps1 -Version "2.0.0"
44 # Packages with specific version
45
46.EXAMPLE
47 ./Package-Extension.ps1 -DevPatchNumber "123"
48 # Packages with dev version (e.g., 1.0.0-dev.123)
49
50.EXAMPLE
51 ./Package-Extension.ps1 -Version "1.1.0" -DevPatchNumber "456"
52 # Packages with specific dev version (1.1.0-dev.456)
53
54.EXAMPLE
55 ./Package-Extension.ps1 -PreRelease
56 # Packages for VS Code Marketplace pre-release channel
57
58.EXAMPLE
59 ./Package-Extension.ps1 -Version "1.1.0" -PreRelease
60 # Packages with ODD minor version for pre-release channel
61
62.EXAMPLE
63 . ./Package-Extension.ps1
64 # Dot-source to import functions for testing without executing packaging.
65#>
66
67[CmdletBinding()]
68param(
69 [Parameter(Mandatory = $false)]
70 [string]$Version = "",
71
72 [Parameter(Mandatory = $false)]
73 [string]$DevPatchNumber = "",
74
75 [Parameter(Mandatory = $false)]
76 [string]$ChangelogPath = "",
77
78 [Parameter(Mandatory = $false)]
79 [switch]$PreRelease,
80
81 [Parameter(Mandatory = $false)]
82 [string]$Collection = "",
83
84 [Parameter(Mandatory = $false)]
85 [Alias('dry-run')]
86 [switch]$DryRun
87)
88
89$ErrorActionPreference = 'Stop'
90
91Import-Module (Join-Path $PSScriptRoot "../lib/Modules/CIHelpers.psm1") -Force
92Import-Module (Join-Path $PSScriptRoot "../plugins/Modules/PluginHelpers.psm1") -Force
93
94#region Pure Functions
95
96function Test-VsceAvailable {
97 <#
98 .SYNOPSIS
99 Checks if vsce or npx is available for packaging.
100 .OUTPUTS
101 Hashtable with IsAvailable, CommandType ('vsce', 'npx', or $null), and Command path.
102 #>
103 [CmdletBinding()]
104 [OutputType([hashtable])]
105 param()
106
107 $vsceCmd = Get-Command vsce -ErrorAction SilentlyContinue
108 if ($vsceCmd) {
109 return @{
110 IsAvailable = $true
111 CommandType = 'vsce'
112 Command = $vsceCmd.Source
113 }
114 }
115
116 $npxCmd = Get-Command npx -ErrorAction SilentlyContinue
117 if ($npxCmd) {
118 return @{
119 IsAvailable = $true
120 CommandType = 'npx'
121 Command = $npxCmd.Source
122 }
123 }
124
125 return @{
126 IsAvailable = $false
127 CommandType = $null
128 Command = $null
129 }
130}
131
132function Test-ExtensionManifestValid {
133 <#
134 .SYNOPSIS
135 Validates an extension manifest (package.json content) for required fields and format.
136 .PARAMETER ManifestContent
137 The parsed package.json content as a PSObject.
138 .OUTPUTS
139 Hashtable with IsValid boolean and Errors array.
140 #>
141 [CmdletBinding()]
142 [OutputType([hashtable])]
143 param(
144 [Parameter(Mandatory = $true)]
145 [PSObject]$ManifestContent
146 )
147
148 $errors = @()
149
150 # Check required fields
151 if (-not $ManifestContent.PSObject.Properties['name']) {
152 $errors += "Missing required 'name' field"
153 }
154
155 if (-not $ManifestContent.PSObject.Properties['version']) {
156 $errors += "Missing required 'version' field"
157 } elseif ($ManifestContent.version -notmatch '^\d+\.\d+\.\d+') {
158 $errors += "Invalid version format: '$($ManifestContent.version)'. Expected semantic version (e.g., 1.0.0)"
159 }
160
161 if (-not $ManifestContent.PSObject.Properties['publisher']) {
162 $errors += "Missing required 'publisher' field"
163 }
164
165 if (-not $ManifestContent.PSObject.Properties['engines']) {
166 $errors += "Missing required 'engines' field"
167 } elseif (-not $ManifestContent.engines.PSObject.Properties['vscode']) {
168 $errors += "Missing required 'engines.vscode' field"
169 }
170
171 return @{
172 IsValid = ($errors.Count -eq 0)
173 Errors = $errors
174 }
175}
176
177function Get-VscePackageCommand {
178 <#
179 .SYNOPSIS
180 Builds the vsce package command arguments without executing.
181 .PARAMETER CommandType
182 The type of command to use ('vsce' or 'npx').
183 .PARAMETER PreRelease
184 Whether to include the --pre-release flag.
185 .OUTPUTS
186 Hashtable with Executable and Arguments array.
187 #>
188 [CmdletBinding()]
189 [OutputType([hashtable])]
190 param(
191 [Parameter(Mandatory = $true)]
192 [ValidateSet('vsce', 'npx')]
193 [string]$CommandType,
194
195 [Parameter(Mandatory = $false)]
196 [switch]$PreRelease
197 )
198
199 $vsceArgs = @('package', '--no-dependencies')
200 if ($PreRelease) {
201 $vsceArgs += '--pre-release'
202 }
203
204 if ($CommandType -eq 'npx') {
205 # --yes auto-confirms npx package installation for non-interactive CI environments
206 return @{
207 Executable = 'npx'
208 Arguments = @('--yes', '@vscode/vsce') + $vsceArgs
209 }
210 }
211
212 return @{
213 Executable = 'vsce'
214 Arguments = $vsceArgs
215 }
216}
217
218function New-PackagingResult {
219 <#
220 .SYNOPSIS
221 Creates a standardized packaging result object.
222 .PARAMETER Success
223 Whether the packaging operation succeeded.
224 .PARAMETER OutputPath
225 Path to the generated .vsix file (if successful).
226 .PARAMETER Version
227 The package version used.
228 .PARAMETER ErrorMessage
229 Error message if the operation failed.
230 .OUTPUTS
231 Hashtable with Success, OutputPath, Version, and ErrorMessage.
232 #>
233 [CmdletBinding()]
234 [OutputType([hashtable])]
235 param(
236 [Parameter(Mandatory = $true)]
237 [bool]$Success,
238
239 [Parameter(Mandatory = $false)]
240 [string]$OutputPath = "",
241
242 [Parameter(Mandatory = $false)]
243 [string]$Version = "",
244
245 [Parameter(Mandatory = $false)]
246 [string]$ErrorMessage = ""
247 )
248
249 return @{
250 Success = $Success
251 OutputPath = $OutputPath
252 Version = $Version
253 ErrorMessage = $ErrorMessage
254 }
255}
256
257function Get-CollectionReadmePath {
258 <#
259 .SYNOPSIS
260 Resolves the collection-specific README path from a collection manifest.
261 .DESCRIPTION
262 Maps a collection manifest to its collection-specific README file. Returns
263 null when the collection is the flagship package (hve-core) or when no
264 matching collection README exists on disk. Supports both YAML and JSON
265 manifest formats.
266 .PARAMETER CollectionPath
267 Path to the collection manifest file (YAML or JSON).
268 .PARAMETER ExtensionDirectory
269 Path to the extension directory containing README files.
270 .OUTPUTS
271 String path to the collection README, or $null if not applicable.
272 #>
273 [CmdletBinding()]
274 [OutputType([string])]
275 param(
276 [Parameter(Mandatory = $true)]
277 [string]$CollectionPath,
278
279 [Parameter(Mandatory = $true)]
280 [string]$ExtensionDirectory
281 )
282
283 $manifest = Get-CollectionManifest -CollectionPath $CollectionPath
284 $collectionId = $manifest.id
285
286 # Flagship package uses the default README.md
287 if ($collectionId -eq 'hve-core') {
288 return $null
289 }
290
291 $collectionReadmePath = Join-Path $ExtensionDirectory "README.$collectionId.md"
292 if (Test-Path $collectionReadmePath) {
293 return $collectionReadmePath
294 }
295
296 return $null
297}
298
299function Get-ResolvedPackageVersion {
300 <#
301 .SYNOPSIS
302 Resolves the package version from parameters or manifest content.
303 .PARAMETER SpecifiedVersion
304 Version specified via parameter (may be empty).
305 .PARAMETER ManifestVersion
306 Version from the package.json manifest.
307 .PARAMETER DevPatchNumber
308 Optional dev patch number to append.
309 .OUTPUTS
310 Hashtable with IsValid, BaseVersion, PackageVersion, and ErrorMessage.
311 #>
312 [CmdletBinding()]
313 [OutputType([hashtable])]
314 param(
315 [Parameter(Mandatory = $false)]
316 [string]$SpecifiedVersion = "",
317
318 [Parameter(Mandatory = $true)]
319 [string]$ManifestVersion,
320
321 [Parameter(Mandatory = $false)]
322 [string]$DevPatchNumber = ""
323 )
324
325 $baseVersion = ""
326
327 if ($SpecifiedVersion -and $SpecifiedVersion -ne "") {
328 # Validate specified version format
329 if ($SpecifiedVersion -notmatch '^\d+\.\d+\.\d+$') {
330 return @{
331 IsValid = $false
332 BaseVersion = ""
333 PackageVersion = ""
334 ErrorMessage = "Invalid version format specified: '$SpecifiedVersion'. Expected semantic version format (e.g., 1.0.0)."
335 }
336 }
337 $baseVersion = $SpecifiedVersion
338 } else {
339 # Validate manifest version
340 if ($ManifestVersion -notmatch '^\d+\.\d+\.\d+') {
341 return @{
342 IsValid = $false
343 BaseVersion = ""
344 PackageVersion = ""
345 ErrorMessage = "Invalid version format in package.json: '$ManifestVersion'. Expected semantic version format (e.g., 1.0.0)."
346 }
347 }
348 # Extract base version
349 $ManifestVersion -match '^(\d+\.\d+\.\d+)' | Out-Null
350 $baseVersion = $Matches[1]
351 }
352
353 # Apply dev patch number if provided
354 $packageVersion = if ($DevPatchNumber -and $DevPatchNumber -ne "") {
355 "$baseVersion-dev.$DevPatchNumber"
356 } else {
357 $baseVersion
358 }
359
360 return @{
361 IsValid = $true
362 BaseVersion = $baseVersion
363 PackageVersion = $packageVersion
364 ErrorMessage = ""
365 }
366}
367
368function Test-PackagingInputsValid {
369 <#
370 .SYNOPSIS
371 Validates all required paths for extension packaging.
372 .DESCRIPTION
373 Pure function that checks existence of ExtensionDirectory, package.json,
374 .github directory, and CIHelpers.psm1 module. Returns resolved paths for use
375 by downstream functions.
376 .PARAMETER ExtensionDirectory
377 Absolute path to the extension directory.
378 .PARAMETER RepoRoot
379 Absolute path to the repository root.
380 .OUTPUTS
381 Hashtable with IsValid, Errors array, and resolved paths.
382 #>
383 [CmdletBinding()]
384 [OutputType([hashtable])]
385 param(
386 [Parameter(Mandatory = $true)]
387 [string]$ExtensionDirectory,
388
389 [Parameter(Mandatory = $true)]
390 [string]$RepoRoot
391 )
392
393 $errors = @()
394
395 if (-not (Test-Path $ExtensionDirectory)) {
396 $errors += "Extension directory not found: $ExtensionDirectory"
397 }
398
399 $packageJsonPath = Join-Path $ExtensionDirectory "package.json"
400 if (-not (Test-Path $packageJsonPath)) {
401 $errors += "package.json not found: $packageJsonPath"
402 }
403
404 $githubDir = Join-Path $RepoRoot ".github"
405 if (-not (Test-Path $githubDir)) {
406 $errors += ".github directory not found: $githubDir"
407 }
408
409 $ciHelpersPath = Join-Path $RepoRoot "scripts/lib/Modules/CIHelpers.psm1"
410 if (-not (Test-Path $ciHelpersPath)) {
411 $errors += "CIHelpers.psm1 not found: $ciHelpersPath"
412 }
413
414 return @{
415 IsValid = ($errors.Count -eq 0)
416 Errors = $errors
417 PackageJsonPath = $packageJsonPath
418 GitHubDir = $githubDir
419 CIHelpersPath = $ciHelpersPath
420 }
421}
422
423function Get-PackagingDirectorySpec {
424 <#
425 .SYNOPSIS
426 Returns specification for directories to copy during packaging.
427 .DESCRIPTION
428 Pure function that defines source to destination mappings without performing I/O.
429 Each spec includes Source, Destination, Required flag, and optional IsFile flag.
430 .PARAMETER RepoRoot
431 Absolute path to the repository root.
432 .PARAMETER ExtensionDirectory
433 Absolute path to the extension directory.
434 .OUTPUTS
435 Array of hashtables with Source, Destination, Required, and IsFile properties.
436 #>
437 [CmdletBinding()]
438 [OutputType([hashtable[]])]
439 param(
440 [Parameter(Mandatory = $true)]
441 [string]$RepoRoot,
442
443 [Parameter(Mandatory = $true)]
444 [string]$ExtensionDirectory
445 )
446
447 return @(
448 @{
449 Source = Join-Path $RepoRoot ".github"
450 Destination = Join-Path $ExtensionDirectory ".github"
451 IsFile = $false
452 },
453 @{
454 Source = Join-Path $RepoRoot "scripts/lib/Modules/CIHelpers.psm1"
455 Destination = Join-Path $ExtensionDirectory "scripts/lib/Modules/CIHelpers.psm1"
456 IsFile = $true
457 },
458 @{
459 Source = Join-Path $RepoRoot "docs/templates"
460 Destination = Join-Path $ExtensionDirectory "docs/templates"
461 IsFile = $false
462 }
463 )
464}
465
466#endregion Pure Functions
467
468#region I/O Functions
469
470function Copy-CollectionArtifacts {
471 <#
472 .SYNOPSIS
473 Copies only collection-filtered artifacts to the extension directory.
474 .DESCRIPTION
475 Reads the prepared package.json to determine which artifacts were selected
476 by collection filtering, then copies only those files instead of the entire
477 .github directory.
478 .PARAMETER RepoRoot
479 Absolute path to the repository root.
480 .PARAMETER ExtensionDirectory
481 Absolute path to the extension directory.
482 .PARAMETER PrepareResult
483 Result hashtable from Invoke-PrepareExtension. Reserved for future collection metadata handling.
484 #>
485 [CmdletBinding()]
486 [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'PrepareResult', Justification = 'Reserved for future collection metadata handling')]
487 param(
488 [Parameter(Mandatory = $true)]
489 [string]$RepoRoot,
490
491 [Parameter(Mandatory = $true)]
492 [string]$ExtensionDirectory,
493
494 [Parameter(Mandatory = $true)]
495 [hashtable]$PrepareResult
496 )
497
498 $preparedPkgJson = Get-Content -Path (Join-Path $ExtensionDirectory "package.json") -Raw | ConvertFrom-Json
499
500 # Copy filtered agents
501 if ($preparedPkgJson.contributes.chatAgents) {
502 $agentsDestDir = Join-Path $ExtensionDirectory ".github/agents"
503 New-Item -Path $agentsDestDir -ItemType Directory -Force | Out-Null
504 foreach ($agent in $preparedPkgJson.contributes.chatAgents) {
505 $srcPath = Join-Path $RepoRoot ($agent.path -replace '^\.[\\/]', '')
506 if (-not (Test-Path $srcPath)) {
507 Write-Warning "Skipping missing collection artifact: $srcPath (referenced by contributes.chatAgents in package.json)"
508 continue
509 }
510 Copy-Item -Path $srcPath -Destination $agentsDestDir -Force
511 }
512 }
513
514 # Copy filtered prompts
515 if ($preparedPkgJson.contributes.chatPromptFiles) {
516 foreach ($prompt in $preparedPkgJson.contributes.chatPromptFiles) {
517 $srcPath = Join-Path $RepoRoot ($prompt.path -replace '^\.[\\/]', '')
518 if (-not (Test-Path $srcPath)) {
519 Write-Warning "Skipping missing collection artifact: $srcPath (referenced by contributes.chatPromptFiles in package.json)"
520 continue
521 }
522 $destPath = Join-Path $ExtensionDirectory ($prompt.path -replace '^\.[\\/]', '')
523 $destDir = Split-Path $destPath -Parent
524 New-Item -Path $destDir -ItemType Directory -Force | Out-Null
525 Copy-Item -Path $srcPath -Destination $destPath -Force
526 }
527 }
528
529 # Copy filtered instructions
530 if ($preparedPkgJson.contributes.chatInstructions) {
531 foreach ($instr in $preparedPkgJson.contributes.chatInstructions) {
532 $srcPath = Join-Path $RepoRoot ($instr.path -replace '^\.[\\/]', '')
533 if (-not (Test-Path $srcPath)) {
534 Write-Warning "Skipping missing collection artifact: $srcPath (referenced by contributes.chatInstructions in package.json)"
535 continue
536 }
537 $destPath = Join-Path $ExtensionDirectory ($instr.path -replace '^\.[\\/]', '')
538 $destDir = Split-Path $destPath -Parent
539 New-Item -Path $destDir -ItemType Directory -Force | Out-Null
540 Copy-Item -Path $srcPath -Destination $destPath -Force
541 }
542 }
543
544 # Copy filtered skills
545 if ($preparedPkgJson.contributes.chatSkills) {
546 foreach ($skill in $preparedPkgJson.contributes.chatSkills) {
547 $srcPath = Join-Path $RepoRoot ($skill.path -replace '^\.[\\/]', '')
548 if (-not (Test-Path $srcPath)) {
549 Write-Warning "Skipping missing collection artifact: $srcPath (referenced by contributes.chatSkills in package.json)"
550 continue
551 }
552 $destPath = Join-Path $ExtensionDirectory ($skill.path -replace '^\.[\\/]', '')
553 $destDir = Split-Path $destPath -Parent
554 New-Item -Path $destDir -ItemType Directory -Force | Out-Null
555 Copy-Item -Path $srcPath -Destination $destPath -Recurse -Force
556
557 # Remove co-located test directories from packaged skills
558 Get-ChildItem -Path $destPath -Directory -Filter 'tests' -Recurse -ErrorAction SilentlyContinue |
559 Remove-Item -Recurse -Force
560 }
561 }
562}
563
564function Set-CollectionReadme {
565 <#
566 .SYNOPSIS
567 Swaps or restores the collection-specific README for extension packaging.
568 .DESCRIPTION
569 In swap mode, backs up the original README.md and copies the collection
570 README in its place. In restore mode, copies the backup back and removes it.
571 .PARAMETER ExtensionDirectory
572 Path to the extension directory.
573 .PARAMETER CollectionReadmePath
574 Path to the collection-specific README file. Required for Swap operation.
575 .PARAMETER Operation
576 Either 'Swap' to replace README.md with collection content, or 'Restore'
577 to revert README.md from backup.
578 #>
579 [CmdletBinding()]
580 param(
581 [Parameter(Mandatory = $true)]
582 [string]$ExtensionDirectory,
583
584 [Parameter(Mandatory = $false)]
585 [string]$CollectionReadmePath = "",
586
587 [Parameter(Mandatory = $true)]
588 [ValidateSet('Swap', 'Restore')]
589 [string]$Operation
590 )
591
592 $readmePath = Join-Path $ExtensionDirectory "README.md"
593 $backupPath = Join-Path $ExtensionDirectory "README.md.bak"
594
595 if ($Operation -eq 'Swap') {
596 if (-not $CollectionReadmePath -or $CollectionReadmePath -eq "") {
597 Write-Warning "No collection README path provided for swap operation"
598 return
599 }
600 Copy-Item -Path $readmePath -Destination $backupPath -Force
601 Copy-Item -Path $CollectionReadmePath -Destination $readmePath -Force
602 Write-Host " Swapped README.md with $(Split-Path $CollectionReadmePath -Leaf)" -ForegroundColor Green
603 }
604 elseif ($Operation -eq 'Restore') {
605 if (Test-Path $backupPath) {
606 Copy-Item -Path $backupPath -Destination $readmePath -Force
607 Remove-Item -Path $backupPath -Force
608 Write-Host " Restored original README.md" -ForegroundColor Green
609 }
610 }
611}
612
613function Invoke-VsceCommand {
614 <#
615 .SYNOPSIS
616 Executes vsce package command with platform-appropriate wrapper.
617 .DESCRIPTION
618 Abstracts platform-specific execution of vsce/npx commands. On Windows with npx,
619 uses cmd /c to avoid PowerShell misinterpreting @ in @vscode/vsce as splatting.
620 The UseWindowsWrapper parameter enables deterministic platform behavior in tests.
621 .PARAMETER Executable
622 The executable to run ('vsce' or 'npx').
623 .PARAMETER Arguments
624 Array of arguments to pass to the executable.
625 .PARAMETER WorkingDirectory
626 Directory to execute the command in.
627 .PARAMETER UseWindowsWrapper
628 When true and Executable is 'npx', uses cmd /c wrapper for Windows compatibility.
629 .OUTPUTS
630 Hashtable with Success boolean and ExitCode integer.
631 #>
632 [CmdletBinding()]
633 [OutputType([hashtable])]
634 param(
635 [Parameter(Mandatory = $true)]
636 [string]$Executable,
637
638 [Parameter(Mandatory = $true)]
639 [string[]]$Arguments,
640
641 [Parameter(Mandatory = $true)]
642 [string]$WorkingDirectory,
643
644 [Parameter(Mandatory = $false)]
645 [switch]$UseWindowsWrapper
646 )
647
648 Push-Location $WorkingDirectory
649 try {
650 $global:LASTEXITCODE = 0
651
652 if ($UseWindowsWrapper -and $Executable -eq 'npx') {
653 $cmdArgs = @('/c', 'npx') + $Arguments
654 & cmd @cmdArgs
655 } else {
656 & $Executable @Arguments
657 }
658
659 return @{
660 Success = ($LASTEXITCODE -eq 0)
661 ExitCode = $LASTEXITCODE
662 }
663 }
664 finally {
665 Pop-Location
666 }
667}
668
669function Remove-PackagingArtifacts {
670 <#
671 .SYNOPSIS
672 Removes temporary directories created during packaging.
673 .DESCRIPTION
674 Cleans up directories copied to the extension folder during the packaging process.
675 Silently skips directories that do not exist.
676 .PARAMETER ExtensionDirectory
677 Absolute path to the extension directory.
678 .PARAMETER DirectoryNames
679 Array of directory names to remove. Defaults to .github, docs, scripts.
680 #>
681 [CmdletBinding()]
682 param(
683 [Parameter(Mandatory = $true)]
684 [string]$ExtensionDirectory,
685
686 [Parameter(Mandatory = $false)]
687 [string[]]$DirectoryNames = @(".github", "docs", "scripts")
688 )
689
690 foreach ($dir in $DirectoryNames) {
691 $dirPath = Join-Path $ExtensionDirectory $dir
692 if (Test-Path $dirPath) {
693 Remove-Item -Path $dirPath -Recurse -Force
694 Write-Host " Removed $dir" -ForegroundColor Gray
695 }
696 }
697}
698
699function Restore-PackageJsonVersion {
700 <#
701 .SYNOPSIS
702 Restores original version in package.json after packaging.
703 .DESCRIPTION
704 Writes the original version back to package.json if it was temporarily modified
705 during packaging. Safely handles null inputs by returning early.
706 .PARAMETER PackageJsonPath
707 Absolute path to the package.json file.
708 .PARAMETER PackageJson
709 The parsed package.json object to modify.
710 .PARAMETER OriginalVersion
711 The original version string to restore.
712 #>
713 [CmdletBinding()]
714 param(
715 [Parameter(Mandatory = $false)]
716 [AllowNull()]
717 [string]$PackageJsonPath,
718
719 [Parameter(Mandatory = $false)]
720 [AllowNull()]
721 [PSObject]$PackageJson,
722
723 [Parameter(Mandatory = $false)]
724 [AllowNull()]
725 [string]$OriginalVersion
726 )
727
728 # Handle null coercion: PowerShell converts $null to empty string for [string] params
729 if ([string]::IsNullOrEmpty($OriginalVersion) -or $null -eq $PackageJson -or [string]::IsNullOrEmpty($PackageJsonPath)) {
730 return
731 }
732
733 try {
734 $PackageJson.version = $OriginalVersion
735 $PackageJson | ConvertTo-Json -Depth 10 | Set-Content -Path $PackageJsonPath -Encoding UTF8NoBOM
736 Write-Host " Version restored to: $OriginalVersion" -ForegroundColor Green
737 }
738 catch {
739 Write-Warning "Failed to restore original package.json version to '$OriginalVersion': $($_.Exception.Message)"
740 }
741}
742
743#endregion I/O Functions
744
745#region Orchestration Functions
746
747function Invoke-PackageExtension {
748 <#
749 .SYNOPSIS
750 Orchestrates VS Code extension packaging with full error handling.
751 .DESCRIPTION
752 Executes the complete packaging workflow: validates paths, resolves version,
753 prepares directories, invokes vsce, and handles cleanup.
754 .PARAMETER ExtensionDirectory
755 Absolute path to the extension directory containing package.json.
756 .PARAMETER RepoRoot
757 Absolute path to the repository root directory.
758 .PARAMETER Version
759 Optional explicit version string (e.g., "1.2.3").
760 .PARAMETER DevPatchNumber
761 Optional dev build patch number for pre-release versions.
762 .PARAMETER ChangelogPath
763 Optional path to changelog file to include in package.
764 .PARAMETER PreRelease
765 Switch to mark the package as a pre-release version.
766 .PARAMETER Collection
767 Optional path to a collection manifest file (YAML or JSON). When specified, only
768 collection-filtered artifacts are copied and the output filename uses the
769 collection ID.
770 .PARAMETER DryRun
771 When specified, validates packaging orchestration without invoking vsce.
772 .OUTPUTS
773 Hashtable with Success, OutputPath, Version, and ErrorMessage properties.
774 #>
775 [CmdletBinding()]
776 [OutputType([hashtable])]
777 param(
778 [Parameter(Mandatory = $true)]
779 [ValidateNotNullOrEmpty()]
780 [string]$ExtensionDirectory,
781
782 [Parameter(Mandatory = $true)]
783 [ValidateNotNullOrEmpty()]
784 [string]$RepoRoot,
785
786 [Parameter(Mandatory = $false)]
787 [string]$Version = "",
788
789 [Parameter(Mandatory = $false)]
790 [string]$DevPatchNumber = "",
791
792 [Parameter(Mandatory = $false)]
793 [string]$ChangelogPath = "",
794
795 [Parameter(Mandatory = $false)]
796 [switch]$PreRelease,
797
798 [Parameter(Mandatory = $false)]
799 [string]$Collection = "",
800
801 [Parameter(Mandatory = $false)]
802 [switch]$DryRun
803 )
804
805 $dirsToClean = @(".github", "docs", "scripts")
806 $originalVersion = $null
807 $packageJson = $null
808 $PackageJsonPath = $null
809 $packageVersion = $null
810 $versionWasModified = $false
811
812 try {
813 # Validate all inputs using pure function
814 $inputValidation = Test-PackagingInputsValid -ExtensionDirectory $ExtensionDirectory -RepoRoot $RepoRoot
815 if (-not $inputValidation.IsValid) {
816 return New-PackagingResult -Success $false -ErrorMessage ($inputValidation.Errors -join '; ')
817 }
818
819 $PackageJsonPath = $inputValidation.PackageJsonPath
820
821 Write-Host "📦 HVE Core Extension Packager" -ForegroundColor Cyan
822 Write-Host "==============================" -ForegroundColor Cyan
823 Write-Host ""
824
825 # Read and validate package.json
826 Write-Host "📖 Reading package.json..." -ForegroundColor Yellow
827 try {
828 $packageJson = Get-Content -Path $PackageJsonPath -Raw | ConvertFrom-Json
829 }
830 catch {
831 return New-PackagingResult -Success $false -ErrorMessage "Failed to parse package.json: $($_.Exception.Message)"
832 }
833
834 $manifestValidation = Test-ExtensionManifestValid -ManifestContent $packageJson
835 if (-not $manifestValidation.IsValid) {
836 return New-PackagingResult -Success $false -ErrorMessage "Invalid package.json: $($manifestValidation.Errors -join '; ')"
837 }
838
839 # Resolve version using pure function
840 $versionResult = Get-ResolvedPackageVersion `
841 -SpecifiedVersion $Version `
842 -ManifestVersion $packageJson.version `
843 -DevPatchNumber $DevPatchNumber
844
845 if (-not $versionResult.IsValid) {
846 return New-PackagingResult -Success $false -ErrorMessage $versionResult.ErrorMessage
847 }
848
849 $packageVersion = $versionResult.PackageVersion
850 Write-Host " Using version: $packageVersion" -ForegroundColor Green
851
852 # Handle temporary version update for dev builds
853 $originalVersion = $packageJson.version
854
855 if ($packageVersion -ne $originalVersion) {
856 Write-Host ""
857 Write-Host "📝 Temporarily updating package.json version..." -ForegroundColor Yellow
858 $packageJson.version = $packageVersion
859 $packageJson | ConvertTo-Json -Depth 10 | Set-Content -Path $PackageJsonPath -Encoding UTF8NoBOM
860 Write-Host " Version: $originalVersion -> $packageVersion" -ForegroundColor Green
861 $versionWasModified = $true
862 }
863
864 # Handle changelog if provided
865 if ($ChangelogPath -and $ChangelogPath -ne "") {
866 Write-Host ""
867 Write-Host "📋 Processing changelog..." -ForegroundColor Yellow
868
869 if (Test-Path $ChangelogPath) {
870 $changelogDest = Join-Path $ExtensionDirectory "CHANGELOG.md"
871 Copy-Item -Path $ChangelogPath -Destination $changelogDest -Force
872 Write-Host " Copied changelog to extension directory" -ForegroundColor Green
873 }
874 else {
875 Write-Warning "Changelog file not found: $ChangelogPath"
876 }
877 }
878
879 # Prepare extension directory
880 Write-Host ""
881 Write-Host "🗂️ Preparing extension directory..." -ForegroundColor Yellow
882
883 # Clean any existing copied directories
884 foreach ($dir in $dirsToClean) {
885 $dirPath = Join-Path $ExtensionDirectory $dir
886 if (Test-Path $dirPath) {
887 Remove-Item -Path $dirPath -Recurse -Force
888 Write-Host " Cleaned existing $dir directory" -ForegroundColor Gray
889 }
890 }
891
892 # Get and execute copy specifications
893 $copySpecs = Get-PackagingDirectorySpec -RepoRoot $RepoRoot -ExtensionDirectory $ExtensionDirectory
894
895 if ($Collection -and $Collection -ne "") {
896 # Collection mode: copy only filtered artifacts for .github content
897 Write-Host " Using collection-filtered artifact copy..." -ForegroundColor Gray
898
899 # Copy non-.github specs normally
900 foreach ($spec in $copySpecs) {
901 if ($spec.Source -like "*/.github*" -or $spec.Source -like "*\.github*") {
902 continue
903 }
904 $specName = Split-Path $spec.Source -Leaf
905 Write-Host " Copying $specName..." -ForegroundColor Gray
906
907 if ($spec.IsFile) {
908 $parentDir = Split-Path $spec.Destination -Parent
909 New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
910 Copy-Item -Path $spec.Source -Destination $spec.Destination -Force
911 } else {
912 $parentDir = Split-Path $spec.Destination -Parent
913 if (-not (Test-Path $parentDir)) {
914 New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
915 }
916 Copy-Item -Path $spec.Source -Destination $spec.Destination -Recurse -Force
917 }
918 }
919
920 # Copy collection-specific artifacts
921 Copy-CollectionArtifacts -RepoRoot $RepoRoot -ExtensionDirectory $ExtensionDirectory -PrepareResult @{}
922 } else {
923 # Full mode: copy everything as before
924 foreach ($spec in $copySpecs) {
925 $specName = Split-Path $spec.Source -Leaf
926 Write-Host " Copying $specName..." -ForegroundColor Gray
927
928 if ($spec.IsFile) {
929 $parentDir = Split-Path $spec.Destination -Parent
930 New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
931 Copy-Item -Path $spec.Source -Destination $spec.Destination -Force
932 } else {
933 $parentDir = Split-Path $spec.Destination -Parent
934 if (-not (Test-Path $parentDir)) {
935 New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
936 }
937 Copy-Item -Path $spec.Source -Destination $spec.Destination -Recurse -Force
938 }
939 }
940 }
941
942 Write-Host " ✅ Extension directory prepared" -ForegroundColor Green
943
944 # Swap collection README if collection specifies one
945 if ($Collection -and $Collection -ne "") {
946 $collectionReadmePath = Get-CollectionReadmePath -CollectionPath $Collection -ExtensionDirectory $ExtensionDirectory
947 if ($collectionReadmePath) {
948 Write-Host ""
949 Write-Host "📄 Applying collection README..." -ForegroundColor Yellow
950 Set-CollectionReadme -ExtensionDirectory $ExtensionDirectory -CollectionReadmePath $collectionReadmePath -Operation Swap
951 }
952 }
953
954 if ($DryRun) {
955 Write-Host ""
956 Write-Host "🧪 Dry-run complete: packaging orchestration validated without VSIX creation." -ForegroundColor Yellow
957 return New-PackagingResult -Success $true -Version $packageVersion
958 }
959
960 # Check vsce availability using pure function
961 $vsceAvailability = Test-VsceAvailable
962 if (-not $vsceAvailability.IsAvailable) {
963 return New-PackagingResult -Success $false -ErrorMessage "Neither vsce nor npx found. Please install @vscode/vsce globally or ensure npm is available."
964 }
965
966 # Build vsce command using pure function
967 $vsceCommand = Get-VscePackageCommand -CommandType $vsceAvailability.CommandType -PreRelease:$PreRelease
968
969 # Package extension
970 Write-Host ""
971 Write-Host "📦 Packaging extension..." -ForegroundColor Yellow
972
973 if ($PreRelease) {
974 Write-Host " Mode: Pre-release channel" -ForegroundColor Magenta
975 }
976
977 Write-Host " Using $($vsceAvailability.CommandType)..." -ForegroundColor Gray
978
979 # Execute vsce command using I/O function
980 $useWindowsWrapper = ($IsWindows -or $env:OS -eq 'Windows_NT') -and ($vsceCommand.Executable -eq 'npx')
981 $vsceResult = Invoke-VsceCommand `
982 -Executable $vsceCommand.Executable `
983 -Arguments $vsceCommand.Arguments `
984 -WorkingDirectory $ExtensionDirectory `
985 -UseWindowsWrapper:$useWindowsWrapper
986
987 if (-not $vsceResult.Success) {
988 return New-PackagingResult -Success $false -ErrorMessage "vsce package command failed with exit code $($vsceResult.ExitCode)"
989 }
990
991 # Find the generated vsix file
992 $vsixFile = Get-ChildItem -Path $ExtensionDirectory -Filter "*.vsix" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
993
994 if (-not $vsixFile) {
995 return New-PackagingResult -Success $false -ErrorMessage "No .vsix file found after packaging"
996 }
997
998 Write-Host ""
999 Write-Host "✅ Extension packaged successfully!" -ForegroundColor Green
1000 Write-Host " File: $($vsixFile.Name)" -ForegroundColor Cyan
1001 Write-Host " Size: $([math]::Round($vsixFile.Length / 1KB, 2)) KB" -ForegroundColor Cyan
1002 Write-Host " Version: $packageVersion" -ForegroundColor Cyan
1003
1004 # Output for CI/CD consumption
1005 Set-CIOutput -Name 'version' -Value $packageVersion
1006 Set-CIOutput -Name 'vsix-file' -Value $vsixFile.Name
1007 Set-CIOutput -Name 'pre-release' -Value $PreRelease.IsPresent
1008
1009 Write-Host ""
1010 Write-Host "🎉 Done!" -ForegroundColor Green
1011 Write-Host ""
1012
1013 return New-PackagingResult -Success $true -OutputPath $vsixFile.FullName -Version $packageVersion
1014 }
1015 catch {
1016 return New-PackagingResult -Success $false -ErrorMessage $_.Exception.Message
1017 }
1018 finally {
1019 # Restore canonical package.json from collection template backup
1020 $backupPath = Join-Path $ExtensionDirectory "package.json.bak"
1021 if (Test-Path $backupPath) {
1022 Copy-Item -Path $backupPath -Destination $PackageJsonPath -Force
1023 Remove-Item -Path $backupPath -Force
1024 Write-Host " Restored canonical package.json from backup" -ForegroundColor Green
1025
1026 # Re-read restored package.json for downstream restore steps
1027 $packageJson = Get-Content -Path $PackageJsonPath -Raw | ConvertFrom-Json
1028 }
1029
1030 # Restore collection README if it was swapped
1031 Set-CollectionReadme -ExtensionDirectory $ExtensionDirectory -Operation Restore
1032
1033 # Cleanup copied directories using I/O function
1034 Write-Host ""
1035 Write-Host "🧹 Cleaning up..." -ForegroundColor Yellow
1036 Remove-PackagingArtifacts -ExtensionDirectory $ExtensionDirectory -DirectoryNames $dirsToClean
1037
1038 # Restore original version if it was changed using I/O function
1039 if ($versionWasModified) {
1040 Write-Host ""
1041 Write-Host "🔄 Restoring original package.json version..." -ForegroundColor Yellow
1042 Restore-PackageJsonVersion -PackageJsonPath $PackageJsonPath -PackageJson $packageJson -OriginalVersion $originalVersion
1043 }
1044 }
1045}
1046
1047#endregion Orchestration Functions
1048
1049#region Main Execution
1050if ($MyInvocation.InvocationName -ne '.') {
1051 try {
1052 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
1053 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
1054 $ExtensionDir = Join-Path $RepoRoot "extension"
1055
1056 $result = Invoke-PackageExtension `
1057 -ExtensionDirectory $ExtensionDir `
1058 -RepoRoot $RepoRoot `
1059 -Version $Version `
1060 -DevPatchNumber $DevPatchNumber `
1061 -ChangelogPath $ChangelogPath `
1062 -PreRelease:$PreRelease `
1063 -Collection $Collection `
1064 -DryRun:$DryRun
1065
1066 if (-not $result.Success) {
1067 Write-Error -ErrorAction Continue $result.ErrorMessage
1068 exit 1
1069 }
1070 exit 0
1071 }
1072 catch {
1073 Write-Error -ErrorAction Continue "Package-Extension failed: $($_.Exception.Message)"
1074 Write-CIAnnotation -Message $_.Exception.Message -Level Error
1075 exit 1
1076 }
1077}
1078#endregion Main Execution
1079