microsoft/hve-core

Public

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

CodeCommitsIssuesPull requestsActionsInsightsSecurity
copilot/explain-repo-functionality

Branches

Tags

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

Clone

HTTPS

Download ZIP

scripts/extension/Package-Extension.ps1

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