microsoft/hve-core

Public

mirrored fromhttps://github.com/microsoft/hve-coreAvailable

CodeCommitsIssuesPull requestsActionsInsightsSecurity
hve-core-v3.2.1

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/extension/Package-Extension.ps1

1082lines ยท 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 "../collections/Modules/CollectionHelpers.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@3.7.1') + $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 foreach ($agent in $preparedPkgJson.contributes.chatAgents) {
503 $srcPath = Join-Path $RepoRoot ($agent.path -replace '^\.[\\/]', '')
504 if (-not (Test-Path $srcPath)) {
505 Write-Warning "Skipping missing collection artifact: $srcPath (referenced by contributes.chatAgents in package.json)"
506 continue
507 }
508 $destPath = Join-Path $ExtensionDirectory ($agent.path -replace '^\.[\\/]', '')
509 $destDir = Split-Path $destPath -Parent
510 New-Item -Path $destDir -ItemType Directory -Force | Out-Null
511 Copy-Item -Path $srcPath -Destination $destPath -Force
512 }
513 }
514
515 # Copy filtered prompts
516 if ($preparedPkgJson.contributes.chatPromptFiles) {
517 foreach ($prompt in $preparedPkgJson.contributes.chatPromptFiles) {
518 $srcPath = Join-Path $RepoRoot ($prompt.path -replace '^\.[\\/]', '')
519 if (-not (Test-Path $srcPath)) {
520 Write-Warning "Skipping missing collection artifact: $srcPath (referenced by contributes.chatPromptFiles in package.json)"
521 continue
522 }
523 $destPath = Join-Path $ExtensionDirectory ($prompt.path -replace '^\.[\\/]', '')
524 $destDir = Split-Path $destPath -Parent
525 New-Item -Path $destDir -ItemType Directory -Force | Out-Null
526 Copy-Item -Path $srcPath -Destination $destPath -Force
527 }
528 }
529
530 # Copy filtered instructions
531 if ($preparedPkgJson.contributes.chatInstructions) {
532 foreach ($instr in $preparedPkgJson.contributes.chatInstructions) {
533 $srcPath = Join-Path $RepoRoot ($instr.path -replace '^\.[\\/]', '')
534 if (-not (Test-Path $srcPath)) {
535 Write-Warning "Skipping missing collection artifact: $srcPath (referenced by contributes.chatInstructions in package.json)"
536 continue
537 }
538 $destPath = Join-Path $ExtensionDirectory ($instr.path -replace '^\.[\\/]', '')
539 $destDir = Split-Path $destPath -Parent
540 New-Item -Path $destDir -ItemType Directory -Force | Out-Null
541 Copy-Item -Path $srcPath -Destination $destPath -Force
542 }
543 }
544
545 # Copy filtered skills
546 if ($preparedPkgJson.contributes.chatSkills) {
547 foreach ($skill in $preparedPkgJson.contributes.chatSkills) {
548 $srcPath = Join-Path $RepoRoot ($skill.path -replace '^\.[\\/]', '')
549 if (-not (Test-Path $srcPath)) {
550 Write-Warning "Skipping missing collection artifact: $srcPath (referenced by contributes.chatSkills in package.json)"
551 continue
552 }
553 # Copy the full skill directory, not just SKILL.md
554 $srcDir = Split-Path $srcPath -Parent
555 $destPath = Join-Path $ExtensionDirectory ($skill.path -replace '^\.[\\/]', '')
556 $destDir = Split-Path $destPath -Parent
557 $destParent = Split-Path $destDir -Parent
558 New-Item -Path $destParent -ItemType Directory -Force | Out-Null
559 Copy-Item -Path $srcDir -Destination $destParent -Recurse -Force
560
561 # Remove co-located test directories from packaged skills
562 Get-ChildItem -Path $destDir -Directory -Filter 'tests' -Recurse -ErrorAction SilentlyContinue |
563 Remove-Item -Recurse -Force
564 }
565 }
566}
567
568function Set-CollectionReadme {
569 <#
570 .SYNOPSIS
571 Swaps or restores the collection-specific README for extension packaging.
572 .DESCRIPTION
573 In swap mode, backs up the original README.md and copies the collection
574 README in its place. In restore mode, copies the backup back and removes it.
575 .PARAMETER ExtensionDirectory
576 Path to the extension directory.
577 .PARAMETER CollectionReadmePath
578 Path to the collection-specific README file. Required for Swap operation.
579 .PARAMETER Operation
580 Either 'Swap' to replace README.md with collection content, or 'Restore'
581 to revert README.md from backup.
582 #>
583 [CmdletBinding()]
584 param(
585 [Parameter(Mandatory = $true)]
586 [string]$ExtensionDirectory,
587
588 [Parameter(Mandatory = $false)]
589 [string]$CollectionReadmePath = "",
590
591 [Parameter(Mandatory = $true)]
592 [ValidateSet('Swap', 'Restore')]
593 [string]$Operation
594 )
595
596 $readmePath = Join-Path $ExtensionDirectory "README.md"
597 $backupPath = Join-Path $ExtensionDirectory "README.md.bak"
598
599 if ($Operation -eq 'Swap') {
600 if (-not $CollectionReadmePath -or $CollectionReadmePath -eq "") {
601 Write-Warning "No collection README path provided for swap operation"
602 return
603 }
604 Copy-Item -Path $readmePath -Destination $backupPath -Force
605 Copy-Item -Path $CollectionReadmePath -Destination $readmePath -Force
606 Write-Host " Swapped README.md with $(Split-Path $CollectionReadmePath -Leaf)" -ForegroundColor Green
607 }
608 elseif ($Operation -eq 'Restore') {
609 if (Test-Path $backupPath) {
610 Copy-Item -Path $backupPath -Destination $readmePath -Force
611 Remove-Item -Path $backupPath -Force
612 Write-Host " Restored original README.md" -ForegroundColor Green
613 }
614 }
615}
616
617function Invoke-VsceCommand {
618 <#
619 .SYNOPSIS
620 Executes vsce package command with platform-appropriate wrapper.
621 .DESCRIPTION
622 Abstracts platform-specific execution of vsce/npx commands. On Windows with npx,
623 uses cmd /c to avoid PowerShell misinterpreting @ in @vscode/vsce as splatting.
624 The UseWindowsWrapper parameter enables deterministic platform behavior in tests.
625 .PARAMETER Executable
626 The executable to run ('vsce' or 'npx').
627 .PARAMETER Arguments
628 Array of arguments to pass to the executable.
629 .PARAMETER WorkingDirectory
630 Directory to execute the command in.
631 .PARAMETER UseWindowsWrapper
632 When true and Executable is 'npx', uses cmd /c wrapper for Windows compatibility.
633 .OUTPUTS
634 Hashtable with Success boolean and ExitCode integer.
635 #>
636 [CmdletBinding()]
637 [OutputType([hashtable])]
638 param(
639 [Parameter(Mandatory = $true)]
640 [string]$Executable,
641
642 [Parameter(Mandatory = $true)]
643 [string[]]$Arguments,
644
645 [Parameter(Mandatory = $true)]
646 [string]$WorkingDirectory,
647
648 [Parameter(Mandatory = $false)]
649 [switch]$UseWindowsWrapper
650 )
651
652 Push-Location $WorkingDirectory
653 try {
654 $global:LASTEXITCODE = 0
655
656 if ($UseWindowsWrapper -and $Executable -eq 'npx') {
657 $cmdArgs = @('/c', 'npx') + $Arguments
658 & cmd @cmdArgs
659 } else {
660 & $Executable @Arguments
661 }
662
663 return @{
664 Success = ($LASTEXITCODE -eq 0)
665 ExitCode = $LASTEXITCODE
666 }
667 }
668 finally {
669 Pop-Location
670 }
671}
672
673function Remove-PackagingArtifacts {
674 <#
675 .SYNOPSIS
676 Removes temporary directories created during packaging.
677 .DESCRIPTION
678 Cleans up directories copied to the extension folder during the packaging process.
679 Silently skips directories that do not exist.
680 .PARAMETER ExtensionDirectory
681 Absolute path to the extension directory.
682 .PARAMETER DirectoryNames
683 Array of directory names to remove. Defaults to .github, docs, scripts.
684 #>
685 [CmdletBinding()]
686 param(
687 [Parameter(Mandatory = $true)]
688 [string]$ExtensionDirectory,
689
690 [Parameter(Mandatory = $false)]
691 [string[]]$DirectoryNames = @(".github", "docs", "scripts")
692 )
693
694 foreach ($dir in $DirectoryNames) {
695 $dirPath = Join-Path $ExtensionDirectory $dir
696 if (Test-Path $dirPath) {
697 Remove-Item -Path $dirPath -Recurse -Force
698 Write-Host " Removed $dir" -ForegroundColor Gray
699 }
700 }
701}
702
703function Restore-PackageJsonVersion {
704 <#
705 .SYNOPSIS
706 Restores original version in package.json after packaging.
707 .DESCRIPTION
708 Writes the original version back to package.json if it was temporarily modified
709 during packaging. Safely handles null inputs by returning early.
710 .PARAMETER PackageJsonPath
711 Absolute path to the package.json file.
712 .PARAMETER PackageJson
713 The parsed package.json object to modify.
714 .PARAMETER OriginalVersion
715 The original version string to restore.
716 #>
717 [CmdletBinding()]
718 param(
719 [Parameter(Mandatory = $false)]
720 [AllowNull()]
721 [string]$PackageJsonPath,
722
723 [Parameter(Mandatory = $false)]
724 [AllowNull()]
725 [PSObject]$PackageJson,
726
727 [Parameter(Mandatory = $false)]
728 [AllowNull()]
729 [string]$OriginalVersion
730 )
731
732 # Handle null coercion: PowerShell converts $null to empty string for [string] params
733 if ([string]::IsNullOrEmpty($OriginalVersion) -or $null -eq $PackageJson -or [string]::IsNullOrEmpty($PackageJsonPath)) {
734 return
735 }
736
737 try {
738 $PackageJson.version = $OriginalVersion
739 $PackageJson | ConvertTo-Json -Depth 10 | Set-Content -Path $PackageJsonPath -Encoding UTF8NoBOM
740 Write-Host " Version restored to: $OriginalVersion" -ForegroundColor Green
741 }
742 catch {
743 Write-Warning "Failed to restore original package.json version to '$OriginalVersion': $($_.Exception.Message)"
744 }
745}
746
747#endregion I/O Functions
748
749#region Orchestration Functions
750
751function Invoke-PackageExtension {
752 <#
753 .SYNOPSIS
754 Orchestrates VS Code extension packaging with full error handling.
755 .DESCRIPTION
756 Executes the complete packaging workflow: validates paths, resolves version,
757 prepares directories, invokes vsce, and handles cleanup.
758 .PARAMETER ExtensionDirectory
759 Absolute path to the extension directory containing package.json.
760 .PARAMETER RepoRoot
761 Absolute path to the repository root directory.
762 .PARAMETER Version
763 Optional explicit version string (e.g., "1.2.3").
764 .PARAMETER DevPatchNumber
765 Optional dev build patch number for pre-release versions.
766 .PARAMETER ChangelogPath
767 Optional path to changelog file to include in package.
768 .PARAMETER PreRelease
769 Switch to mark the package as a pre-release version.
770 .PARAMETER Collection
771 Optional path to a collection manifest file (YAML or JSON). When specified, only
772 collection-filtered artifacts are copied and the output filename uses the
773 collection ID.
774 .PARAMETER DryRun
775 When specified, validates packaging orchestration without invoking vsce.
776 .OUTPUTS
777 Hashtable with Success, OutputPath, Version, and ErrorMessage properties.
778 #>
779 [CmdletBinding()]
780 [OutputType([hashtable])]
781 param(
782 [Parameter(Mandatory = $true)]
783 [ValidateNotNullOrEmpty()]
784 [string]$ExtensionDirectory,
785
786 [Parameter(Mandatory = $true)]
787 [ValidateNotNullOrEmpty()]
788 [string]$RepoRoot,
789
790 [Parameter(Mandatory = $false)]
791 [string]$Version = "",
792
793 [Parameter(Mandatory = $false)]
794 [string]$DevPatchNumber = "",
795
796 [Parameter(Mandatory = $false)]
797 [string]$ChangelogPath = "",
798
799 [Parameter(Mandatory = $false)]
800 [switch]$PreRelease,
801
802 [Parameter(Mandatory = $false)]
803 [string]$Collection = "",
804
805 [Parameter(Mandatory = $false)]
806 [switch]$DryRun
807 )
808
809 $dirsToClean = @(".github", "docs", "scripts")
810 $originalVersion = $null
811 $packageJson = $null
812 $PackageJsonPath = $null
813 $packageVersion = $null
814 $versionWasModified = $false
815
816 try {
817 # Validate all inputs using pure function
818 $inputValidation = Test-PackagingInputsValid -ExtensionDirectory $ExtensionDirectory -RepoRoot $RepoRoot
819 if (-not $inputValidation.IsValid) {
820 return New-PackagingResult -Success $false -ErrorMessage ($inputValidation.Errors -join '; ')
821 }
822
823 $PackageJsonPath = $inputValidation.PackageJsonPath
824
825 Write-Host "๐Ÿ“ฆ HVE Core Extension Packager" -ForegroundColor Cyan
826 Write-Host "==============================" -ForegroundColor Cyan
827 Write-Host ""
828
829 # Read and validate package.json
830 Write-Host "๐Ÿ“– Reading package.json..." -ForegroundColor Yellow
831 try {
832 $packageJson = Get-Content -Path $PackageJsonPath -Raw | ConvertFrom-Json
833 }
834 catch {
835 return New-PackagingResult -Success $false -ErrorMessage "Failed to parse package.json: $($_.Exception.Message)"
836 }
837
838 $manifestValidation = Test-ExtensionManifestValid -ManifestContent $packageJson
839 if (-not $manifestValidation.IsValid) {
840 return New-PackagingResult -Success $false -ErrorMessage "Invalid package.json: $($manifestValidation.Errors -join '; ')"
841 }
842
843 # Resolve version using pure function
844 $versionResult = Get-ResolvedPackageVersion `
845 -SpecifiedVersion $Version `
846 -ManifestVersion $packageJson.version `
847 -DevPatchNumber $DevPatchNumber
848
849 if (-not $versionResult.IsValid) {
850 return New-PackagingResult -Success $false -ErrorMessage $versionResult.ErrorMessage
851 }
852
853 $packageVersion = $versionResult.PackageVersion
854 Write-Host " Using version: $packageVersion" -ForegroundColor Green
855
856 # Handle temporary version update for dev builds
857 $originalVersion = $packageJson.version
858
859 if ($packageVersion -ne $originalVersion) {
860 Write-Host ""
861 Write-Host "๐Ÿ“ Temporarily updating package.json version..." -ForegroundColor Yellow
862 $packageJson.version = $packageVersion
863 $packageJson | ConvertTo-Json -Depth 10 | Set-Content -Path $PackageJsonPath -Encoding UTF8NoBOM
864 Write-Host " Version: $originalVersion -> $packageVersion" -ForegroundColor Green
865 $versionWasModified = $true
866 }
867
868 # Handle changelog if provided
869 if ($ChangelogPath -and $ChangelogPath -ne "") {
870 Write-Host ""
871 Write-Host "๐Ÿ“‹ Processing changelog..." -ForegroundColor Yellow
872
873 if (Test-Path $ChangelogPath) {
874 $changelogDest = Join-Path $ExtensionDirectory "CHANGELOG.md"
875 Copy-Item -Path $ChangelogPath -Destination $changelogDest -Force
876 Write-Host " Copied changelog to extension directory" -ForegroundColor Green
877 }
878 else {
879 Write-Warning "Changelog file not found: $ChangelogPath"
880 }
881 }
882
883 # Prepare extension directory
884 Write-Host ""
885 Write-Host "๐Ÿ—‚๏ธ Preparing extension directory..." -ForegroundColor Yellow
886
887 # Clean any existing copied directories
888 foreach ($dir in $dirsToClean) {
889 $dirPath = Join-Path $ExtensionDirectory $dir
890 if (Test-Path $dirPath) {
891 Remove-Item -Path $dirPath -Recurse -Force
892 Write-Host " Cleaned existing $dir directory" -ForegroundColor Gray
893 }
894 }
895
896 # Get and execute copy specifications
897 $copySpecs = Get-PackagingDirectorySpec -RepoRoot $RepoRoot -ExtensionDirectory $ExtensionDirectory
898
899 if ($Collection -and $Collection -ne "") {
900 # Collection mode: copy only filtered artifacts for .github content
901 Write-Host " Using collection-filtered artifact copy..." -ForegroundColor Gray
902
903 # Copy non-.github specs normally
904 foreach ($spec in $copySpecs) {
905 if ($spec.Source -like "*/.github*" -or $spec.Source -like "*\.github*") {
906 continue
907 }
908 $specName = Split-Path $spec.Source -Leaf
909 Write-Host " Copying $specName..." -ForegroundColor Gray
910
911 if ($spec.IsFile) {
912 $parentDir = Split-Path $spec.Destination -Parent
913 New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
914 Copy-Item -Path $spec.Source -Destination $spec.Destination -Force
915 } else {
916 $parentDir = Split-Path $spec.Destination -Parent
917 if (-not (Test-Path $parentDir)) {
918 New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
919 }
920 Copy-Item -Path $spec.Source -Destination $spec.Destination -Recurse -Force
921 }
922 }
923
924 # Copy collection-specific artifacts
925 Copy-CollectionArtifacts -RepoRoot $RepoRoot -ExtensionDirectory $ExtensionDirectory -PrepareResult @{}
926 } else {
927 # Full mode: copy everything as before
928 foreach ($spec in $copySpecs) {
929 $specName = Split-Path $spec.Source -Leaf
930 Write-Host " Copying $specName..." -ForegroundColor Gray
931
932 if ($spec.IsFile) {
933 $parentDir = Split-Path $spec.Destination -Parent
934 New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
935 Copy-Item -Path $spec.Source -Destination $spec.Destination -Force
936 } else {
937 $parentDir = Split-Path $spec.Destination -Parent
938 if (-not (Test-Path $parentDir)) {
939 New-Item -Path $parentDir -ItemType Directory -Force | Out-Null
940 }
941 Copy-Item -Path $spec.Source -Destination $spec.Destination -Recurse -Force
942 }
943 }
944 }
945
946 Write-Host " โœ… Extension directory prepared" -ForegroundColor Green
947
948 # Swap collection README if collection specifies one
949 if ($Collection -and $Collection -ne "") {
950 $collectionReadmePath = Get-CollectionReadmePath -CollectionPath $Collection -ExtensionDirectory $ExtensionDirectory
951 if ($collectionReadmePath) {
952 Write-Host ""
953 Write-Host "๐Ÿ“„ Applying collection README..." -ForegroundColor Yellow
954 Set-CollectionReadme -ExtensionDirectory $ExtensionDirectory -CollectionReadmePath $collectionReadmePath -Operation Swap
955 }
956 }
957
958 if ($DryRun) {
959 Write-Host ""
960 Write-Host "๐Ÿงช Dry-run complete: packaging orchestration validated without VSIX creation." -ForegroundColor Yellow
961 return New-PackagingResult -Success $true -Version $packageVersion
962 }
963
964 # Check vsce availability using pure function
965 $vsceAvailability = Test-VsceAvailable
966 if (-not $vsceAvailability.IsAvailable) {
967 return New-PackagingResult -Success $false -ErrorMessage "Neither vsce nor npx found. Please install @vscode/vsce globally or ensure npm is available."
968 }
969
970 # Build vsce command using pure function
971 $vsceCommand = Get-VscePackageCommand -CommandType $vsceAvailability.CommandType -PreRelease:$PreRelease
972
973 # Package extension
974 Write-Host ""
975 Write-Host "๐Ÿ“ฆ Packaging extension..." -ForegroundColor Yellow
976
977 if ($PreRelease) {
978 Write-Host " Mode: Pre-release channel" -ForegroundColor Magenta
979 }
980
981 Write-Host " Using $($vsceAvailability.CommandType)..." -ForegroundColor Gray
982
983 # Execute vsce command using I/O function
984 $useWindowsWrapper = ($IsWindows -or $env:OS -eq 'Windows_NT') -and ($vsceCommand.Executable -eq 'npx')
985 $vsceResult = Invoke-VsceCommand `
986 -Executable $vsceCommand.Executable `
987 -Arguments $vsceCommand.Arguments `
988 -WorkingDirectory $ExtensionDirectory `
989 -UseWindowsWrapper:$useWindowsWrapper
990
991 if (-not $vsceResult.Success) {
992 return New-PackagingResult -Success $false -ErrorMessage "vsce package command failed with exit code $($vsceResult.ExitCode)"
993 }
994
995 # Find the generated vsix file
996 $vsixFile = Get-ChildItem -Path $ExtensionDirectory -Filter "*.vsix" | Sort-Object LastWriteTime -Descending | Select-Object -First 1
997
998 if (-not $vsixFile) {
999 return New-PackagingResult -Success $false -ErrorMessage "No .vsix file found after packaging"
1000 }
1001
1002 Write-Host ""
1003 Write-Host "โœ… Extension packaged successfully!" -ForegroundColor Green
1004 Write-Host " File: $($vsixFile.Name)" -ForegroundColor Cyan
1005 Write-Host " Size: $([math]::Round($vsixFile.Length / 1KB, 2)) KB" -ForegroundColor Cyan
1006 Write-Host " Version: $packageVersion" -ForegroundColor Cyan
1007
1008 # Output for CI/CD consumption
1009 Set-CIOutput -Name 'version' -Value $packageVersion
1010 Set-CIOutput -Name 'vsix-file' -Value $vsixFile.Name
1011 Set-CIOutput -Name 'pre-release' -Value $PreRelease.IsPresent
1012
1013 Write-Host ""
1014 Write-Host "๐ŸŽ‰ Done!" -ForegroundColor Green
1015 Write-Host ""
1016
1017 return New-PackagingResult -Success $true -OutputPath $vsixFile.FullName -Version $packageVersion
1018 }
1019 catch {
1020 return New-PackagingResult -Success $false -ErrorMessage $_.Exception.Message
1021 }
1022 finally {
1023 # Restore canonical package.json from collection template backup
1024 $backupPath = Join-Path $ExtensionDirectory "package.json.bak"
1025 if (Test-Path $backupPath) {
1026 Copy-Item -Path $backupPath -Destination $PackageJsonPath -Force
1027 Remove-Item -Path $backupPath -Force
1028 Write-Host " Restored canonical package.json from backup" -ForegroundColor Green
1029
1030 # Re-read restored package.json for downstream restore steps
1031 $packageJson = Get-Content -Path $PackageJsonPath -Raw | ConvertFrom-Json
1032 }
1033
1034 # Restore collection README if it was swapped
1035 Set-CollectionReadme -ExtensionDirectory $ExtensionDirectory -Operation Restore
1036
1037 # Cleanup copied directories using I/O function
1038 Write-Host ""
1039 Write-Host "๐Ÿงน Cleaning up..." -ForegroundColor Yellow
1040 Remove-PackagingArtifacts -ExtensionDirectory $ExtensionDirectory -DirectoryNames $dirsToClean
1041
1042 # Restore original version if it was changed using I/O function
1043 if ($versionWasModified) {
1044 Write-Host ""
1045 Write-Host "๐Ÿ”„ Restoring original package.json version..." -ForegroundColor Yellow
1046 Restore-PackageJsonVersion -PackageJsonPath $PackageJsonPath -PackageJson $packageJson -OriginalVersion $originalVersion
1047 }
1048 }
1049}
1050
1051#endregion Orchestration Functions
1052
1053#region Main Execution
1054if ($MyInvocation.InvocationName -ne '.') {
1055 try {
1056 $ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path
1057 $RepoRoot = (Get-Item "$ScriptDir/../..").FullName
1058 $ExtensionDir = Join-Path $RepoRoot "extension"
1059
1060 $result = Invoke-PackageExtension `
1061 -ExtensionDirectory $ExtensionDir `
1062 -RepoRoot $RepoRoot `
1063 -Version $Version `
1064 -DevPatchNumber $DevPatchNumber `
1065 -ChangelogPath $ChangelogPath `
1066 -PreRelease:$PreRelease `
1067 -Collection $Collection `
1068 -DryRun:$DryRun
1069
1070 if (-not $result.Success) {
1071 Write-Error -ErrorAction Continue $result.ErrorMessage
1072 exit 1
1073 }
1074 exit 0
1075 }
1076 catch {
1077 Write-Error -ErrorAction Continue "Package-Extension failed: $($_.Exception.Message)"
1078 Write-CIAnnotation -Message $_.Exception.Message -Level Error
1079 exit 1
1080 }
1081}
1082#endregion Main Execution
1083