< Summary - Kestrun — Combined Coverage

Line coverage
65%
Covered lines: 3732
Uncovered lines: 1941
Coverable lines: 5673
Total lines: 14266
Line coverage: 65.7%
Branch coverage
58%
Covered branches: 1859
Total branches: 3151
Branch coverage: 58.9%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Coverage history

Coverage history 0 25 50 75 100 03/26/2026 - 03:54:59 Line coverage: 63.4% (2980/4693) Branch coverage: 57.1% (1532/2681) Total lines: 11769 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d04/03/2026 - 00:32:29 Line coverage: 65.6% (3713/5658) Branch coverage: 58.9% (1853/3145) Total lines: 14247 Tag: Kestrun/Kestrun@8b1d7be6fa3c9196a4dc338b779df2907d8580a404/19/2026 - 15:52:57 Line coverage: 65.7% (3732/5673) Branch coverage: 58.9% (1859/3151) Total lines: 14266 Tag: Kestrun/Kestrun@765a8f13c573c01494250a29d6392b6037f087c9 03/26/2026 - 03:54:59 Line coverage: 63.4% (2980/4693) Branch coverage: 57.1% (1532/2681) Total lines: 11769 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d04/03/2026 - 00:32:29 Line coverage: 65.6% (3713/5658) Branch coverage: 58.9% (1853/3145) Total lines: 14247 Tag: Kestrun/Kestrun@8b1d7be6fa3c9196a4dc338b779df2907d8580a404/19/2026 - 15:52:57 Line coverage: 65.7% (3732/5673) Branch coverage: 58.9% (1859/3151) Total lines: 14266 Tag: Kestrun/Kestrun@765a8f13c573c01494250a29d6392b6037f087c9

Coverage delta

Coverage delta 3 -3

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
File 1: Main(...)50%10869.23%
File 1: TryHandleInternalServiceRegisterMode(...)50%10653.85%
File 1: TryDispatchParsedCommand(...)8.33%981215.79%
File 1: HandleModuleCommand(...)0%2040%
File 1: RequiresWindowsElevationForGlobalModuleOperation(...)0%7280%
File 1: HandleServiceRemoveCommand(...)33.33%14640%
File 1: HandleServiceStartCommand(...)12.5%228166.06%
File 1: HandleServiceStopCommand(...)12.5%228166.06%
File 1: ExecuteRunMode(...)16.67%20626.67%
File 1: IsWindowsAdministrator()100%210%
File 1: TryPreflightWindowsServiceInstall(...)0%7280%
File 1: TryPreflightWindowsServiceRemove(...)0%2040%
File 1: TryPreflightWindowsServiceControl(...)0%2040%
File 1: WindowsServiceExists(...)0%4260%
File 1: RelaunchElevatedOnWindows(...)0%4260%
File 1: TryResolveElevationExecutablePath(...)0%4260%
File 1: WriteElevationWrapperScript(...)100%210%
File 1: StartElevatedProcess(...)0%4260%
File 1: RelayElevatedOutput(...)0%2040%
File 1: WriteElevationCanceledMessage(...)0%620%
File 1: WriteElevationFailureMessage(...)0%620%
File 1: TryDeleteFileQuietly(...)75%5462.5%
File 1: BuildElevatedRelaunchArguments(...)66.67%8662.5%
File 1: IsDotnetHostExecutable(...)100%11100%
File 1: BuildRunnerArgumentsForService(...)83.33%6684.62%
File 1: BuildDaemonHostArgumentsForService(...)100%22100%
File 1: InstallWindowsService(...)0%2040%
File 1: RegisterWindowsService(...)0%620%
File 1: CreateWindowsServiceRegistration(...)0%7280%
File 1: NormalizeWindowsServiceAccountName(...)82.22%4545100%
File 1: IsWindowsBuiltinServiceAccount(...)100%44100%
File 1: UsesDedicatedServiceHostExecutable(...)100%22100%
File 1: BuildWindowsServiceRegisterArguments(...)0%7280%
File 1: BuildDedicatedServiceHostArguments(...)75%4488.89%
File 1: RemoveWindowsService(...)0%7280%
File 1: WaitForWindowsServiceToStopOrDisappear(...)0%110100%
File 1: WriteServiceOperationLog(...)100%2280%
File 1: WriteServiceOperationResult(...)50%22100%
File 1: ResolveServiceOperationLogPath(...)60%1010100%
File 1: TryGetWindowsServiceLogPath(...)0%7280%
File 1: TryResolveServiceRuntimeExecutableFromModule(...)38.89%591850%
File 1: EnumerateServiceRuntimeExecutableCandidates()83.33%6694.44%
File 1: TryResolveDedicatedServiceHostExecutableFromToolDistribution(...)62.5%9880%
File 1: TryResolvePowerShellModulesPayloadFromToolDistribution(...)66.67%6677.78%
File 1: EnumerateDedicatedServiceHostCandidates()90%101091.67%
File 1: EnumeratePowerShellModulesPayloadCandidates()75%4493.75%
File 1: EnumerateDirectoryAndParents()100%44100%
File 1: TryResolveServiceScriptSource(...)75%9871.43%
File 1: TryClassifyServiceContentRoot(...)100%44100%
File 1: TryResolveServiceScriptFromContentRoot(...)100%44100%
File 1: get_HasArchiveChecksum()100%11100%
File 1: get_HasBearerToken()100%11100%
File 1: get_IgnoreCertificate()100%11100%
File 1: get_HasHeaders()100%11100%
File 1: ResolveRequestedServiceScriptPath(...)75%44100%
File 1: GetServiceContentRootOptionFlags(...)100%11100%
File 1: TryResolveServiceScriptWithoutContentRoot(...)50%4470%
File 1: TryResolveServiceScriptFromHttpContentRoot(...)60%131067.74%
File 1: TryResolveServiceScriptFromDirectoryContentRoot(...)75%8888.89%
File 1: TryResolveServiceScriptFromArchiveContentRoot(...)80%121073.08%
File 1: TryResolveServiceScriptFromExtractedArchiveContentRoot(...)50%9877.78%
File 1: TryValidateOptionsForMissingContentRoot(...)50%20842.86%
File 1: TryValidateUrlOnlyContentRootOptions(...)83.33%6681.82%
File 1: TryValidateDirectoryContentRootOptions(...)50%3250%
File 1: TryValidateLocalArchiveContentRootOptions(...)100%11100%
File 1: TryValidateHttpContentRootScriptPath(...)50%5460%
File 1: TryResolveServiceInstallDescriptor(...)75%121289.29%
File 1: TryReadNormalizedServiceDescriptorText(...)50%2257.14%
File 1: TryResolveServiceDescriptorCoreFields(...)75%4470%
File 1: TryResolveServiceDescriptorEntryPointAndVersion(...)70%121075%
File 1: TryResolveOptionalServiceDescriptorVersion(...)75%4483.33%
File 1: NormalizeServiceDescriptorText(...)100%11100%
File 1: TryGetServiceDescriptorStringValue(...)80%101087.5%
File 1: TryGetServiceDescriptorStringArrayValue(...)62.5%8892.31%
File 1: TryResolveServiceDescriptorScriptPath(...)75%4470%
File 1: ApplyDescriptorMetadata(...)100%11100%
File 1: TryDownloadAndExtractHttpContentRoot(...)75%44100%
File 1: TryResolveScriptFromResolvedContentRoot(...)50%5466.67%
File 1: CreateEmptyResolvedServiceScriptSource()100%11100%
File 1: CreateServiceContentRootExtractionDirectory(...)20%101087.5%
File 1: TryParseServiceContentRootHttpUri(...)100%88100%
File 1: TryDownloadServiceContentRootArchive(...)57.14%601438.24%
File 1: TryApplyServiceContentRootCustomHeaders(...)83.33%221876%
File 1: TryWriteDownloadedContentRootArchive(...)50%9879.31%
File 1: GetSafeServiceContentRootArchiveFileName(...)56.25%201675%
File 1: TryResolveDownloadedServiceContentRootArchiveFileName(...)75%8881.82%
File 1: TryGetServiceContentRootArchiveExtensionFromMediaType(...)91.67%1212100%
File 1: TryDetectServiceContentRootArchiveExtensionFromSignature(...)81.58%403889.66%
File 1: BuildServiceContentRootArchiveFileName(...)100%66100%
File 1: TryResolveServiceContentRootArchiveFileName(...)81.25%1616100%
File 1: IsSupportedServiceContentRootArchive(...)100%88100%
File 1: TryValidateServiceContentRootArchiveChecksum(...)90%101095.24%
File 1: TryCreateChecksumAlgorithm(...)67.5%994066.67%
File 1: TryExtractServiceContentRootArchive(...)80%141064.71%
File 1: TryExtractZipArchiveSafely(...)66.67%131278.95%
File 1: TryExtractTarArchiveSafely(...)77.78%241873.91%
File 1: IsPathWithinDirectory(...)75%88100%
File 1: TryResolveServiceDeploymentRoot(...)25%521234.62%
File 1: TryEnsureDirectoryWritable(...)100%11100%
File 1: GetServiceDeploymentRootCandidates()40%341038.1%
File 1: TryRemoveServiceBundle(...)58.33%221259.09%
File 1: IsExpectedUnixProtectedRootCleanupFailure(...)0%110100%
File 1: IsProtectedUnixServiceRoot(...)0%2040%
File 1: TryDeleteDirectoryWithRetry(...)50%17847.37%
File 1: GetServiceDeploymentDirectoryName(...)58.33%141277.78%
File 1: TryGetServiceRuntimeRid(...)35.71%171473.91%
File 1: TryEnsureServiceRuntimeExecutablePermissions(...)100%1171.43%
File 1: NormalizeServiceLogPath(...)100%66100%
File 1: EscapeXml(...)100%11100%
File 1: EscapeSystemdToken(...)50%2290%
File 1: BuildWindowsCommandLine(...)100%11100%
File 1: EscapeWindowsCommandLineArgument(...)68.75%321660%
File 1: GetLinuxUnitName(...)91.67%1212100%
File 1: IsLikelyRunningAsRootOnLinux()50%22100%
File 1: IsLikelyRunningAsRootOnUnix()0%2040%
File 1: WriteLinuxUserSystemdFailureHint(...)50%161478.57%
File 1: RunProcess(...)87.5%8895%
File 1: get_ExitCode()100%11100%
File 1: ResolveRunModuleManifestPath(...)0%7280%
File 1: BuildDedicatedServiceHostRunArguments(...)0%2040%
File 1: ShouldDiscoverPowerShellHomeForManifest(...)0%7280%
File 1: ResolveCurrentProcessPathOrFallback(...)0%2040%
File 1: ParseGlobalOptions(...)100%1414100%
File 1: IsNoCheckOption(...)100%22100%
File 1: TryValidateInstallAction(...)50%2280%
File 1: TryValidateUpdateAction(...)75%4487.5%
File 1: TryReadPackageVersion(...)66.67%6688.89%
File 1: TryGetPackagePayloadPath(...)65%332068.42%
File 1: CopyDirectoryContents(...)100%11100%
File 1: CopyDirectoryContents(...)66.67%1212100%
File 1: ShouldExcludeCopyFile(...)100%22100%
File 1: BuildCopyExclusionRegexes(...)90%101091.67%
File 1: NormalizeCopyPath(...)50%5466.67%
File 1: TryRemoveInstalledModule(...)62.5%252487.18%
File 1: CopyStreamWithProgress(...)62.5%8890.91%
File 1: FormatByteProgressDetail(...)50%22100%
File 1: FormatFileProgressDetail(...)50%22100%
File 1: FormatServiceBundleStepProgressDetail(...)40%151063.64%
File 1: FormatByteSize(...)83.33%66100%
File 1: WarnIfNewerGalleryVersionExists(...)0%110100%
File 1: WriteWarningToLogOrConsole(...)75%5461.54%
File 1: TryGetLatestInstalledModuleVersionText(...)100%210%
File 1: TryGetLatestInstalledModuleVersionTextFromModuleRoot(...)50%2283.33%
File 1: TryGetInstalledModuleVersionText(...)50%5466.67%
File 1: TryReadModuleSemanticVersionFromManifest(...)100%101088.24%
File 1: TryGetLatestGalleryVersionString(...)100%210%
File 1: TryGetLatestGalleryVersionStringFromClient(...)83.33%6688.89%
File 1: TryGetGalleryModuleVersions(...)100%210%
File 1: TryGetGalleryModuleVersionsFromClient(...)66.67%7673.68%
File 1: TryParseGalleryModuleVersions(...)50%2288.89%
File 1: CreateGalleryHttpClient()100%11100%
File 1: CreateServiceContentRootHttpClient()100%11100%
File 1: TryParseVersionValue(...)87.5%8890.91%
File 1: TryNormalizeModuleVersion(...)50%2280%
File 1: TryParseModuleScope(...)66.67%7670%
File 1: GetScopeToken(...)100%22100%
File 1: CompareModuleVersionValues(...)62.5%201675%
File 1: HasPrereleaseSuffix(...)50%22100%
File 1: TryReadModuleVersionFromManifest(...)100%2280%
File 1: GetInstalledModuleRecords(...)88.89%181890%
File 1: GetPowerShellModulePath(...)0%620%
File 1: GetGlobalPowerShellModulePath()0%2040%
File 1: GetDefaultPowerShellModulePath()25%6450%
File 1: WriteModuleNotFoundMessage(...)100%44100%
File 1: TryParseArguments(...)50%11650%
File 1: TryParseLeadingKestrunOptions(...)94.44%181893.75%
File 1: TryConsumeLeadingOptionValue(...)100%22100%
File 1: TryParseCommandFromToken(...)100%66100%
File 1: TryHandleMetaCommands(...)100%2828100%
File 1: IsHelpToken(...)50%66100%
File 1: TryGetHelpTopic(...)100%1010100%
File 1: FilterGlobalOptions(...)64.29%171475%
File 1: PrintUsage()100%11100%
File 1: PrintHelpForTopic(...)90%1010100%
File 1: PrintVersion()100%11100%
File 1: PrintInfo()50%44100%
File 1: GetProductVersion()37.5%88100%
File 1: LocateModuleManifest(...)38.89%461855.56%
File 1: EnumerateExecutableManifestCandidates()0%620%
File 1: GetExecutableDirectory()50%4483.33%
File 1: EnumerateModulePathManifestCandidates()0%156120%
File 2: ManageModuleCommand(...)0%3050%
File 2: PrintModuleInfo(...)0%156120%
File 2: ManageModuleFromGallery(...)0%4260%
File 2: HandleModuleRemoveAction(...)0%2040%
File 2: TryValidateModuleInstallAction(...)0%620%
File 2: TryExecuteModuleInstallOrUpdate(...)100%210%
File 2: WriteModuleActionFailure(...)0%620%
File 2: WriteModuleInstallOrUpdateSuccess(...)0%4260%
File 2: TryInstallOrUpdateModuleFromGallery(...)0%7280%
File 2: TryDownloadModulePackage(...)0%4260%
File 2: NormalizeRequestedModuleVersion(...)100%22100%
File 2: BuildGalleryPackageUrl(...)100%22100%
File 2: TryHandlePackageDownloadResponseStatus(...)42.86%241463.16%
File 2: TryDownloadPackagePayload(...)90%1010100%
File 2: TryResolveDownloadedPackageVersion(...)50%12645.45%
File 2: TryExtractModulePackage(...)50%171266.67%
File 2: TryCollectModulePayloadEntries(...)100%66100%
File 2: ShouldStripModulePrefix(...)50%22100%
File 2: TryExtractPayloadEntriesToStaging(...)66.67%181896.77%
File 2: NormalizePayloadRelativePath(...)50%4483.33%
File 2: TryResolveSafeStagingDestination(...)100%22100%
File 2: TryResolveExtractedManifestPath(...)50%4471.43%
File 2: TryInstallExtractedModule(...)100%44100%
File 2: TryDeleteDirectoryQuietly(...)100%2266.67%
File 2: TryParseModuleArguments(...)75%4485.71%
File 2: get_ActionToken()100%11100%
File 2: get_Mode()100%11100%
File 2: get_ModuleVersion()100%11100%
File 2: get_ModuleScope()100%11100%
File 2: get_ModuleScopeSet()100%11100%
File 2: get_ModuleForce()100%11100%
File 2: get_ModuleForceSet()100%11100%
File 2: CreateDefaultModuleParsedCommand()100%11100%
File 2: TryResolveModuleAction(...)75%131278.26%
File 2: TryParseModuleOptionLoop(...)100%44100%
File 2: TryConsumeModuleOption(...)77.27%442264.29%
File 2: TryConsumeModuleScopeOption(...)66.67%6676.92%
File 2: TryConsumeModuleVersionOption(...)83.33%6681.82%
File 2: TryConsumeModuleForceOption(...)75%4481.82%
File 2: TryConsumeOptionValue(...)25%4475%
File 2: CreateParsedModuleCommand(...)100%11100%
File 3: .cctor()100%11100%
File 3: .ctor(...)100%11100%
File 3: get_Mode()100%11100%
File 3: get_ScriptPath()100%11100%
File 3: get_ScriptPathProvided()100%11100%
File 3: get_ScriptArguments()100%11100%
File 3: get_KestrunFolder()100%11100%
File 3: get_KestrunManifestPath()100%11100%
File 3: get_ServiceName()100%11100%
File 3: get_ServiceNameProvided()100%11100%
File 3: get_ServiceLogPath()100%11100%
File 3: get_ServiceUser()100%11100%
File 3: get_ServicePassword()100%11100%
File 3: get_ModuleVersion()100%11100%
File 3: get_ModuleScope()100%11100%
File 3: get_ModuleForce()100%11100%
File 3: get_ServiceContentRoot()100%11100%
File 3: get_ServiceDeploymentRoot()100%11100%
File 3: get_ServiceRuntimeSource()100%11100%
File 3: get_ServiceRuntimePackage()100%11100%
File 3: get_ServiceRuntimeVersion()100%11100%
File 3: get_ServiceRuntimePackageId()100%11100%
File 3: get_ServiceRuntimeCache()100%11100%
File 3: get_ServiceContentRootChecksum()100%11100%
File 3: get_ServiceContentRootChecksumAlgorithm()100%11100%
File 3: get_ServiceContentRootBearerToken()100%11100%
File 3: get_ServiceContentRootIgnoreCertificate()100%11100%
File 3: get_ServiceContentRootHeaders()100%11100%
File 3: get_ServiceFailback()100%11100%
File 3: get_ServiceUseRepositoryKestrun()100%11100%
File 3: get_JsonOutput()100%11100%
File 3: get_RawOutput()100%11100%
File 3: .ctor(...)100%210%
File 3: get_ServiceName()100%210%
File 3: get_ServiceHostExecutablePath()100%210%
File 3: get_RunnerExecutablePath()100%210%
File 3: get_ScriptPath()100%210%
File 3: get_ModuleManifestPath()100%210%
File 3: get_ScriptArguments()100%210%
File 3: get_ServiceLogPath()100%210%
File 3: get_ServiceUser()100%210%
File 3: get_ServicePassword()100%210%
File 3: .ctor(...)100%11100%
File 3: get_CommandArgs()100%11100%
File 3: get_SkipGalleryCheck()100%11100%
File 3: .ctor(...)100%11100%
File 3: get_Version()100%11100%
File 3: get_ManifestPath()100%11100%
File 3: .ctor(...)100%11100%
File 3: get_RootPath()100%11100%
File 3: get_RuntimeExecutablePath()100%11100%
File 3: get_ServiceHostExecutablePath()100%11100%
File 3: get_ScriptPath()100%11100%
File 3: get_ModuleManifestPath()100%11100%
File 3: .ctor(...)100%11100%
File 3: get_Rid()100%11100%
File 3: get_PackageId()100%11100%
File 3: get_PackageVersion()100%11100%
File 3: get_PackagePath()100%11100%
File 3: get_ExtractionRoot()100%11100%
File 3: get_ServiceHostExecutablePath()100%11100%
File 3: get_ModulesPath()100%11100%
File 3: .ctor(...)100%11100%
File 3: get_FullScriptPath()100%11100%
File 3: get_FullContentRoot()100%11100%
File 3: get_RelativeScriptPath()100%11100%
File 3: get_TemporaryContentRootPath()100%11100%
File 3: get_DescriptorServiceName()100%11100%
File 3: get_DescriptorServiceDescription()100%210%
File 3: get_DescriptorServiceVersion()100%11100%
File 3: get_DescriptorServiceLogPath()100%11100%
File 3: get_DescriptorPreservePaths()100%11100%
File 3: get_DescriptorApplicationDataFolders()100%11100%
File 3: .ctor(...)100%11100%
File 3: get_FormatVersion()100%11100%
File 3: get_Name()100%11100%
File 3: get_EntryPoint()100%11100%
File 3: get_Description()100%11100%
File 3: get_Version()100%11100%
File 3: get_ServiceLogPath()100%11100%
File 3: get_PreservePaths()100%11100%
File 3: get_ApplicationDataFolders()100%11100%
File 4: ExecuteScriptViaServiceHost(...)0%4260%
File 4: RunForegroundProcess(...)75%4475%
File 4: TryParseRunArguments(...)100%1414100%
File 4: TryCaptureRunScriptArguments(...)100%44100%
File 4: TryConsumeRunOption(...)100%1414100%
File 4: TryConsumeRunScriptOption(...)100%44100%
File 4: TryConsumeRunKestrunFolderOption(...)100%22100%
File 4: TryConsumeRunKestrunManifestOption(...)100%22100%
File 4: .ctor(...)100%11100%
File 4: get_Index()100%11100%
File 4: get_KestrunFolder()100%11100%
File 4: get_KestrunManifestPath()100%11100%
File 4: get_ScriptPath()100%11100%
File 4: get_ScriptPathSet()100%11100%
File 4: get_ScriptArguments()100%11100%
File 5: InstallService(...)12.5%54810.53%
File 5: IsRuntimeOnlyServiceInstall(...)70%1010100%
File 5: CacheServiceRuntimePackage(...)100%44100%
File 5: TryResolveInstallServiceInputs(...)75%131282.86%
File 5: TryCleanupTemporaryServiceContentRoot(...)100%4471.43%
File 5: TryRunInstallServicePreflight(...)0%342180%
File 5: TryPrepareInstallServiceBundle(...)50%6676.32%
File 5: InstallPreparedServiceForCurrentPlatform(...)37.5%16850%
File 5: RemoveService(...)7.14%1431413.04%
File 5: StartService(...)50%15853.33%
File 5: StopService(...)50%15853.33%
File 5: QueryService(...)50%15853.33%
File 5: .ctor(...)100%11100%
File 5: get_Operation()100%11100%
File 5: get_ServiceName()100%11100%
File 5: get_Platform()100%11100%
File 5: get_State()100%11100%
File 5: get_Pid()100%11100%
File 5: get_ExitCode()100%11100%
File 5: get_Message()100%11100%
File 5: get_RawOutput()100%11100%
File 5: get_RawError()100%11100%
File 5: get_Success()100%11100%
File 5: WriteServiceControlResult(...)93.75%1616100%
File 5: InfoService(...)62.5%252487.5%
File 5: WriteServiceInfoHumanReadable(...)56.25%161688.89%
File 5: get_Version()100%11100%
File 5: GetServiceBackupSnapshots(...)100%44100%
File 5: TryEnumerateInstalledServiceBundleRoots(...)100%1414100%
File 5: UpdateService(...)0%2040%
File 5: ExecuteServiceUpdateFlow(...)0%7280%
File 5: TryPrepareServiceUpdateExecution(...)50%5460%
File 5: TryRunServiceUpdateOperations(...)50%8661.54%
File 5: TryValidateUpdateServiceCommand(...)100%1818100%
File 5: TryResolveUpdateServiceIdentity(...)0%110100%
File 5: TryResolveServiceUpdatePaths(...)100%22100%
File 5: TryExecuteServiceFailback(...)50%2270%
File 5: TryApplyServicePackageUpdate(...)50%9873.33%
File 5: TryEnsureServicePackageSourceResolved(...)25%9433.33%
File 5: TryValidateServicePackageUpdateContext(...)50%181056.52%
File 5: TryApplyServiceApplicationReplacement(...)50%5462.5%
File 5: TryApplyServiceModuleUpdate(...)33.33%551233.33%
File 5: TryApplyDirectModuleReplacement(...)50%6450%
File 5: TryResolveUpdateManifestPath(...)100%11100%
File 5: TryResolveUpdateManifestPath(...)83.33%6680%
File 5: TryResolveSourceModuleRoot(...)83.33%66100%
File 5: TryApplyServiceHostUpdate(...)50%2285.71%
File 5: WriteServiceUpdateSummary(...)50%22100%
File 5: get_ServiceRootPath()100%11100%
File 5: get_ScriptRoot()100%11100%
File 5: get_ModuleRoot()100%11100%
File 5: get_BackupRoot()100%11100%
File 5: ResolveRepositoryModuleManifestPath()100%210%
File 5: ResolveRepositoryModuleManifestPath(...)75%4483.33%
File 5: TryEvaluateRepositoryModuleUpdateNeeded(...)62.5%10866.67%
File 5: TryFailbackServiceFromBackup(...)64.29%211466.67%
File 5: TryResolveLatestServiceBackupDirectory(...)75%4490.91%
File 5: TryParseServiceDescriptorVersion(...)50%8660%
File 5: TryValidateServicePackageVersionUpdate(...)75%9876.47%
File 5: TryEnsureServiceIsStopped(...)33.33%15637.5%
File 5: TryEnsureWindowsServiceIsStopped(...)0%7280%
File 5: TryEnsureLinuxServiceIsStopped(...)50%2275%
File 5: TryEnsureMacServiceIsStopped(...)0%210140%
File 5: TryBackupDirectory(...)50%2260%
File 5: TryReplaceDirectoryFromSource(...)93.75%211673.91%
File 5: TryStagePreservedPaths(...)87.5%161696.3%
File 5: TryRestorePreservedPaths(...)100%6682.35%
File 5: TryNormalizePreservePath(...)62.5%9875%
File 5: TryUpdateBundledServiceHostIfNewer(...)33.33%12644.44%
File 5: TryResolveServiceHostUpdatePaths(...)50%2271.43%
File 5: TryCopyServiceHostBinary(...)0%5463.64%
File 5: ShouldReplaceBundledServiceHostBinary(...)0%7280%
File 5: TryBackupAndReplaceServiceHostBinary(...)100%210%
File 5: TryReadFileVersion(...)0%156120%
File 5: TryResolveInstalledServiceBundleRoot(...)100%1010100%
File 5: StartWindowsService(...)0%620%
File 5: StopWindowsService(...)0%2040%
File 5: IsWindowsServiceAlreadyStopped(...)0%2040%
File 5: QueryWindowsService(...)0%7280%
File 5: InstallLinuxUserDaemon(...)45%432061.29%
File 5: BuildLinuxSystemdUnitContent(...)100%44100%
File 5: RemoveLinuxUserDaemon(...)0%110100%
File 5: StartLinuxUserDaemon(...)50%2281.82%
File 5: StopLinuxUserDaemon(...)66.67%7669.57%
File 5: IsLinuxServiceAlreadyStopped(...)100%66100%
File 5: QueryLinuxUserDaemon(...)37.5%10866.67%
File 5: RunLinuxSystemctl(...)75%44100%
File 5: IsLinuxSystemUnitInstalled(...)100%11100%
File 5: TryGetInstalledLinuxUnitScope(...)50%2271.43%
File 5: InstallMacLaunchAgent(...)0%7280%
File 5: RemoveMacLaunchAgent(...)25%13857.14%
File 5: StartMacLaunchAgent(...)25%23838.89%
File 5: StopMacLaunchAgent(...)20%461029.17%
File 5: IsMacServiceAlreadyStopped(...)0%4260%
File 5: QueryMacLaunchAgent(...)12.5%37823.53%
File 5: TryExtractWindowsServicePid(...)50%8888.89%
File 5: TryQueryLinuxServicePid(...)0%4260%
File 5: TryExtractMacServicePid(...)78.57%191470%
File 5: IsMacSystemLaunchDaemonInstalled(...)100%11100%
File 5: BuildLaunchdPlist(...)100%22100%
File 5: TryPrepareServiceBundle(...)50%292684.31%
File 5: TryResolveServiceBundleContext(...)50%9877.78%
File 5: RecreateServiceBundleDirectories(...)50%2285.71%
File 5: CopyServiceRuntimeExecutable(...)100%11100%
File 5: TryCopyServiceHostExecutable(...)50%2275%
File 5: TryCopyBundledRuntimeModules(...)50%4481.82%
File 5: EnsureBundleExecutablesAreRunnable(...)66.67%6683.33%
File 5: TryCopyServiceModuleFiles(...)50%2283.33%
File 5: TryCopyServiceScriptFiles(...)83.33%6688.24%
File 5: get_FullScriptPath()100%11100%
File 5: get_FullManifestPath()100%11100%
File 5: get_RuntimeExecutablePath()100%11100%
File 5: get_ModuleRoot()100%11100%
File 5: get_RuntimePackageServiceHostPath()100%11100%
File 5: get_RuntimePackageModulesPath()100%11100%
File 5: get_ServiceRoot()100%11100%
File 5: get_RuntimeDirectory()100%11100%
File 5: get_ModulesDirectory()100%11100%
File 5: get_ModuleDirectory()100%11100%
File 5: get_ScriptDirectory()100%11100%
File 6: get_ServiceName()100%11100%
File 6: get_ServiceNameSet()100%11100%
File 6: get_ScriptPath()100%11100%
File 6: get_ScriptPathSet()100%11100%
File 6: get_ScriptArguments()100%11100%
File 6: get_ServiceLogPath()100%11100%
File 6: get_ServiceUser()100%11100%
File 6: get_ServicePassword()100%11100%
File 6: get_ServiceContentRoot()100%11100%
File 6: get_ServicePackageSet()100%11100%
File 6: get_ServiceDeploymentRoot()100%11100%
File 6: get_ServiceRuntimeSource()100%11100%
File 6: get_ServiceRuntimePackage()100%11100%
File 6: get_ServiceRuntimeVersion()100%11100%
File 6: get_ServiceRuntimePackageId()100%11100%
File 6: get_ServiceRuntimeCache()100%11100%
File 6: get_ServiceContentRootChecksum()100%11100%
File 6: get_ServiceContentRootChecksumAlgorithm()100%11100%
File 6: get_ServiceContentRootBearerToken()100%11100%
File 6: get_ServiceContentRootIgnoreCertificate()100%11100%
File 6: get_ServiceFailbackRequested()100%11100%
File 6: get_ServiceUseRepositoryKestrun()100%11100%
File 6: get_ServiceJsonOutputRequested()100%11100%
File 6: get_ServiceRawOutputRequested()100%11100%
File 6: get_ServiceContentRootHeaders()100%11100%
File 6: get_ServiceName()100%11100%
File 6: get_ServiceHostExecutablePath()100%11100%
File 6: get_RunnerExecutablePath()100%11100%
File 6: get_ScriptPath()100%11100%
File 6: get_ModuleManifestPath()100%11100%
File 6: get_ScriptArguments()100%11100%
File 6: get_ServiceLogPath()100%210%
File 6: get_ServiceUser()100%210%
File 6: get_ServicePassword()100%210%
File 6: TryParseServiceArguments(...)83.33%6690%
File 6: CreateDefaultServiceParsedCommand(...)100%11100%
File 6: TryResolveServiceMode(...)50%2260%
File 6: TryParseServiceOptionLoop(...)72.22%231875%
File 6: CreateServiceParsedCommand(...)100%11100%
File 6: TryParseServiceMode(...)62.86%433581.25%
File 6: TryConsumeServiceOption(...)93.94%13313296.77%
File 6: TryConsumeServiceJsonOption(...)50%4471.43%
File 6: TryConsumeServiceRawOption(...)50%4471.43%
File 6: TryConsumeServiceRepositoryKestrunOption(...)50%2271.43%
File 6: TryConsumeServiceFailbackOption(...)50%2271.43%
File 6: TryConsumeServiceScriptOption(...)50%14854.55%
File 6: TryConsumeServiceNameOption(...)50%2280%
File 6: TryConsumeKestrunFolderOption(...)0%620%
File 6: TryConsumeKestrunManifestOption(...)50%2275%
File 6: TryConsumeServiceLogPathOption(...)50%2275%
File 6: TryConsumeServiceUserOption(...)50%9657.14%
File 6: TryConsumeServicePasswordOption(...)50%9657.14%
File 6: TryConsumeServiceDeploymentRootOption(...)83.33%6685.71%
File 6: TryConsumeServicePackageOption(...)83.33%6687.5%
File 6: TryConsumeServiceRuntimeSourceOption(...)75%4485.71%
File 6: TryConsumeServiceRuntimePackageOption(...)50%5457.14%
File 6: TryConsumeServiceRuntimeVersionOption(...)50%5457.14%
File 6: TryConsumeServiceRuntimePackageIdOption(...)50%5457.14%
File 6: TryConsumeServiceRuntimeCacheOption(...)50%5457.14%
File 6: TryConsumeDeprecatedServiceContentRootOption(...)50%7666.67%
File 6: TryConsumeServiceContentRootChecksumOption(...)50%9657.14%
File 6: TryConsumeServiceContentRootChecksumAlgorithmOption(...)50%9657.14%
File 6: TryConsumeServiceContentRootBearerTokenOption(...)50%9657.14%
File 6: TryConsumeServiceContentRootIgnoreCertificateOption(...)50%4471.43%
File 6: TryConsumeServiceContentRootHeaderOption(...)50%9657.14%
File 6: TryConsumeOptionValue(...)100%22100%
File 6: TryConsumeServicePositionalScript(...)12.5%29830.77%
File 6: TryValidateServiceParseState(...)100%1212100%
File 6: TryValidateServiceRuntimeOptions(...)80%121073.33%
File 6: TryValidateServiceUpdateOptions(...)100%1818100%
File 6: TryValidateServiceName(...)93.75%161689.47%
File 6: ApplyDefaultServiceInstallScript(...)100%11100%
File 6: TryValidateServiceCredentialOptions(...)75%301250%
File 6: TryValidateServiceContentRootDependentOptions(...)87.5%8891.67%
File 6: TryValidateServiceInstallScriptContentRootSelection(...)87.5%9875%
File 6: HasServiceRuntimeAcquisitionRequest(...)83.33%66100%
File 6: TryValidateServiceInstallPackageExtension(...)83.33%231875%
File 6: TryValidateServiceContentRootLinkedOptions(...)100%88100%
File 6: TryValidateServiceContentRootChecksumOptions(...)100%66100%
File 6: TryValidateContentRootLinkedOption(...)100%44100%
File 6: TryParseServiceRegisterArguments(...)83.33%6687.5%
File 6: TryParseServiceRegisterOptionLoop(...)75%4483.33%
File 6: TryConsumeServiceRegisterOption(...)60%141066.67%
File 6: TryApplyServiceRegisterOptionValue(...)0%2162460%
File 6: IsServiceRegisterOptionWithValue(...)73.91%4646100%
File 6: TryConsumeServiceRegisterOptionValue(...)75%5462.5%
File 6: TryBuildServiceRegisterOptions(...)0%110100%
File 7: TryResolveServiceRuntimePackage(...)78.57%141498.11%
File 7: HasExplicitRuntimeOverride(...)100%88100%
File 7: GetEffectiveRuntimePackageId(...)100%22100%
File 7: NormalizeRuntimeVersion(...)100%22100%
File 7: TryResolveServiceRuntimePackageFromCacheOrSources(...)86.36%222290.91%
File 7: BuildRuntimePackageNotFoundError(...)50%9655.56%
File 7: TryResolveServiceRuntimePackageFromToolDistribution(...)62.5%8887.5%
File 7: TryResolveServiceRuntimePackageFromExplicitPackage(...)100%22100%
File 7: TryLoadAndValidateExplicitRuntimePackage(...)70%141064.71%
File 7: TryResolveExplicitRuntimePackagePath(...)50%14853.85%
File 7: TryResolveExplicitRuntimePackageFromDirectory(...)83.33%66100%
File 7: TryPrepareExplicitRuntimePackageCacheEntry(...)83.33%6681.82%
File 7: TryResolveServiceRuntimePackageFromSource(...)75%4496.67%
File 7: TryEnsureCachedRuntimePackageForSource(...)70%131070%
File 7: TryLoadAndValidateCachedRuntimePackage(...)50%8660%
File 7: TryResolveServiceRuntimePackageFromExpandedCache(...)12.5%41820%
File 7: TryPrepareResolvedServiceRuntimePackage(...)100%22100%
File 7: GetServiceRuntimeSourceCandidates(...)100%22100%
File 7: TryResolveRuntimeCacheRoot(...)100%44100%
File 7: GetDefaultRuntimeCacheRoot()50%201053.85%
File 7: TryResolveServiceRuntimePackageFromDirectSource(...)43.75%401654.29%
File 7: GetDefaultServiceRuntimePackageVersion()50%22100%
File 7: TryAcquireRuntimePackageFromSource(...)64.29%231464.29%
File 7: TryResolveDirectRuntimeSourceLocalPackagePath(...)61.11%281868.18%
File 7: TryResolveDirectRuntimeSourceExistingFilePath(...)75%4481.82%
File 7: TryResolveDirectRuntimeSourceFileUri(...)30%481027.78%
File 7: IsDirectRuntimePackageUri(...)100%11100%
File 7: GetDirectRuntimePackageDownloadPath(...)0%4260%
File 7: TryCopyRuntimePackageFromLocalSource(...)100%22100%
File 7: TryCleanupEmptyRuntimePackageDirectory(...)78.57%141493.33%
File 7: TryDownloadRuntimePackageFromSource(...)50%2283.33%
File 7: TryDownloadRuntimePackageFile(...)42.86%871428.21%
File 7: TryResolvePackageBaseAddress(...)50%8438.46%
File 7: TryResolveFlatContainerBaseAddress(...)25%6450%
File 7: TryResolvePackageBaseAddressFromServiceIndexDocument(...)50%5462.5%
File 7: TryGetRuntimeSourceResourcesArray(...)50%5460%
File 7: TryResolvePackageBaseAddressFromResources(...)75%4483.33%
File 7: TryResolvePackageBaseAddressFromResource(...)60%111080%
File 7: IsPackageBaseAddressResourceType(...)37.5%16850%
File 7: TryGetRuntimeSourceJsonDocument(...)42.86%701434.21%
File 7: TryEnsureExtractedServiceRuntimePackage(...)75%151273.08%
File 7: TryValidateExtractedServiceRuntimePackagePayload(...)100%7672.73%
File 7: TryValidateExtractedRuntimePackagePreconditions(...)75%4475%
File 7: TryValidateRuntimePackageHostPath(...)100%22100%
File 7: TryValidateRuntimePackageModulesPath(...)62.5%9877.78%
File 7: TryResolveRuntimePackageHostPath(...)33.33%6678.57%
File 7: TryResolveRuntimeManifestPayloadPath(...)66.67%66100%
File 7: TryWriteRuntimePackageExtractionMarker(...)100%1160%
File 7: TryResolveExtractedServiceRuntimePackageLayout(...)64.29%161479.17%
File 7: TryReadRuntimePackageManifest(...)77.78%201881.58%
File 7: TryReadPackageIdentity(...)50%101086.36%
File 7: SanitizePathToken(...)75%44100%

File(s)

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/Program.cs

#LineLine coverage
 1using System.Reflection;
 2using Kestrun.Runner;
 3using System.Diagnostics;
 4using System.ComponentModel;
 5using System.Security.Principal;
 6using System.Runtime.Versioning;
 7using System.Runtime.InteropServices;
 8using System.IO.Compression;
 9using System.Formats.Tar;
 10using System.Net.Http.Headers;
 11using System.Security.Cryptography;
 12using System.Text;
 13using System.Text.RegularExpressions;
 14using System.Xml.Linq;
 15
 16namespace Kestrun.Tool;
 17
 18internal static partial class Program
 19{
 20    private static int Main(string[] args)
 21    {
 222        if (TryHandleInternalServiceRegisterMode(args, out var serviceRegisterExitCode))
 23        {
 024            return serviceRegisterExitCode;
 25        }
 26
 227        var globalOptions = ParseGlobalOptions(args);
 228        var commandArgs = globalOptions.CommandArgs;
 29
 230        if (TryHandleMetaCommands(commandArgs, out var metaExitCode))
 31        {
 132            return metaExitCode;
 33        }
 34
 135        if (!TryParseArguments(commandArgs, out var parsedCommand, out var parseError))
 36        {
 137            Console.Error.WriteLine(parseError);
 138            PrintUsage();
 139            return 2;
 40        }
 41
 042        if (TryDispatchParsedCommand(parsedCommand, globalOptions, args, out var commandExitCode))
 43        {
 044            return commandExitCode;
 45        }
 46        // If the command was not handled by dispatch, it must be a run command. Execute the default run mode path.
 047        return ExecuteRunMode(parsedCommand, globalOptions.SkipGalleryCheck);
 48    }
 49
 50    /// <summary>
 51    /// Handles internal Windows-only service registration mode prior to normal command parsing.
 52    /// </summary>
 53    /// <param name="args">Raw command-line arguments.</param>
 54    /// <param name="exitCode">Exit code when internal service registration mode is handled.</param>
 55    /// <returns>True when internal service registration mode was handled.</returns>
 56    private static bool TryHandleInternalServiceRegisterMode(string[] args, out int exitCode)
 57    {
 458        exitCode = 0;
 59
 460        if (TryParseServiceRegisterArguments(args, out var serviceRegisterOptions, out var serviceRegisterError))
 61        {
 062            if (!OperatingSystem.IsWindows())
 63            {
 064                Console.Error.WriteLine("Internal service registration mode is only supported on Windows.");
 065                exitCode = 1;
 066                return true;
 67            }
 68
 069            exitCode = RegisterWindowsService(serviceRegisterOptions!);
 070            return true;
 71        }
 72
 473        if (string.IsNullOrWhiteSpace(serviceRegisterError))
 74        {
 375            return false;
 76        }
 77
 178        Console.Error.WriteLine(serviceRegisterError);
 179        exitCode = 2;
 180        return true;
 81    }
 82
 83    /// <summary>
 84    /// Dispatches parsed non-run commands and returns an exit code when handled.
 85    /// </summary>
 86    /// <param name="parsedCommand">Parsed command.</param>
 87    /// <param name="globalOptions">Parsed global options.</param>
 88    /// <param name="args">Raw command-line arguments.</param>
 89    /// <param name="exitCode">Exit code when command is handled.</param>
 90    /// <returns>True when the command mode is handled by dispatch.</returns>
 91    private static bool TryDispatchParsedCommand(ParsedCommand parsedCommand, GlobalOptions globalOptions, string[] args
 92    {
 193        switch (parsedCommand.Mode)
 94        {
 95            case CommandMode.ServiceInstall:
 096                exitCode = InstallService(parsedCommand, globalOptions.SkipGalleryCheck);
 097                return true;
 98            case CommandMode.ServiceUpdate:
 099                exitCode = UpdateService(parsedCommand);
 0100                return true;
 101            case CommandMode.ModuleInstall:
 102            case CommandMode.ModuleUpdate:
 103            case CommandMode.ModuleRemove:
 104            case CommandMode.ModuleInfo:
 0105                exitCode = HandleModuleCommand(parsedCommand, args);
 0106                return true;
 107            case CommandMode.ServiceRemove:
 0108                exitCode = HandleServiceRemoveCommand(parsedCommand, args);
 0109                return true;
 110            case CommandMode.ServiceStart:
 0111                exitCode = HandleServiceStartCommand(parsedCommand, args);
 0112                return true;
 113            case CommandMode.ServiceStop:
 0114                exitCode = HandleServiceStopCommand(parsedCommand, args);
 0115                return true;
 116            case CommandMode.ServiceQuery:
 0117                exitCode = QueryService(parsedCommand);
 0118                return true;
 119            case CommandMode.ServiceInfo:
 0120                exitCode = InfoService(parsedCommand);
 0121                return true;
 122            default:
 1123                exitCode = 0;
 1124                return false;
 125        }
 126    }
 127
 128    /// <summary>
 129    /// Handles module command execution, including Windows elevation for global scope changes.
 130    /// </summary>
 131    /// <param name="parsedCommand">Parsed module command.</param>
 132    /// <param name="args">Raw command-line arguments.</param>
 133    /// <returns>Process exit code.</returns>
 134    private static int HandleModuleCommand(ParsedCommand parsedCommand, string[] args)
 135    {
 0136        if (OperatingSystem.IsWindows() && RequiresWindowsElevationForGlobalModuleOperation(parsedCommand))
 137        {
 0138            return RelaunchElevatedOnWindows(args);
 139        }
 140
 141        // For non-Windows OSes, attempt module management without elevation and rely on error handling for permission i
 0142        return ManageModuleCommand(parsedCommand);
 143    }
 144
 145    /// <summary>
 146    /// Returns true when a module install/update/remove command requires Windows elevation.
 147    /// </summary>
 148    /// <param name="parsedCommand">Parsed command.</param>
 149    /// <returns>True when the command targets global scope on Windows without elevation.</returns>
 150    private static bool RequiresWindowsElevationForGlobalModuleOperation(ParsedCommand parsedCommand)
 151    {
 0152        if (!OperatingSystem.IsWindows())
 153        {
 0154            return false;
 155        }
 156        // Global scope module operations require admin rights on Windows.
 0157        return parsedCommand.Mode is CommandMode.ModuleInstall or CommandMode.ModuleUpdate or CommandMode.ModuleRemove
 0158            && parsedCommand.ModuleScope == ModuleStorageScope.Global
 0159            && !IsWindowsAdministrator();
 160    }
 161
 162    /// <summary>
 163    /// Handles service remove command execution and Windows elevation preflight.
 164    /// </summary>
 165    /// <param name="parsedCommand">Parsed service command.</param>
 166    /// <param name="args">Raw command-line arguments.</param>
 167    /// <returns>Process exit code.</returns>
 168    private static int HandleServiceRemoveCommand(ParsedCommand parsedCommand, string[] args)
 169    {
 1170        if (OperatingSystem.IsWindows() && !IsWindowsAdministrator())
 171        {
 0172            return !TryPreflightWindowsServiceRemove(parsedCommand, out var preflightExitCode)
 0173                ? preflightExitCode
 0174                : RelaunchElevatedOnWindows(args);
 175        }
 176
 177        // For non-Windows OSes, attempt removal without elevation and rely on permission/service-state errors.
 1178        return RemoveService(parsedCommand);
 179    }
 180
 181    /// <summary>
 182    /// Handles service start command execution and Windows elevation preflight.
 183    /// </summary>
 184    /// <param name="parsedCommand">Parsed service command.</param>
 185    /// <param name="args">Raw command-line arguments.</param>
 186    /// <returns>Process exit code.</returns>
 187    private static int HandleServiceStartCommand(ParsedCommand parsedCommand, string[] args)
 188    {
 1189        if (OperatingSystem.IsWindows() && !IsWindowsAdministrator())
 190        {
 0191            if (!TryPreflightWindowsServiceControl(parsedCommand, out var preflightExitCode, out var preflightMessage))
 192            {
 0193                if (parsedCommand.RawOutput)
 194                {
 0195                    Console.Error.WriteLine(preflightMessage);
 0196                    return preflightExitCode;
 197                }
 198
 0199                return WriteServiceControlResult(
 0200                    parsedCommand,
 0201                    new ServiceControlResult(
 0202                        "start",
 0203                        parsedCommand.ServiceName ?? string.Empty,
 0204                        "windows",
 0205                        "unknown",
 0206                        null,
 0207                        preflightExitCode,
 0208                        preflightMessage,
 0209                        string.Empty,
 0210                        string.Empty));
 211            }
 212
 0213            var relaunchExitCode = RelaunchElevatedOnWindows(args, suppressStatusMessages: true);
 0214            return relaunchExitCode == 1223 && !parsedCommand.RawOutput
 0215                ? WriteServiceControlResult(
 0216                    parsedCommand,
 0217                    new ServiceControlResult(
 0218                        "start",
 0219                        parsedCommand.ServiceName ?? string.Empty,
 0220                        "windows",
 0221                        "unknown",
 0222                        null,
 0223                        1,
 0224                        "Elevation was canceled by the user. Run this command from an elevated terminal if you want to p
 0225                        string.Empty,
 0226                        string.Empty))
 0227                : relaunchExitCode;
 228        }
 229
 230        // For non-Windows OSes, attempt start without elevation and rely on permission/service-state errors.
 1231        return StartService(parsedCommand);
 232    }
 233
 234    /// <summary>
 235    /// Handles service stop command execution and Windows elevation preflight.
 236    /// </summary>
 237    /// <param name="parsedCommand">Parsed service command.</param>
 238    /// <param name="args">Raw command-line arguments.</param>
 239    /// <returns>Process exit code.</returns>
 240    private static int HandleServiceStopCommand(ParsedCommand parsedCommand, string[] args)
 241    {
 1242        if (OperatingSystem.IsWindows() && !IsWindowsAdministrator())
 243        {
 0244            if (!TryPreflightWindowsServiceControl(parsedCommand, out var preflightExitCode, out var preflightMessage))
 245            {
 0246                if (parsedCommand.RawOutput)
 247                {
 0248                    Console.Error.WriteLine(preflightMessage);
 0249                    return preflightExitCode;
 250                }
 251
 0252                return WriteServiceControlResult(
 0253                    parsedCommand,
 0254                    new ServiceControlResult(
 0255                        "stop",
 0256                        parsedCommand.ServiceName ?? string.Empty,
 0257                        "windows",
 0258                        "unknown",
 0259                        null,
 0260                        preflightExitCode,
 0261                        preflightMessage,
 0262                        string.Empty,
 0263                        string.Empty));
 264            }
 265
 0266            var relaunchExitCode = RelaunchElevatedOnWindows(args, suppressStatusMessages: true);
 0267            return relaunchExitCode == 1223 && !parsedCommand.RawOutput
 0268                ? WriteServiceControlResult(
 0269                    parsedCommand,
 0270                    new ServiceControlResult(
 0271                        "stop",
 0272                        parsedCommand.ServiceName ?? string.Empty,
 0273                        "windows",
 0274                        "unknown",
 0275                        null,
 0276                        1,
 0277                        "Elevation was canceled by the user. Run this command from an elevated terminal if you want to p
 0278                        string.Empty,
 0279                        string.Empty))
 0280                : relaunchExitCode;
 281        }
 282
 283        // For non-Windows OSes, attempt stop without elevation and rely on permission/service-state errors.
 1284        return StopService(parsedCommand);
 285    }
 286
 287    /// <summary>
 288    /// Executes the default run mode path after command parsing succeeds.
 289    /// </summary>
 290    /// <param name="parsedCommand">Parsed run command.</param>
 291    /// <param name="skipGalleryCheck">True to skip gallery version checks.</param>
 292    /// <returns>Process exit code.</returns>
 293    private static int ExecuteRunMode(ParsedCommand parsedCommand, bool skipGalleryCheck)
 294    {
 1295        var fullScriptPath = Path.GetFullPath(parsedCommand.ScriptPath);
 1296        if (!File.Exists(fullScriptPath))
 297        {
 1298            Console.Error.WriteLine($"Script file not found: {fullScriptPath}");
 1299            return 2;
 300        }
 301
 0302        var moduleManifestPath = ResolveRunModuleManifestPath(parsedCommand.KestrunManifestPath, parsedCommand.KestrunFo
 0303        if (moduleManifestPath is null)
 304        {
 0305            WriteModuleNotFoundMessage(parsedCommand.KestrunManifestPath, parsedCommand.KestrunFolder, Console.Error.Wri
 0306            return 3;
 307        }
 308
 0309        if (!skipGalleryCheck)
 310        {
 0311            WarnIfNewerGalleryVersionExists(moduleManifestPath);
 312        }
 313
 314        try
 315        {
 0316            return ExecuteScriptViaServiceHost(fullScriptPath, parsedCommand.ScriptArguments, moduleManifestPath);
 317        }
 0318        catch (Exception ex)
 319        {
 0320            Console.Error.WriteLine($"Execution failed: {ex.Message}");
 0321            return 1;
 322        }
 0323    }
 324
 325    /// <summary>
 326    /// Checks whether the current Windows process token has administrator privileges.
 327    /// </summary>
 328    /// <returns>True when running elevated as administrator.</returns>
 329    [SupportedOSPlatform("windows")]
 330    private static bool IsWindowsAdministrator()
 331    {
 0332        using var identity = WindowsIdentity.GetCurrent();
 0333        var principal = new WindowsPrincipal(identity);
 0334        return principal.IsInRole(WindowsBuiltInRole.Administrator);
 0335    }
 336
 337    /// <summary>
 338    /// Performs non-admin checks before elevating a Windows service install request.
 339    /// </summary>
 340    /// <param name="command">Parsed service command.</param>
 341    /// <param name="serviceName">Resolved service name.</param>
 342    /// <param name="exitCode">Exit code when preflight fails.</param>
 343    /// <returns>True when install should proceed with elevation.</returns>
 344    [SupportedOSPlatform("windows")]
 345    private static bool TryPreflightWindowsServiceInstall(ParsedCommand command, string serviceName, out int exitCode)
 346    {
 0347        exitCode = 0;
 0348        if (string.IsNullOrWhiteSpace(serviceName))
 349        {
 0350            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 0351            exitCode = 2;
 0352            return false;
 353        }
 354
 0355        var moduleManifestPath = LocateModuleManifest(command.KestrunManifestPath, command.KestrunFolder);
 0356        if (moduleManifestPath is null)
 357        {
 0358            WriteModuleNotFoundMessage(command.KestrunManifestPath, command.KestrunFolder, Console.Error.WriteLine);
 0359            exitCode = 3;
 0360            return false;
 361        }
 362
 0363        if (!TryResolveServiceRuntimeExecutableFromModule(moduleManifestPath, out _, out var runtimeError))
 364        {
 0365            Console.Error.WriteLine(runtimeError);
 0366            exitCode = 1;
 0367            return false;
 368        }
 369
 370        // Do not hard-fail preflight on bundled modules payload resolution.
 371        // Elevated relaunch can run under a different working-directory/layout context,
 372        // so definitive payload validation is performed during actual bundle preparation.
 373
 0374        if (WindowsServiceExists(serviceName))
 375        {
 0376            Console.Error.WriteLine($"Windows service '{serviceName}' already exists.");
 0377            exitCode = 2;
 0378            return false;
 379        }
 380
 0381        return true;
 382    }
 383
 384    /// <summary>
 385    /// Performs non-admin checks before elevating a Windows service removal request.
 386    /// </summary>
 387    /// <param name="command">Parsed service command.</param>
 388    /// <param name="exitCode">Exit code when preflight fails.</param>
 389    /// <returns>True when remove should proceed with elevation.</returns>
 390    [SupportedOSPlatform("windows")]
 391    private static bool TryPreflightWindowsServiceRemove(ParsedCommand command, out int exitCode)
 392    {
 0393        exitCode = 0;
 0394        if (string.IsNullOrWhiteSpace(command.ServiceName))
 395        {
 0396            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 0397            exitCode = 2;
 0398            return false;
 399        }
 400
 0401        if (!WindowsServiceExists(command.ServiceName))
 402        {
 0403            Console.Error.WriteLine($"Windows service '{command.ServiceName}' was not found.");
 0404            exitCode = 2;
 0405            return false;
 406        }
 407
 0408        return true;
 409    }
 410
 411    /// <summary>
 412    /// Performs non-admin checks before elevating a Windows service start/stop request.
 413    /// </summary>
 414    /// <param name="command">Parsed service command.</param>
 415    /// <param name="exitCode">Exit code when preflight fails.</param>
 416    /// <param name="errorMessage">Preflight failure message when validation fails.</param>
 417    /// <returns>True when control should proceed with elevation.</returns>
 418    [SupportedOSPlatform("windows")]
 419    private static bool TryPreflightWindowsServiceControl(ParsedCommand command, out int exitCode, out string errorMessa
 420    {
 0421        exitCode = 0;
 0422        errorMessage = string.Empty;
 0423        if (string.IsNullOrWhiteSpace(command.ServiceName))
 424        {
 0425            exitCode = 2;
 0426            errorMessage = "Service name is required. Use --name <value>.";
 0427            return false;
 428        }
 429
 0430        if (!WindowsServiceExists(command.ServiceName))
 431        {
 0432            exitCode = 2;
 0433            errorMessage = $"Windows service '{command.ServiceName}' was not found.";
 0434            return false;
 435        }
 436
 0437        return true;
 438    }
 439
 440    /// <summary>
 441    /// Determines whether a Windows service exists.
 442    /// </summary>
 443    /// <param name="serviceName">Service name.</param>
 444    /// <returns>True when the service exists.</returns>
 445    [SupportedOSPlatform("windows")]
 446    private static bool WindowsServiceExists(string serviceName)
 447    {
 0448        var result = RunProcess("sc.exe", ["query", serviceName], writeStandardOutput: false);
 0449        if (result.ExitCode == 0)
 450        {
 0451            return true;
 452        }
 453
 0454        var combined = $"{result.Output}\n{result.Error}";
 0455        if (combined.Contains("1060", StringComparison.OrdinalIgnoreCase)
 0456            || combined.Contains("does not exist", StringComparison.OrdinalIgnoreCase))
 457        {
 0458            return false;
 459        }
 460
 461        // A non-zero exit without a clear not-found signal is likely permission-related.
 0462        return true;
 463    }
 464
 465    /// <summary>
 466    /// Relaunches the current executable with UAC elevation on Windows.
 467    /// </summary>
 468    /// <param name="args">Original command-line arguments.</param>
 469    /// <param name="exePath">Optional executable path to launch. When null, the current process executable will be used
 470    /// <returns>Exit code from the elevated child process or an error code.</returns>
 471    [SupportedOSPlatform("windows")]
 472    private static int RelaunchElevatedOnWindows(IReadOnlyList<string> args, string? exePath = null, bool suppressStatus
 473    {
 0474        exePath ??= Environment.ProcessPath;
 0475        if (!TryResolveElevationExecutablePath(exePath, out var resolvedExePath))
 476        {
 0477            return 1;
 478        }
 479
 0480        if (!suppressStatusMessages)
 481        {
 0482            Console.Error.WriteLine("Administrator rights are required. Requesting elevation...");
 483        }
 484
 0485        var relaunchArgs = BuildElevatedRelaunchArguments(resolvedExePath, args);
 0486        var tempDirectory = Path.Combine(Path.GetTempPath(), ProductName);
 0487        _ = Directory.CreateDirectory(tempDirectory);
 488
 0489        var outputPath = Path.Combine(tempDirectory, $"elevated-{Guid.NewGuid():N}.log");
 0490        var wrapperPath = Path.Combine(tempDirectory, $"elevated-{Guid.NewGuid():N}.cmd");
 491
 0492        WriteElevationWrapperScript(wrapperPath, outputPath, resolvedExePath, relaunchArgs);
 493
 494        try
 495        {
 0496            return StartElevatedProcess(wrapperPath, outputPath, suppressStatusMessages);
 497        }
 0498        catch (Win32Exception ex) when (ex.NativeErrorCode == 1223)
 499        {
 0500            WriteElevationCanceledMessage(suppressStatusMessages);
 0501            return 1223;
 502        }
 503        catch (Exception ex)
 504        {
 0505            WriteElevationFailureMessage(ex.Message, suppressStatusMessages);
 0506            return 1;
 507        }
 508        finally
 509        {
 0510            TryDeleteFileQuietly(wrapperPath);
 0511            TryDeleteFileQuietly(outputPath);
 0512        }
 0513    }
 514
 515    /// <summary>
 516    /// Resolves and validates the executable path used for elevation relaunch.
 517    /// </summary>
 518    /// <param name="exePath">Input executable path.</param>
 519    /// <param name="resolvedExePath">Resolved executable path when validation succeeds.</param>
 520    /// <returns>True when the executable path is valid.</returns>
 521    private static bool TryResolveElevationExecutablePath(string? exePath, out string resolvedExePath)
 522    {
 0523        resolvedExePath = exePath ?? string.Empty;
 0524        if (string.IsNullOrWhiteSpace(resolvedExePath) || !File.Exists(resolvedExePath))
 525        {
 0526            Console.Error.WriteLine("Unable to resolve KestrunTool executable path for elevation.");
 0527            return false;
 528        }
 529
 0530        return true;
 531    }
 532
 533    /// <summary>
 534    /// Writes the temporary wrapper script used to capture elevated process output.
 535    /// </summary>
 536    /// <param name="wrapperPath">Wrapper script path.</param>
 537    /// <param name="outputPath">Output capture file path.</param>
 538    /// <param name="exePath">Executable path to launch.</param>
 539    /// <param name="relaunchArgs">Relaunch argument tokens.</param>
 540    private static void WriteElevationWrapperScript(string wrapperPath, string outputPath, string exePath, IReadOnlyList
 541    {
 0542        var commandLine = BuildWindowsCommandLine(exePath, relaunchArgs);
 0543        var wrapperContents = $"@echo off{Environment.NewLine}{commandLine} > \"{outputPath}\" 2>&1{Environment.NewLine}
 0544        File.WriteAllText(wrapperPath, wrapperContents, Encoding.ASCII);
 0545    }
 546
 547    /// <summary>
 548    /// Starts the elevated wrapper process and relays captured output.
 549    /// </summary>
 550    /// <param name="wrapperPath">Wrapper script path.</param>
 551    /// <param name="outputPath">Output capture file path.</param>
 552    /// <param name="suppressStatusMessages">True to suppress non-essential status messages.</param>
 553    /// <returns>Exit code from the elevated child process.</returns>
 554    private static int StartElevatedProcess(string wrapperPath, string outputPath, bool suppressStatusMessages)
 555    {
 0556        var startInfo = new ProcessStartInfo
 0557        {
 0558            FileName = "cmd.exe",
 0559            Arguments = $"/c \"{wrapperPath}\"",
 0560            UseShellExecute = true,
 0561            Verb = "runas",
 0562            WorkingDirectory = Environment.CurrentDirectory,
 0563        };
 564
 0565        using var process = Process.Start(startInfo);
 0566        if (process is null)
 567        {
 0568            Console.Error.WriteLine("Failed to start elevated process.");
 0569            return 1;
 570        }
 571
 0572        process.WaitForExit();
 0573        RelayElevatedOutput(outputPath);
 574
 0575        if (process.ExitCode != 0 && !suppressStatusMessages)
 576        {
 0577            Console.Error.WriteLine("Elevated operation failed. If no UAC prompt was shown, run this command from an ele
 578        }
 579
 0580        return process.ExitCode;
 0581    }
 582
 583    /// <summary>
 584    /// Writes captured elevated output to standard output when available.
 585    /// </summary>
 586    /// <param name="outputPath">Output capture file path.</param>
 587    private static void RelayElevatedOutput(string outputPath)
 588    {
 0589        if (!File.Exists(outputPath))
 590        {
 0591            return;
 592        }
 593
 0594        var elevatedOutput = File.ReadAllText(outputPath);
 0595        if (!string.IsNullOrWhiteSpace(elevatedOutput))
 596        {
 0597            Console.Write(elevatedOutput);
 598        }
 0599    }
 600
 601    /// <summary>
 602    /// Writes the standard elevation canceled message when status output is enabled.
 603    /// </summary>
 604    /// <param name="suppressStatusMessages">True to suppress status messages.</param>
 605    private static void WriteElevationCanceledMessage(bool suppressStatusMessages)
 606    {
 0607        if (suppressStatusMessages)
 608        {
 0609            return;
 610        }
 611
 0612        Console.Error.WriteLine("Elevation was canceled by the user.");
 0613        Console.Error.WriteLine("Run this command from an elevated terminal if you want to proceed without UAC interacti
 0614    }
 615
 616    /// <summary>
 617    /// Writes the standard elevation failure message when status output is enabled.
 618    /// </summary>
 619    /// <param name="errorMessage">Error message from the failed elevation attempt.</param>
 620    /// <param name="suppressStatusMessages">True to suppress status messages.</param>
 621    private static void WriteElevationFailureMessage(string errorMessage, bool suppressStatusMessages)
 622    {
 0623        if (suppressStatusMessages)
 624        {
 0625            return;
 626        }
 627
 0628        Console.Error.WriteLine($"Failed to elevate process: {errorMessage}");
 0629        Console.Error.WriteLine("Run this command from an elevated terminal if automatic elevation is unavailable.");
 0630    }
 631
 632    /// <summary>
 633    /// Best-effort delete for temporary files used by elevated relaunch flow.
 634    /// </summary>
 635    /// <param name="path">File path to remove.</param>
 636    private static void TryDeleteFileQuietly(string path)
 637    {
 15638        if (string.IsNullOrWhiteSpace(path))
 639        {
 0640            return;
 641        }
 642
 643        try
 644        {
 15645            if (File.Exists(path))
 646            {
 15647                File.Delete(path);
 648            }
 15649        }
 0650        catch
 651        {
 652            // Best-effort cleanup only.
 0653        }
 15654    }
 655
 656    /// <summary>
 657    /// Builds argument tokens for elevated relaunch scenarios.
 658    /// </summary>
 659    /// <param name="executablePath">Current process executable path.</param>
 660    /// <param name="args">Original command-line arguments.</param>
 661    /// <returns>Argument token list for elevated invocation.</returns>
 662    private static IReadOnlyList<string> BuildElevatedRelaunchArguments(string executablePath, IReadOnlyList<string> arg
 663    {
 2664        if (!IsDotnetHostExecutable(executablePath))
 665        {
 1666            return [.. args];
 667        }
 668
 1669        var assemblyPath = typeof(Program).Assembly.Location;
 1670        if (!string.IsNullOrWhiteSpace(assemblyPath) && File.Exists(assemblyPath))
 671        {
 1672            var elevatedArgs = new List<string>(args.Count + 1)
 1673            {
 1674                Path.GetFullPath(assemblyPath),
 1675            };
 676
 1677            elevatedArgs.AddRange(args);
 1678            return elevatedArgs;
 679        }
 680
 681        // Fallback path when assembly location is unavailable.
 0682        var fallbackArgs = new List<string>(args.Count + 1)
 0683        {
 0684            ProductName,
 0685        };
 686
 0687        fallbackArgs.AddRange(args);
 0688        return fallbackArgs;
 689    }
 690
 691    /// <summary>
 692    /// Determines whether the given executable path is the dotnet host executable.
 693    /// </summary>
 694    /// <param name="executablePath">Executable path to inspect.</param>
 695    /// <returns>True when the executable is dotnet host.</returns>
 696    private static bool IsDotnetHostExecutable(string executablePath)
 697    {
 2698        var fileNameWithoutExtension = Path.GetFileNameWithoutExtension(executablePath);
 2699        return string.Equals(fileNameWithoutExtension, "dotnet", StringComparison.OrdinalIgnoreCase);
 700    }
 701
 702    /// <summary>
 703    /// Builds KestrunTool command-line arguments used by installed services/daemons.
 704    /// </summary>
 705    /// <param name="scriptPath">Absolute script path to execute.</param>
 706    /// <param name="scriptArguments">Script arguments for the run command.</param>
 707    /// <param name="kestrunFolder">Optional folder containing Kestrun module manifest.</param>
 708    /// <param name="kestrunManifestPath">Optional explicit Kestrun module manifest path.</param>
 709    /// <returns>Ordered runner argument tokens.</returns>
 710    private static IReadOnlyList<string> BuildRunnerArgumentsForService(string scriptPath, IReadOnlyList<string> scriptA
 711    {
 1712        var arguments = new List<string>(8 + scriptArguments.Count);
 1713        if (!string.IsNullOrWhiteSpace(kestrunManifestPath))
 714        {
 1715            arguments.Add("--kestrun-manifest");
 1716            arguments.Add(Path.GetFullPath(kestrunManifestPath));
 717        }
 718
 1719        if (!string.IsNullOrWhiteSpace(kestrunFolder))
 720        {
 0721            arguments.Add("--kestrun-folder");
 0722            arguments.Add(Path.GetFullPath(kestrunFolder));
 723        }
 724
 1725        arguments.Add("run");
 1726        arguments.Add(scriptPath);
 727
 1728        if (scriptArguments.Count > 0)
 729        {
 1730            arguments.Add("--arguments");
 1731            arguments.AddRange(scriptArguments);
 732        }
 733
 1734        return arguments;
 735    }
 736
 737    /// <summary>
 738    /// Builds command-line arguments for daemon host registration.
 739    /// </summary>
 740    /// <param name="serviceName">Service name.</param>
 741    /// <param name="serviceHostExecutablePath">Service-host executable path.</param>
 742    /// <param name="runnerExecutablePath">Runner executable path.</param>
 743    /// <param name="scriptPath">Absolute script path.</param>
 744    /// <param name="moduleManifestPath">Absolute module manifest path.</param>
 745    /// <param name="scriptArguments">Script arguments for run mode.</param>
 746    /// <param name="serviceLogPath">Optional service log path.</param>
 747    /// <returns>Ordered daemon-host argument tokens.</returns>
 748    private static IReadOnlyList<string> BuildDaemonHostArgumentsForService(
 749        string serviceName,
 750        string serviceHostExecutablePath,
 751        string runnerExecutablePath,
 752        string scriptPath,
 753        string moduleManifestPath,
 754        IReadOnlyList<string> scriptArguments,
 755        string? serviceLogPath)
 756    {
 2757        if (UsesDedicatedServiceHostExecutable(serviceHostExecutablePath))
 758        {
 1759            return BuildDedicatedServiceHostArguments(serviceName, runnerExecutablePath, scriptPath, moduleManifestPath,
 760        }
 761        // Fallback to generic runner invocation when the service host is not the dedicated one.
 1762        return BuildRunnerArgumentsForService(scriptPath, scriptArguments, null, moduleManifestPath);
 763    }
 764
 765    /// <summary>
 766    /// Installs a Windows service using sc.exe.
 767    /// </summary>
 768    /// <param name="command">Parsed service command.</param>
 769    /// <param name="serviceName">Resolved service name.</param>
 770    /// <param name="serviceLogPath">Effective service log path.</param>
 771    /// <param name="serviceHostExecutablePath">Service host executable path.</param>
 772    /// <param name="runnerExecutablePath">Runner executable path.</param>
 773    /// <param name="scriptPath">Bundled script path.</param>
 774    /// <param name="moduleManifestPath">Bundled module manifest path.</param>
 775    /// <returns>Process exit code.</returns>
 776    [SupportedOSPlatform("windows")]
 777    private static int InstallWindowsService(
 778        ParsedCommand command,
 779        string serviceName,
 780        string? serviceLogPath,
 781        string serviceHostExecutablePath,
 782        string runnerExecutablePath,
 783        string scriptPath,
 784        string moduleManifestPath)
 785    {
 0786        if (!IsWindowsAdministrator())
 787        {
 0788            var relaunchArgs = BuildWindowsServiceRegisterArguments(command, serviceName, serviceLogPath, serviceHostExe
 0789            return RelaunchElevatedOnWindows(relaunchArgs);
 790        }
 791
 0792        var createResult = CreateWindowsServiceRegistration(
 0793            serviceName,
 0794            Path.GetFullPath(serviceHostExecutablePath),
 0795            Path.GetFullPath(runnerExecutablePath),
 0796            Path.GetFullPath(scriptPath),
 0797            Path.GetFullPath(moduleManifestPath),
 0798            command.ScriptArguments,
 0799            serviceLogPath,
 0800            command.ServiceUser,
 0801            command.ServicePassword);
 802
 0803        if (createResult.ExitCode != 0)
 804        {
 0805            Console.Error.WriteLine(createResult.Error);
 0806            return createResult.ExitCode;
 807        }
 808
 0809        WriteServiceOperationLog($"Service '{serviceName}' install operation completed.", serviceLogPath, serviceName);
 810
 0811        Console.WriteLine($"Installed Windows service '{serviceName}' (not started).");
 0812        return 0;
 813    }
 814
 815    /// <summary>
 816    /// Registers a Windows service using pre-staged runtime/module/script paths.
 817    /// </summary>
 818    /// <param name="options">Parsed service registration options.</param>
 819    /// <returns>Process exit code.</returns>
 820    [SupportedOSPlatform("windows")]
 821    private static int RegisterWindowsService(ServiceRegisterOptions options)
 822    {
 0823        var serviceName = options.ServiceName;
 0824        var createResult = CreateWindowsServiceRegistration(
 0825            serviceName,
 0826            Path.GetFullPath(options.ServiceHostExecutablePath),
 0827            Path.GetFullPath(options.RunnerExecutablePath),
 0828            Path.GetFullPath(options.ScriptPath),
 0829            Path.GetFullPath(options.ModuleManifestPath),
 0830            options.ScriptArguments,
 0831            options.ServiceLogPath,
 0832            options.ServiceUser,
 0833            options.ServicePassword);
 834
 0835        if (createResult.ExitCode != 0)
 836        {
 0837            Console.Error.WriteLine(createResult.Error);
 0838            return createResult.ExitCode;
 839        }
 840
 0841        WriteServiceOperationLog($"Service '{serviceName}' install operation completed.", options.ServiceLogPath, servic
 842
 0843        Console.WriteLine($"Installed Windows service '{serviceName}' (not started).");
 0844        return 0;
 845    }
 846
 847    /// <summary>
 848    /// Creates a Windows service registration using sc.exe.
 849    /// </summary>
 850    /// <param name="serviceName">Service name.</param>
 851    /// <param name="serviceHostExecutablePath">Service-host executable path.</param>
 852    /// <param name="runnerExecutablePath">Runner executable path.</param>
 853    /// <param name="scriptPath">Absolute script path.</param>
 854    /// <param name="moduleManifestPath">Absolute module manifest path.</param>
 855    /// <param name="scriptArguments">Script arguments for service-host mode.</param>
 856    /// <param name="serviceLogPath">Optional service log path.</param>
 857    /// <returns>Process result from sc.exe create.</returns>
 858    private static ProcessResult CreateWindowsServiceRegistration(
 859        string serviceName,
 860        string serviceHostExecutablePath,
 861        string runnerExecutablePath,
 862        string scriptPath,
 863        string moduleManifestPath,
 864        IReadOnlyList<string> scriptArguments,
 865        string? serviceLogPath,
 866        string? serviceUser,
 867        string? servicePassword)
 868    {
 0869        if (!UsesDedicatedServiceHostExecutable(serviceHostExecutablePath))
 870        {
 0871            return new ProcessResult(
 0872                1,
 0873                string.Empty,
 0874                "Service registration now requires the dedicated kestrun-service-host executable. Reinstall or update Ke
 875        }
 876
 0877        var hostArgs = BuildDedicatedServiceHostArguments(serviceName, runnerExecutablePath, scriptPath, moduleManifestP
 878
 0879        var imagePath = BuildWindowsCommandLine(serviceHostExecutablePath, hostArgs);
 0880        var scArgs = new List<string>
 0881        {
 0882            "create",
 0883            serviceName,
 0884            "start=",
 0885            "auto",
 0886            "binPath=",
 0887            imagePath,
 0888            "DisplayName=",
 0889            serviceName,
 0890        };
 891
 0892        if (!string.IsNullOrWhiteSpace(serviceUser))
 893        {
 0894            var normalizedServiceUser = NormalizeWindowsServiceAccountName(serviceUser);
 0895            scArgs.Add("obj=");
 0896            scArgs.Add(normalizedServiceUser);
 897
 898            // Windows built-in service accounts do not require a password.
 0899            if (!IsWindowsBuiltinServiceAccount(normalizedServiceUser) && !string.IsNullOrWhiteSpace(servicePassword))
 900            {
 0901                scArgs.Add("password=");
 0902                scArgs.Add(servicePassword);
 903            }
 904        }
 905
 0906        return RunProcess("sc.exe", scArgs);
 907    }
 908
 909    /// <summary>
 910    /// Normalizes friendly Windows built-in service account aliases to SCM-compatible names.
 911    /// </summary>
 912    /// <param name="serviceUser">Raw service user argument.</param>
 913    /// <returns>Normalized account name for sc.exe registration.</returns>
 914    private static string NormalizeWindowsServiceAccountName(string serviceUser)
 915    {
 5916        var trimmed = serviceUser.Trim();
 917
 5918        return trimmed.ToLowerInvariant() switch
 5919        {
 2920            "networkservice" or "network service" or @"nt authority\networkservice" => @"NT AUTHORITY\NetworkService",
 1921            "localservice" or "local service" or @"nt authority\localservice" => @"NT AUTHORITY\LocalService",
 1922            "localsystem" or "local system" or "system" or @"nt authority\system" => "LocalSystem",
 1923            _ => trimmed,
 5924        };
 925    }
 926
 927    /// <summary>
 928    /// Determines whether an account string refers to a Windows built-in service account.
 929    /// </summary>
 930    /// <param name="accountName">Account name to inspect.</param>
 931    /// <returns>True when account is LocalSystem, NetworkService, or LocalService.</returns>
 932    private static bool IsWindowsBuiltinServiceAccount(string accountName)
 933    {
 4934        return accountName.Equals("LocalSystem", StringComparison.OrdinalIgnoreCase)
 4935            || accountName.Equals(@"NT AUTHORITY\NetworkService", StringComparison.OrdinalIgnoreCase)
 4936            || accountName.Equals(@"NT AUTHORITY\LocalService", StringComparison.OrdinalIgnoreCase);
 937    }
 938
 939    /// <summary>
 940    /// Determines whether a path refers to the dedicated service-host executable.
 941    /// </summary>
 942    /// <param name="executablePath">Executable path.</param>
 943    /// <returns>True when the executable is the dedicated service host.</returns>
 944    private static bool UsesDedicatedServiceHostExecutable(string executablePath)
 945    {
 5946        var fileName = Path.GetFileNameWithoutExtension(executablePath);
 5947        return string.Equals(fileName, "kestrun-service-host", StringComparison.OrdinalIgnoreCase)
 5948            || string.Equals(fileName, "Kestrun.ServiceHost", StringComparison.OrdinalIgnoreCase);
 949    }
 950
 951    /// <summary>
 952    /// Builds elevated relaunch arguments for internal Windows service registration.
 953    /// </summary>
 954    /// <param name="command">Parsed service command.</param>
 955    /// <param name="serviceName">Resolved service name.</param>
 956    /// <param name="serviceLogPath">Effective service log path.</param>
 957    /// <param name="executablePath">Executable path.</param>
 958    /// <param name="scriptPath">Absolute script path.</param>
 959    /// <param name="moduleManifestPath">Manifest path staged for service runtime.</param>
 960    /// <returns>Ordered argument tokens.</returns>
 961    private static IReadOnlyList<string> BuildWindowsServiceRegisterArguments(
 962        ParsedCommand command,
 963        string serviceName,
 964        string? serviceLogPath,
 965        string serviceHostExecutablePath,
 966        string runnerExecutablePath,
 967        string scriptPath,
 968        string moduleManifestPath)
 969    {
 0970        var arguments = new List<string>(16 + command.ScriptArguments.Length)
 0971        {
 0972            "--service-register",
 0973            "--name",
 0974            serviceName,
 0975            "--service-host-exe",
 0976            Path.GetFullPath(serviceHostExecutablePath),
 0977            "--runner-exe",
 0978            Path.GetFullPath(runnerExecutablePath),
 0979            "--script",
 0980            Path.GetFullPath(scriptPath),
 0981            "--kestrun-manifest",
 0982            Path.GetFullPath(moduleManifestPath),
 0983        };
 984
 0985        if (!string.IsNullOrWhiteSpace(serviceLogPath))
 986        {
 0987            arguments.Add("--service-log-path");
 0988            arguments.Add(Path.GetFullPath(serviceLogPath));
 989        }
 990
 0991        if (!string.IsNullOrWhiteSpace(command.ServiceUser))
 992        {
 0993            arguments.Add("--service-user");
 0994            arguments.Add(command.ServiceUser);
 995        }
 996
 0997        if (!string.IsNullOrWhiteSpace(command.ServicePassword))
 998        {
 0999            arguments.Add("--service-password");
 01000            arguments.Add(command.ServicePassword);
 1001        }
 1002
 01003        if (command.ScriptArguments.Length > 0)
 1004        {
 01005            arguments.Add("--arguments");
 01006            arguments.AddRange(command.ScriptArguments);
 1007        }
 1008
 01009        return arguments;
 1010    }
 1011
 1012    /// <summary>
 1013    /// Builds arguments for the dedicated service-host executable.
 1014    /// </summary>
 1015    /// <param name="serviceName">Service name.</param>
 1016    /// <param name="runnerExecutablePath">Runner executable path.</param>
 1017    /// <param name="scriptPath">Absolute script path.</param>
 1018    /// <param name="moduleManifestPath">Manifest path staged for service runtime.</param>
 1019    /// <param name="scriptArguments">Script arguments forwarded to run mode.</param>
 1020    /// <param name="serviceLogPath">Optional service log path.</param>
 1021    /// <returns>Ordered argument tokens.</returns>
 1022    private static IReadOnlyList<string> BuildDedicatedServiceHostArguments(
 1023        string serviceName,
 1024        string runnerExecutablePath,
 1025        string scriptPath,
 1026        string moduleManifestPath,
 1027        IReadOnlyList<string> scriptArguments,
 1028        string? serviceLogPath)
 1029    {
 21030        var arguments = new List<string>(14 + scriptArguments.Count)
 21031        {
 21032            "--name",
 21033            serviceName,
 21034            "--runner-exe",
 21035            Path.GetFullPath(runnerExecutablePath),
 21036            "--script",
 21037            scriptPath,
 21038            "--kestrun-manifest",
 21039            Path.GetFullPath(moduleManifestPath),
 21040        };
 1041
 21042        if (!string.IsNullOrWhiteSpace(serviceLogPath))
 1043        {
 11044            arguments.Add("--service-log-path");
 11045            arguments.Add(Path.GetFullPath(serviceLogPath));
 1046        }
 1047
 21048        if (scriptArguments.Count > 0)
 1049        {
 01050            arguments.Add("--arguments");
 01051            arguments.AddRange(scriptArguments);
 1052        }
 1053
 21054        return arguments;
 1055    }
 1056
 1057    /// <summary>
 1058    /// Removes a Windows service using sc.exe.
 1059    /// </summary>
 1060    /// <param name="command">Parsed service command.</param>
 1061    /// <returns>Process exit code.</returns>
 1062    private static int RemoveWindowsService(ParsedCommand command)
 1063    {
 01064        var operationLogPath = ResolveServiceOperationLogPath(command.ServiceLogPath, command.ServiceName);
 1065
 01066        var stopResult = RunProcess("sc.exe", ["stop", command.ServiceName!], writeStandardOutput: false);
 01067        if (stopResult.ExitCode != 0 && !IsWindowsServiceAlreadyStopped(stopResult))
 1068        {
 01069            WriteServiceOperationLog(
 01070                $"Service '{command.ServiceName}' stop-before-delete returned exitCode={stopResult.ExitCode} error='{sto
 01071                operationLogPath,
 01072                command.ServiceName);
 1073        }
 01074        else if (!WaitForWindowsServiceToStopOrDisappear(command.ServiceName!, timeoutMs: 15000))
 1075        {
 01076            WriteServiceOperationLog(
 01077                $"Service '{command.ServiceName}' did not reach STOPPED/deleted state before delete attempt.",
 01078                operationLogPath,
 01079                command.ServiceName);
 1080        }
 1081
 01082        var deleteResult = RunProcess("sc.exe", ["delete", command.ServiceName!]);
 01083        if (deleteResult.ExitCode != 0)
 1084        {
 01085            Console.Error.WriteLine(deleteResult.Error);
 01086            return deleteResult.ExitCode;
 1087        }
 1088
 01089        WriteServiceOperationLog($"Service '{command.ServiceName}' remove operation completed.", operationLogPath, comma
 1090
 01091        Console.WriteLine($"Removed Windows service '{command.ServiceName}'.");
 01092        return 0;
 1093    }
 1094
 1095    /// <summary>
 1096    /// Waits until a Windows service reaches STOPPED state or is no longer present in SCM.
 1097    /// </summary>
 1098    /// <param name="serviceName">Service name.</param>
 1099    /// <param name="timeoutMs">Maximum wait time in milliseconds.</param>
 1100    /// <param name="pollIntervalMs">Polling interval in milliseconds.</param>
 1101    /// <returns>True when the service is stopped or deleted before timeout.</returns>
 1102    private static bool WaitForWindowsServiceToStopOrDisappear(string serviceName, int timeoutMs = 15000, int pollInterv
 1103    {
 01104        var timeout = TimeSpan.FromMilliseconds(Math.Max(timeoutMs, pollIntervalMs));
 01105        var deadline = DateTime.UtcNow + timeout;
 1106
 01107        while (DateTime.UtcNow <= deadline)
 1108        {
 01109            var queryResult = RunProcess("sc.exe", ["query", serviceName], writeStandardOutput: false);
 01110            var diagnostics = $"{queryResult.Output}\n{queryResult.Error}";
 1111
 01112            if (queryResult.ExitCode == 0)
 1113            {
 01114                if (diagnostics.Contains("STOPPED", StringComparison.OrdinalIgnoreCase))
 1115                {
 01116                    return true;
 1117                }
 1118            }
 01119            else if (diagnostics.Contains("1060", StringComparison.OrdinalIgnoreCase)
 01120                || diagnostics.Contains("does not exist", StringComparison.OrdinalIgnoreCase))
 1121            {
 01122                return true;
 1123            }
 1124
 01125            Thread.Sleep(pollIntervalMs);
 1126        }
 1127
 01128        return false;
 1129    }
 1130
 1131    /// <summary>
 1132    /// Writes an install/remove operation log line for Windows service lifecycle operations.
 1133    /// </summary>
 1134    /// <param name="message">Operation message to append.</param>
 1135    /// <param name="configuredPath">Optional configured log path.</param>
 1136    /// <param name="serviceName">Optional service name used to discover configured log path.</param>
 1137    private static void WriteServiceOperationLog(string message, string? configuredPath, string? serviceName)
 1138    {
 71139        var logPath = ResolveServiceOperationLogPath(configuredPath, serviceName);
 1140        try
 1141        {
 71142            var directory = Path.GetDirectoryName(logPath);
 71143            if (!string.IsNullOrWhiteSpace(directory))
 1144            {
 71145                _ = Directory.CreateDirectory(directory);
 1146            }
 1147
 71148            var line = $"{DateTime.UtcNow:O} {message}{Environment.NewLine}";
 71149            File.AppendAllText(logPath, line, Encoding.UTF8);
 71150        }
 01151        catch
 1152        {
 1153            // Best-effort operation logging only.
 01154        }
 71155    }
 1156
 1157    /// <summary>
 1158    /// Writes a standardized service operation result entry.
 1159    /// </summary>
 1160    /// <param name="operation">Operation name (install/start/stop/query/remove).</param>
 1161    /// <param name="platform">Platform label.</param>
 1162    /// <param name="serviceName">Service name.</param>
 1163    /// <param name="exitCode">Process exit code.</param>
 1164    /// <param name="configuredPath">Optional configured log path.</param>
 1165    private static void WriteServiceOperationResult(string operation, string platform, string serviceName, int exitCode,
 1166    {
 11167        var result = exitCode == 0 ? "success" : "failed";
 11168        WriteServiceOperationLog(
 11169            $"operation='{operation}' service='{serviceName}' platform='{platform}' result='{result}' exitCode={exitCode
 11170            configuredPath,
 11171            serviceName);
 11172    }
 1173
 1174    /// <summary>
 1175    /// Resolves the service operation log path from user input, service config, or defaults.
 1176    /// </summary>
 1177    /// <param name="configuredPath">Optional configured log path.</param>
 1178    /// <param name="serviceName">Optional service name for discovery from service config.</param>
 1179    /// <returns>Absolute log file path.</returns>
 1180    private static string ResolveServiceOperationLogPath(string? configuredPath, string? serviceName)
 1181    {
 71182        var defaultFileName = "kestrun-tool-service.log";
 71183        var defaultPath = RunnerRuntime.ResolveBootstrapLogPath(null, defaultFileName);
 1184
 71185        if (!string.IsNullOrWhiteSpace(configuredPath))
 1186        {
 41187            return NormalizeServiceLogPath(configuredPath, defaultFileName);
 1188        }
 1189        // When no explicit path is configured, attempt to discover a --service-log-path from the service config for bet
 31190        return OperatingSystem.IsWindows() && !string.IsNullOrWhiteSpace(serviceName)
 31191            && TryGetWindowsServiceLogPath(serviceName, out var discoveredPath)
 31192            && !string.IsNullOrWhiteSpace(discoveredPath)
 31193            ? discoveredPath
 31194            : defaultPath;
 1195    }
 1196
 1197    /// <summary>
 1198    /// Tries to read the configured --service-log-path from a Windows service ImagePath.
 1199    /// </summary>
 1200    /// <param name="serviceName">Service name.</param>
 1201    /// <param name="logPath">Discovered log path.</param>
 1202    /// <returns>True when a log path could be discovered.</returns>
 1203    private static bool TryGetWindowsServiceLogPath(string serviceName, out string? logPath)
 1204    {
 01205        logPath = null;
 01206        var queryResult = RunProcess("sc.exe", ["qc", serviceName], writeStandardOutput: false);
 01207        if (queryResult.ExitCode != 0)
 1208        {
 01209            return false;
 1210        }
 1211
 01212        var text = string.Concat(queryResult.Output, Environment.NewLine, queryResult.Error);
 01213        var match = ServiceLogPathRegex().Match(text);
 1214
 01215        if (!match.Success)
 1216        {
 01217            return false;
 1218        }
 1219
 01220        var rawPath = match.Groups["quoted"].Success
 01221            ? match.Groups["quoted"].Value
 01222            : match.Groups["plain"].Value;
 1223
 01224        if (string.IsNullOrWhiteSpace(rawPath))
 1225        {
 01226            return false;
 1227        }
 1228
 01229        logPath = NormalizeServiceLogPath(rawPath, defaultFileName: "kestrun-tool-service.log");
 01230        return true;
 1231    }
 1232
 1233    /// <summary>
 1234    /// Resolves the runtime executable path from a Kestrun module manifest directory.
 1235    /// </summary>
 1236    /// <param name="moduleManifestPath">Path to Kestrun.psd1.</param>
 1237    /// <param name="runtimeExecutablePath">Resolved runtime executable path.</param>
 1238    /// <param name="error">Error details when resolution fails.</param>
 1239    /// <returns>True when a runtime executable is available for the current OS/architecture.</returns>
 1240    private static bool TryResolveServiceRuntimeExecutableFromModule(string moduleManifestPath, out string runtimeExecut
 1241    {
 61242        runtimeExecutablePath = string.Empty;
 61243        error = string.Empty;
 1244
 61245        var fullManifestPath = Path.GetFullPath(moduleManifestPath);
 61246        var moduleRoot = Path.GetDirectoryName(fullManifestPath);
 61247        if (string.IsNullOrWhiteSpace(moduleRoot) || !Directory.Exists(moduleRoot))
 1248        {
 01249            error = $"Unable to resolve module root from manifest path: {fullManifestPath}";
 01250            return false;
 1251        }
 1252
 61253        if (!TryGetServiceRuntimeRid(out var runtimeRid, out var ridError))
 1254        {
 01255            error = ridError;
 01256            return false;
 1257        }
 1258
 61259        var runtimeBinaryName = OperatingSystem.IsWindows() ? WindowsServiceRuntimeBinaryName : UnixServiceRuntimeBinary
 261260        foreach (var candidate in EnumerateServiceRuntimeExecutableCandidates(moduleRoot, runtimeRid, runtimeBinaryName)
 1261        {
 101262            if (!File.Exists(candidate))
 1263            {
 1264                continue;
 1265            }
 1266
 61267            runtimeExecutablePath = Path.GetFullPath(candidate);
 61268            return true;
 1269        }
 1270
 1271        // Module distributions may intentionally omit tool runtime binaries.
 1272        // In that case, reuse the dedicated service-host payload as the runner executable.
 01273        if (TryResolveDedicatedServiceHostExecutableFromToolDistribution(out var serviceHostExecutablePath))
 1274        {
 01275            runtimeExecutablePath = serviceHostExecutablePath;
 01276            return true;
 1277        }
 1278
 1279        // Final fallback for non-standard layouts: use the current process executable path when available.
 01280        if (!string.IsNullOrWhiteSpace(Environment.ProcessPath) && File.Exists(Environment.ProcessPath))
 1281        {
 01282            runtimeExecutablePath = Path.GetFullPath(Environment.ProcessPath);
 01283            return true;
 1284        }
 1285
 01286        error = $"Unable to locate service runner executable for '{runtimeRid}'. Checked module path '{moduleRoot}', fal
 01287        return false;
 61288    }
 1289
 1290    /// <summary>
 1291    /// Enumerates candidate runtime executable paths for service bundle staging.
 1292    /// </summary>
 1293    /// <param name="moduleRoot">Resolved module root path.</param>
 1294    /// <param name="runtimeRid">Runtime identifier segment (for example, win-x64).</param>
 1295    /// <param name="runtimeBinaryName">Runtime executable file name.</param>
 1296    /// <returns>Candidate runtime executable paths in resolution priority order.</returns>
 1297    private static IEnumerable<string> EnumerateServiceRuntimeExecutableCandidates(string moduleRoot, string runtimeRid,
 1298    {
 61299        var candidates = new List<string>
 61300        {
 61301            Path.Combine(moduleRoot, "runtimes", runtimeRid, runtimeBinaryName),
 61302            Path.Combine(moduleRoot, "lib", "runtimes", runtimeRid, runtimeBinaryName),
 61303            Path.Combine(GetExecutableDirectory(), "runtimes", runtimeRid, runtimeBinaryName),
 61304            Path.Combine(GetExecutableDirectory(), "lib", "runtimes", runtimeRid, runtimeBinaryName),
 61305        };
 1306
 61307        var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory);
 61308        var executableDirectory = GetExecutableDirectory();
 61309        if (!string.Equals(baseDirectory, executableDirectory, StringComparison.OrdinalIgnoreCase))
 1310        {
 61311            candidates.Add(Path.Combine(baseDirectory, "runtimes", runtimeRid, runtimeBinaryName));
 61312            candidates.Add(Path.Combine(baseDirectory, "lib", "runtimes", runtimeRid, runtimeBinaryName));
 1313        }
 1314
 841315        foreach (var parent in EnumerateDirectoryAndParents(Environment.CurrentDirectory))
 1316        {
 361317            candidates.Add(Path.Combine(parent, "src", "PowerShell", "Kestrun", "runtimes", runtimeRid, runtimeBinaryNam
 361318            candidates.Add(Path.Combine(parent, "src", "PowerShell", "Kestrun", "lib", "runtimes", runtimeRid, runtimeBi
 1319        }
 1320
 261321        foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
 1322        {
 101323            yield return candidate;
 1324        }
 01325    }
 1326
 1327    /// <summary>
 1328    /// Tries to resolve a dedicated service-host executable path from the Kestrun.Tool distribution.
 1329    /// </summary>
 1330    /// <param name="serviceHostExecutablePath">Resolved service-host executable path when available.</param>
 1331    /// <returns>True when a dedicated service-host executable is available.</returns>
 1332    private static bool TryResolveDedicatedServiceHostExecutableFromToolDistribution(out string serviceHostExecutablePat
 1333    {
 51334        serviceHostExecutablePath = string.Empty;
 51335        if (!TryGetServiceRuntimeRid(out var runtimeRid, out _))
 1336        {
 01337            return false;
 1338        }
 1339
 51340        var hostBinaryName = OperatingSystem.IsWindows() ? "kestrun-service-host.exe" : "kestrun-service-host";
 391341        foreach (var candidate in EnumerateDedicatedServiceHostCandidates(runtimeRid, hostBinaryName))
 1342        {
 171343            if (!File.Exists(candidate))
 1344            {
 1345                continue;
 1346            }
 1347
 51348            serviceHostExecutablePath = Path.GetFullPath(candidate);
 51349            return true;
 1350        }
 1351
 01352        return false;
 51353    }
 1354
 1355    /// <summary>
 1356    /// Tries to resolve bundled PowerShell Modules payload from the Kestrun.Tool distribution.
 1357    /// </summary>
 1358    /// <param name="modulesPayloadPath">Resolved modules payload path when available.</param>
 1359    /// <returns>True when the modules payload is available.</returns>
 1360    private static bool TryResolvePowerShellModulesPayloadFromToolDistribution(out string modulesPayloadPath)
 1361    {
 31362        modulesPayloadPath = string.Empty;
 31363        if (!TryGetServiceRuntimeRid(out var runtimeRid, out _))
 1364        {
 01365            return false;
 1366        }
 1367
 271368        foreach (var candidate in EnumeratePowerShellModulesPayloadCandidates(runtimeRid))
 1369        {
 121370            if (!Directory.Exists(candidate))
 1371            {
 1372                continue;
 1373            }
 1374
 31375            modulesPayloadPath = Path.GetFullPath(candidate);
 31376            return true;
 1377        }
 1378
 01379        return false;
 31380    }
 1381
 1382    /// <summary>
 1383    /// Enumerates candidate service-host paths shipped with Kestrun.Tool for the target RID.
 1384    /// </summary>
 1385    /// <param name="runtimeRid">Runtime identifier segment (for example, win-x64).</param>
 1386    /// <param name="hostBinaryName">Service-host executable file name.</param>
 1387    /// <returns>Candidate service-host paths in resolution priority order.</returns>
 1388    private static IEnumerable<string> EnumerateDedicatedServiceHostCandidates(string runtimeRid, string hostBinaryName)
 1389    {
 51390        var candidates = new List<string>();
 1391
 1392        // 1. Development/source path: ./src/CSharp/Kestrun.Tool/kestrun-service/<rid>/
 1393        // Prefer this during local builds/tests to avoid accidentally selecting a stale globally installed tool payload
 761394        foreach (var parent in EnumerateDirectoryAndParents(Environment.CurrentDirectory))
 1395        {
 331396            candidates.Add(Path.Combine(parent, "src", "CSharp", "Kestrun.Tool", "kestrun-service", runtimeRid, hostBina
 1397        }
 1398
 1399        // 2. Dotnet tool store path: ~/.dotnet/tools/.store/kestrun.tool/<version>/
 51400        var homeDirectory = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 51401        if (!string.IsNullOrWhiteSpace(homeDirectory))
 1402        {
 51403            var toolStorePath = Path.Combine(homeDirectory, ".dotnet", "tools", ".store", "kestrun.tool");
 51404            if (Directory.Exists(toolStorePath))
 1405            {
 201406                foreach (var versionDir in Directory.GetDirectories(toolStorePath))
 1407                {
 51408                    candidates.Add(Path.Combine(versionDir, "kestrun.tool", Path.GetFileName(versionDir), "tools", "net1
 1409                }
 1410            }
 1411        }
 1412
 391413        foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
 1414        {
 171415            yield return candidate;
 1416        }
 01417    }
 1418
 1419    /// <summary>
 1420    /// Enumerates candidate PowerShell Modules payload paths shipped with Kestrun.Tool for the target RID.
 1421    /// </summary>
 1422    /// <param name="runtimeRid">Runtime identifier segment (for example, win-x64).</param>
 1423    /// <returns>Candidate modules payload paths in resolution priority order.</returns>
 1424    private static IEnumerable<string> EnumeratePowerShellModulesPayloadCandidates(string runtimeRid)
 1425    {
 31426        var executableDirectory = GetExecutableDirectory();
 31427        var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory);
 31428        var candidates = new List<string>
 31429        {
 31430            Path.Combine(executableDirectory, "kestrun-service", runtimeRid, "Modules"),
 31431            Path.Combine(baseDirectory, "kestrun-service", runtimeRid, "Modules"),
 31432            Path.Combine(executableDirectory, runtimeRid, "Modules"),
 31433            Path.Combine(baseDirectory, runtimeRid, "Modules"),
 31434            Path.Combine(executableDirectory, "runtimes", runtimeRid, "Modules"),
 31435            Path.Combine(baseDirectory, "runtimes", runtimeRid, "Modules"),
 31436        };
 1437
 241438        foreach (var parent in EnumerateDirectoryAndParents(Environment.CurrentDirectory))
 1439        {
 91440            candidates.Add(Path.Combine(parent, "src", "CSharp", "Kestrun.Tool", "kestrun-service", runtimeRid, "Modules
 1441        }
 1442
 271443        foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
 1444        {
 121445            yield return candidate;
 1446        }
 01447    }
 1448
 1449    /// <summary>
 1450    /// Enumerates a directory and all parents up to the filesystem root.
 1451    /// </summary>
 1452    /// <param name="startDirectory">Starting directory path.</param>
 1453    /// <returns>Normalized directory path sequence from leaf to root.</returns>
 1454    private static IEnumerable<string> EnumerateDirectoryAndParents(string startDirectory)
 1455    {
 161456        var current = Path.GetFullPath(startDirectory);
 801457        while (!string.IsNullOrWhiteSpace(current))
 1458        {
 801459            yield return current;
 1460
 781461            var parent = Directory.GetParent(current);
 781462            if (parent is null)
 1463            {
 1464                break;
 1465            }
 1466
 641467            current = parent.FullName;
 1468        }
 141469    }
 1470
 1471    /// <summary>
 1472    /// Resolves service-install script source, including optional content-root semantics.
 1473    /// </summary>
 1474    /// <param name="command">Parsed service command.</param>
 1475    /// <param name="scriptSource">Resolved script source details.</param>
 1476    /// <param name="error">Error details when validation fails.</param>
 1477    /// <returns>True when script source is valid and exists.</returns>
 1478    private static bool TryResolveServiceScriptSource(ParsedCommand command, out ResolvedServiceScriptSource scriptSourc
 1479    {
 211480        var optionFlags = GetServiceContentRootOptionFlags(command);
 211481        if (!TryClassifyServiceContentRoot(command.ServiceContentRoot, out _, out var contentRootUri, out var fullConten
 1482        {
 11483            var fallbackScriptPath = ResolveRequestedServiceScriptPath(command.ScriptPath, useDefaultWhenMissing: true);
 11484            return TryResolveServiceScriptWithoutContentRoot(fallbackScriptPath, optionFlags, out scriptSource, out erro
 1485        }
 1486
 201487        if (command.Mode == CommandMode.ServiceInstall && command.ServiceNameProvided)
 1488        {
 01489            scriptSource = CreateEmptyResolvedServiceScriptSource();
 01490            error = "--name is no longer supported for service install. Define Name in Service.psd1 inside the package."
 01491            return false;
 1492        }
 1493
 201494        if (command.ScriptPathProvided)
 1495        {
 01496            scriptSource = CreateEmptyResolvedServiceScriptSource();
 01497            error = "An explicit script path is not supported when --package is used. Define EntryPoint in Service.psd1 
 01498            return false;
 1499        }
 1500
 201501        var requestedScriptPath = ResolveRequestedServiceScriptPath(command.ScriptPath, useDefaultWhenMissing: false);
 1502        // When a content-root value is supplied, attempt to resolve the script source from the content root, even if th
 1503        // since some content-root scenarios imply a default script name.
 201504        return TryResolveServiceScriptFromContentRoot(
 201505            command,
 201506            requestedScriptPath,
 201507            contentRootUri,
 201508            fullContentRoot,
 201509            optionFlags,
 201510            out scriptSource,
 201511            out error);
 1512    }
 1513
 1514    /// <summary>
 1515    /// Classifies service content-root input into normalized local path or HTTP(S) URI forms.
 1516    /// </summary>
 1517    /// <param name="rawContentRoot">Raw content-root token from CLI arguments.</param>
 1518    /// <param name="normalizedContentRoot">Trimmed content-root token.</param>
 1519    /// <param name="contentRootUri">Parsed HTTP(S) URI when the content root is remote; otherwise null.</param>
 1520    /// <param name="fullContentRoot">Normalized absolute local path when the content root is local; otherwise empty.</p
 1521    /// <returns>True when a content-root value exists; false when no content-root was supplied.</returns>
 1522    private static bool TryClassifyServiceContentRoot(
 1523        string? rawContentRoot,
 1524        out string normalizedContentRoot,
 1525        out Uri? contentRootUri,
 1526        out string fullContentRoot)
 1527    {
 231528        normalizedContentRoot = string.Empty;
 231529        contentRootUri = null;
 231530        fullContentRoot = string.Empty;
 1531
 231532        if (string.IsNullOrWhiteSpace(rawContentRoot))
 1533        {
 21534            return false;
 1535        }
 1536
 211537        normalizedContentRoot = rawContentRoot.Trim();
 211538        if (TryParseServiceContentRootHttpUri(normalizedContentRoot, out var parsedUri))
 1539        {
 71540            contentRootUri = parsedUri;
 71541            return true;
 1542        }
 1543
 141544        fullContentRoot = Path.GetFullPath(normalizedContentRoot);
 141545        return true;
 1546    }
 1547
 1548    /// <summary>
 1549    /// Resolves service script source for a classified content-root input.
 1550    /// </summary>
 1551    /// <param name="command">Parsed service command.</param>
 1552    /// <param name="requestedScriptPath">Requested script path argument.</param>
 1553    /// <param name="contentRootUri">Parsed HTTP(S) content-root URI when applicable.</param>
 1554    /// <param name="fullContentRoot">Absolute local content-root path when applicable.</param>
 1555    /// <param name="optionFlags">Content-root related option usage flags.</param>
 1556    /// <param name="scriptSource">Resolved script source details.</param>
 1557    /// <param name="error">Error details when resolution fails.</param>
 1558    /// <returns>True when script source resolution succeeds.</returns>
 1559    private static bool TryResolveServiceScriptFromContentRoot(
 1560        ParsedCommand command,
 1561        string requestedScriptPath,
 1562        Uri? contentRootUri,
 1563        string fullContentRoot,
 1564        ServiceContentRootOptionFlags optionFlags,
 1565        out ResolvedServiceScriptSource scriptSource,
 1566        out string error)
 1567    {
 201568        if (contentRootUri is not null)
 1569        {
 61570            return TryResolveServiceScriptFromHttpContentRoot(command, requestedScriptPath, contentRootUri, out scriptSo
 1571        }
 1572
 141573        if (Directory.Exists(fullContentRoot))
 1574        {
 61575            return TryResolveServiceScriptFromDirectoryContentRoot(requestedScriptPath, fullContentRoot, optionFlags, ou
 1576        }
 1577        // When content-root is supplied but does not exist as a local directory, attempt to treat it as an archive path
 1578        // to a file without realizing that only directories are supported for local content roots.
 81579        return TryResolveServiceScriptFromArchiveContentRoot(command, requestedScriptPath, fullContentRoot, optionFlags,
 1580    }
 1581
 1582    /// <summary>
 1583    /// Represents parsed option usage for content-root related arguments.
 1584    /// </summary>
 1585    private readonly record struct ServiceContentRootOptionFlags(
 71586        bool HasArchiveChecksum,
 141587        bool HasBearerToken,
 141588        bool IgnoreCertificate,
 131589        bool HasHeaders);
 1590
 1591    /// <summary>
 1592    /// Resolves the script path value for service install commands, applying default script fallback.
 1593    /// </summary>
 1594    /// <param name="scriptPath">Requested script path argument.</param>
 1595    /// <param name="useDefaultWhenMissing">True to apply the default script file name when no script path was provided.
 1596    /// <returns>Resolved script path token.</returns>
 1597    private static string ResolveRequestedServiceScriptPath(string scriptPath, bool useDefaultWhenMissing)
 211598        => string.IsNullOrWhiteSpace(scriptPath)
 211599            ? (useDefaultWhenMissing ? ServiceDefaultScriptFileName : string.Empty)
 211600            : scriptPath;
 1601
 1602    /// <summary>
 1603    /// Captures which content-root related options were supplied on the command line.
 1604    /// </summary>
 1605    /// <param name="command">Parsed service command.</param>
 1606    /// <returns>Normalized option usage flags.</returns>
 1607    private static ServiceContentRootOptionFlags GetServiceContentRootOptionFlags(ParsedCommand command)
 211608        => new(
 211609            !string.IsNullOrWhiteSpace(command.ServiceContentRootChecksum),
 211610            !string.IsNullOrWhiteSpace(command.ServiceContentRootBearerToken),
 211611            command.ServiceContentRootIgnoreCertificate,
 211612            command.ServiceContentRootHeaders.Length > 0);
 1613
 1614    /// <summary>
 1615    /// Resolves script source when no content-root argument is provided.
 1616    /// </summary>
 1617    /// <param name="requestedScriptPath">Requested script path.</param>
 1618    /// <param name="optionFlags">Content-root related option flags.</param>
 1619    /// <param name="scriptSource">Resolved script source details.</param>
 1620    /// <param name="error">Error details when validation fails.</param>
 1621    /// <returns>True when script resolution succeeds.</returns>
 1622    private static bool TryResolveServiceScriptWithoutContentRoot(
 1623        string requestedScriptPath,
 1624        ServiceContentRootOptionFlags optionFlags,
 1625        out ResolvedServiceScriptSource scriptSource,
 1626        out string error)
 1627    {
 11628        scriptSource = CreateEmptyResolvedServiceScriptSource();
 11629        if (!TryValidateOptionsForMissingContentRoot(optionFlags, out error))
 1630        {
 01631            return false;
 1632        }
 1633
 11634        var fullScriptPath = Path.GetFullPath(requestedScriptPath);
 11635        if (!File.Exists(fullScriptPath))
 1636        {
 01637            error = $"Script file not found: {fullScriptPath}";
 01638            return false;
 1639        }
 1640
 11641        scriptSource = new ResolvedServiceScriptSource(fullScriptPath, null, Path.GetFileName(fullScriptPath), null, nul
 11642        error = string.Empty;
 11643        return true;
 1644    }
 1645
 1646    /// <summary>
 1647    /// Resolves script source when content-root points to an HTTP(S) archive.
 1648    /// </summary>
 1649    /// <param name="command">Parsed service command.</param>
 1650    /// <param name="requestedScriptPath">Requested script path.</param>
 1651    /// <param name="contentRootUri">HTTP(S) archive URI.</param>
 1652    /// <param name="optionFlags">Content-root related option flags.</param>
 1653    /// <param name="scriptSource">Resolved script source details.</param>
 1654    /// <param name="error">Error details when validation fails.</param>
 1655    /// <returns>True when script resolution succeeds.</returns>
 1656    private static bool TryResolveServiceScriptFromHttpContentRoot(
 1657        ParsedCommand command,
 1658        string requestedScriptPath,
 1659        Uri contentRootUri,
 1660        out ResolvedServiceScriptSource scriptSource,
 1661        out string error)
 1662    {
 61663        scriptSource = CreateEmptyResolvedServiceScriptSource();
 61664        if (!TryValidateHttpContentRootScriptPath(requestedScriptPath, out error))
 1665        {
 01666            return false;
 1667        }
 1668
 61669        var temporaryRoot = CreateServiceContentRootExtractionDirectory(command.ServiceName);
 61670        var downloadedContentRoot = Path.Combine(temporaryRoot, "content");
 61671        _ = Directory.CreateDirectory(downloadedContentRoot);
 1672
 1673        try
 1674        {
 61675            if (!TryDownloadAndExtractHttpContentRoot(command, contentRootUri, temporaryRoot, downloadedContentRoot, out
 1676            {
 31677                TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50);
 31678                return false;
 1679            }
 1680
 31681            if (!TryResolveServiceInstallDescriptor(downloadedContentRoot, out var descriptor, out error))
 1682            {
 01683                TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50);
 01684                return false;
 1685            }
 1686
 31687            if (!TryResolveServiceDescriptorScriptPath(requestedScriptPath, descriptor, out var resolvedScriptPath, out 
 1688            {
 01689                TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50);
 01690                return false;
 1691            }
 1692
 31693            if (!TryResolveScriptFromResolvedContentRoot(
 31694                    resolvedScriptPath,
 31695                    downloadedContentRoot,
 31696                    $"Script path '{resolvedScriptPath}' escapes the extracted archive content root.",
 31697                    $"Script file '{resolvedScriptPath}' was not found inside extracted archive downloaded from '{conten
 31698                    temporaryRoot,
 31699                    out scriptSource,
 31700                    out error))
 1701            {
 01702                TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50);
 01703                return false;
 1704            }
 1705
 31706            scriptSource = ApplyDescriptorMetadata(scriptSource, descriptor);
 1707
 31708            return true;
 1709        }
 01710        catch
 1711        {
 01712            TryDeleteDirectoryWithRetry(temporaryRoot, maxAttempts: 5, initialDelayMs: 50);
 01713            throw;
 1714        }
 61715    }
 1716
 1717    /// <summary>
 1718    /// Resolves script source when content-root points to a local directory.
 1719    /// </summary>
 1720    /// <param name="requestedScriptPath">Requested script path.</param>
 1721    /// <param name="fullContentRoot">Absolute content-root directory path.</param>
 1722    /// <param name="optionFlags">Content-root related option flags.</param>
 1723    /// <param name="scriptSource">Resolved script source details.</param>
 1724    /// <param name="error">Error details when validation fails.</param>
 1725    /// <returns>True when script resolution succeeds.</returns>
 1726    private static bool TryResolveServiceScriptFromDirectoryContentRoot(
 1727        string requestedScriptPath,
 1728        string fullContentRoot,
 1729        ServiceContentRootOptionFlags optionFlags,
 1730        out ResolvedServiceScriptSource scriptSource,
 1731        out string error)
 1732    {
 61733        scriptSource = CreateEmptyResolvedServiceScriptSource();
 61734        if (!TryValidateDirectoryContentRootOptions(optionFlags, out error))
 1735        {
 01736            return false;
 1737        }
 1738
 61739        if (!TryResolveServiceInstallDescriptor(fullContentRoot, out var descriptor, out error))
 1740        {
 21741            return false;
 1742        }
 1743
 41744        if (!TryResolveServiceDescriptorScriptPath(requestedScriptPath, descriptor, out var resolvedScriptPath, out erro
 1745        {
 11746            return false;
 1747        }
 1748
 31749        if (!TryResolveScriptFromResolvedContentRoot(
 31750            resolvedScriptPath,
 31751            fullContentRoot,
 31752            $"Script path '{resolvedScriptPath}' escapes the service content root '{fullContentRoot}'.",
 31753            $"Script file '{resolvedScriptPath}' was not found under service content root '{fullContentRoot}'.",
 31754            null,
 31755            out scriptSource,
 31756            out error))
 1757        {
 01758            return false;
 1759        }
 1760
 31761        scriptSource = ApplyDescriptorMetadata(scriptSource, descriptor);
 31762        return true;
 1763    }
 1764
 1765    /// <summary>
 1766    /// Resolves script source when content-root points to a local archive file.
 1767    /// </summary>
 1768    /// <param name="command">Parsed service command.</param>
 1769    /// <param name="requestedScriptPath">Requested script path.</param>
 1770    /// <param name="fullContentRoot">Absolute archive path.</param>
 1771    /// <param name="optionFlags">Content-root related option flags.</param>
 1772    /// <param name="scriptSource">Resolved script source details.</param>
 1773    /// <param name="error">Error details when validation fails.</param>
 1774    /// <returns>True when script resolution succeeds.</returns>
 1775    private static bool TryResolveServiceScriptFromArchiveContentRoot(
 1776        ParsedCommand command,
 1777        string requestedScriptPath,
 1778        string fullContentRoot,
 1779        ServiceContentRootOptionFlags optionFlags,
 1780        out ResolvedServiceScriptSource scriptSource,
 1781        out string error)
 1782    {
 81783        scriptSource = CreateEmptyResolvedServiceScriptSource();
 81784        if (!File.Exists(fullContentRoot))
 1785        {
 11786            error = $"Service content root path was not found: {fullContentRoot}";
 11787            return false;
 1788        }
 1789
 71790        if (!TryValidateLocalArchiveContentRootOptions(optionFlags, out error))
 1791        {
 21792            return false;
 1793        }
 1794
 51795        if (!IsSupportedServiceContentRootArchive(fullContentRoot))
 1796        {
 01797            error = $"Unsupported package format. Supported extensions: {ServicePackageExtension}, .zip, .tar, .tgz, .ta
 01798            return false;
 1799        }
 1800
 51801        if (!TryValidateServiceContentRootArchiveChecksum(command, fullContentRoot, out error))
 1802        {
 11803            return false;
 1804        }
 1805
 41806        var extractedContentRoot = CreateServiceContentRootExtractionDirectory(command.ServiceName);
 1807        try
 1808        {
 41809            if (TryResolveServiceScriptFromExtractedArchiveContentRoot(
 41810                    requestedScriptPath,
 41811                    fullContentRoot,
 41812                    extractedContentRoot,
 41813                    out var extractedScriptSource,
 41814                    out error))
 1815            {
 41816                scriptSource = extractedScriptSource;
 41817                return true;
 1818            }
 1819
 01820            TryDeleteDirectoryWithRetry(extractedContentRoot, maxAttempts: 5, initialDelayMs: 50);
 01821            return false;
 1822        }
 01823        catch
 1824        {
 01825            TryDeleteDirectoryWithRetry(extractedContentRoot, maxAttempts: 5, initialDelayMs: 50);
 01826            throw;
 1827        }
 41828    }
 1829
 1830    /// <summary>
 1831    /// Resolves service script source from an already-created extraction directory for a local archive content root.
 1832    /// </summary>
 1833    /// <param name="requestedScriptPath">Requested script path.</param>
 1834    /// <param name="fullContentRoot">Absolute archive path.</param>
 1835    /// <param name="extractedContentRoot">Archive extraction directory path.</param>
 1836    /// <param name="scriptSource">Resolved script source details.</param>
 1837    /// <param name="error">Error details when validation fails.</param>
 1838    /// <returns>True when script source resolution succeeds.</returns>
 1839    private static bool TryResolveServiceScriptFromExtractedArchiveContentRoot(
 1840        string requestedScriptPath,
 1841        string fullContentRoot,
 1842        string extractedContentRoot,
 1843        out ResolvedServiceScriptSource scriptSource,
 1844        out string error)
 1845    {
 41846        scriptSource = CreateEmptyResolvedServiceScriptSource();
 1847
 41848        if (!TryExtractServiceContentRootArchive(fullContentRoot, extractedContentRoot, out error))
 1849        {
 01850            return false;
 1851        }
 1852
 41853        if (!TryResolveServiceInstallDescriptor(extractedContentRoot, out var descriptor, out error))
 1854        {
 01855            return false;
 1856        }
 1857
 41858        if (!TryResolveServiceDescriptorScriptPath(requestedScriptPath, descriptor, out var resolvedScriptPath, out erro
 1859        {
 01860            return false;
 1861        }
 1862
 41863        if (!TryResolveScriptFromResolvedContentRoot(
 41864                resolvedScriptPath,
 41865                extractedContentRoot,
 41866                $"Script path '{resolvedScriptPath}' escapes the extracted archive content root.",
 41867                $"Script file '{resolvedScriptPath}' was not found inside extracted archive '{fullContentRoot}'.",
 41868                extractedContentRoot,
 41869                out scriptSource,
 41870                out error))
 1871        {
 01872            return false;
 1873        }
 1874
 41875        scriptSource = ApplyDescriptorMetadata(scriptSource, descriptor);
 41876        return true;
 1877    }
 1878
 1879    /// <summary>
 1880    /// Validates content-root options when no content-root argument was supplied.
 1881    /// </summary>
 1882    /// <param name="optionFlags">Content-root related option flags.</param>
 1883    /// <param name="error">Validation error message when invalid combinations are detected.</param>
 1884    /// <returns>True when the option combination is valid.</returns>
 1885    private static bool TryValidateOptionsForMissingContentRoot(ServiceContentRootOptionFlags optionFlags, out string er
 1886    {
 11887        if (optionFlags.HasArchiveChecksum)
 1888        {
 01889            error = "--content-root-checksum requires --content-root.";
 01890            return false;
 1891        }
 1892
 11893        if (optionFlags.HasBearerToken)
 1894        {
 01895            error = "--content-root-bearer-token requires --content-root.";
 01896            return false;
 1897        }
 1898
 11899        if (optionFlags.IgnoreCertificate)
 1900        {
 01901            error = "--content-root-ignore-certificate requires --content-root.";
 01902            return false;
 1903        }
 1904
 11905        if (optionFlags.HasHeaders)
 1906        {
 01907            error = "--content-root-header requires --content-root.";
 01908            return false;
 1909        }
 1910
 11911        error = string.Empty;
 11912        return true;
 1913    }
 1914
 1915    /// <summary>
 1916    /// Validates URL-only options for non-URL content roots.
 1917    /// </summary>
 1918    /// <param name="optionFlags">Content-root related option flags.</param>
 1919    /// <param name="error">Validation error message when invalid combinations are detected.</param>
 1920    /// <returns>True when URL-only options were not supplied.</returns>
 1921    private static bool TryValidateUrlOnlyContentRootOptions(ServiceContentRootOptionFlags optionFlags, out string error
 1922    {
 131923        if (optionFlags.HasBearerToken)
 1924        {
 01925            error = "--content-root-bearer-token is only supported when --content-root points to an HTTP(S) archive URL.
 01926            return false;
 1927        }
 1928
 131929        if (optionFlags.IgnoreCertificate)
 1930        {
 11931            error = "--content-root-ignore-certificate is only supported when --content-root points to an HTTPS archive 
 11932            return false;
 1933        }
 1934
 121935        if (optionFlags.HasHeaders)
 1936        {
 11937            error = "--content-root-header is only supported when --content-root points to an HTTP(S) archive URL.";
 11938            return false;
 1939        }
 1940
 111941        error = string.Empty;
 111942        return true;
 1943    }
 1944
 1945    /// <summary>
 1946    /// Validates option combinations for directory-based content roots.
 1947    /// </summary>
 1948    /// <param name="optionFlags">Content-root related option flags.</param>
 1949    /// <param name="error">Validation error message when invalid combinations are detected.</param>
 1950    /// <returns>True when the option combination is valid.</returns>
 1951    private static bool TryValidateDirectoryContentRootOptions(ServiceContentRootOptionFlags optionFlags, out string err
 1952    {
 61953        if (optionFlags.HasArchiveChecksum)
 1954        {
 01955            error = "--content-root-checksum is only supported when --content-root points to an archive file.";
 01956            return false;
 1957        }
 1958
 61959        return TryValidateUrlOnlyContentRootOptions(optionFlags, out error);
 1960    }
 1961
 1962    /// <summary>
 1963    /// Validates option combinations for local archive content roots.
 1964    /// </summary>
 1965    /// <param name="optionFlags">Content-root related option flags.</param>
 1966    /// <param name="error">Validation error message when invalid combinations are detected.</param>
 1967    /// <returns>True when the option combination is valid.</returns>
 1968    private static bool TryValidateLocalArchiveContentRootOptions(ServiceContentRootOptionFlags optionFlags, out string 
 71969        => TryValidateUrlOnlyContentRootOptions(optionFlags, out error);
 1970
 1971    /// <summary>
 1972    /// Validates script path shape for HTTP archive content roots.
 1973    /// </summary>
 1974    /// <param name="requestedScriptPath">Requested script path.</param>
 1975    /// <param name="error">Validation error text.</param>
 1976    /// <returns>True when the script path is valid for URL archive usage.</returns>
 1977    private static bool TryValidateHttpContentRootScriptPath(string requestedScriptPath, out string error)
 1978    {
 61979        if (!string.IsNullOrWhiteSpace(requestedScriptPath) && Path.IsPathRooted(requestedScriptPath))
 1980        {
 01981            error = "When --content-root is a URL archive, --script must be a relative path inside the archive.";
 01982            return false;
 1983        }
 1984
 61985        error = string.Empty;
 61986        return true;
 1987    }
 1988
 1989    /// <summary>
 1990    /// Reads and validates Service.psd1 from a resolved service content-root folder.
 1991    /// </summary>
 1992    /// <param name="fullContentRoot">Absolute content-root directory path.</param>
 1993    /// <param name="descriptor">Resolved and validated descriptor metadata.</param>
 1994    /// <param name="error">Validation error details.</param>
 1995    /// <returns>True when descriptor exists and mandatory metadata is valid.</returns>
 1996    private static bool TryResolveServiceInstallDescriptor(string fullContentRoot, out ServiceInstallDescriptor descript
 1997    {
 211998        descriptor = new ServiceInstallDescriptor(string.Empty, string.Empty, string.Empty, string.Empty, null, null, []
 211999        if (!TryReadNormalizedServiceDescriptorText(fullContentRoot, out var descriptorText, out error))
 2000        {
 02001            return false;
 2002        }
 2003
 212004        if (!TryResolveServiceDescriptorCoreFields(descriptorText, out var name, out var description, out var formatVers
 2005        {
 12006            return false;
 2007        }
 2008
 202009        if (!TryResolveServiceDescriptorEntryPointAndVersion(
 202010                descriptorText,
 202011                formatVersion,
 202012                out var normalizedFormatVersion,
 202013                out var entryPoint,
 202014                out var version,
 202015                out error))
 2016        {
 12017            return false;
 2018        }
 2019
 192020        _ = TryGetServiceDescriptorStringValue(descriptorText, "ServiceLogPath", required: false, out var serviceLogPath
 2021
 192022        if (!TryGetServiceDescriptorStringArrayValue(descriptorText, "PreservePaths", out var preservePaths, out error))
 2023        {
 02024            return false;
 2025        }
 2026
 192027        if (!TryGetServiceDescriptorStringArrayValue(descriptorText, "ApplicationDataFolders", out var applicationDataFo
 2028        {
 02029            return false;
 2030        }
 2031
 192032        descriptor = new ServiceInstallDescriptor(
 192033            normalizedFormatVersion,
 192034            name,
 192035            entryPoint,
 192036            description,
 192037            version,
 192038            string.IsNullOrWhiteSpace(serviceLogPath) ? null : serviceLogPath,
 192039            preservePaths,
 192040            applicationDataFolders);
 192041        return true;
 2042    }
 2043
 2044    /// <summary>
 2045    /// Reads service descriptor text from disk and normalizes escaped newlines for regex parsing.
 2046    /// </summary>
 2047    /// <param name="fullContentRoot">Absolute content-root directory path.</param>
 2048    /// <param name="descriptorText">Normalized service descriptor text.</param>
 2049    /// <param name="error">Error details when file resolution or read fails.</param>
 2050    /// <returns>True when descriptor text is available and normalized.</returns>
 2051    private static bool TryReadNormalizedServiceDescriptorText(string fullContentRoot, out string descriptorText, out st
 2052    {
 212053        descriptorText = string.Empty;
 212054        var descriptorPath = Path.Combine(fullContentRoot, ServiceDescriptorFileName);
 212055        if (!File.Exists(descriptorPath))
 2056        {
 02057            error = $"Service descriptor file '{ServiceDescriptorFileName}' was not found at content-root '{fullContentR
 02058            return false;
 2059        }
 2060
 2061        try
 2062        {
 212063            descriptorText = File.ReadAllText(descriptorPath, Encoding.UTF8);
 212064        }
 02065        catch (Exception ex)
 2066        {
 02067            error = $"Failed to read service descriptor '{descriptorPath}': {ex.Message}";
 02068            return false;
 2069        }
 2070
 212071        descriptorText = NormalizeServiceDescriptorText(descriptorText);
 212072        error = string.Empty;
 212073        return true;
 02074    }
 2075
 2076    /// <summary>
 2077    /// Resolves required descriptor core fields and optional format version.
 2078    /// </summary>
 2079    /// <param name="descriptorText">Normalized service descriptor text.</param>
 2080    /// <param name="name">Resolved service name.</param>
 2081    /// <param name="description">Resolved service description.</param>
 2082    /// <param name="formatVersion">Optional format version token.</param>
 2083    /// <param name="error">Validation error details.</param>
 2084    /// <returns>True when required core fields are valid.</returns>
 2085    private static bool TryResolveServiceDescriptorCoreFields(
 2086        string descriptorText,
 2087        out string name,
 2088        out string description,
 2089        out string formatVersion,
 2090        out string error)
 2091    {
 212092        if (!TryGetServiceDescriptorStringValue(descriptorText, "Name", required: true, out name, out error))
 2093        {
 02094            description = string.Empty;
 02095            formatVersion = string.Empty;
 02096            return false;
 2097        }
 2098
 212099        if (!TryGetServiceDescriptorStringValue(descriptorText, "Description", required: true, out description, out erro
 2100        {
 12101            formatVersion = string.Empty;
 12102            return false;
 2103        }
 2104
 202105        _ = TryGetServiceDescriptorStringValue(descriptorText, "FormatVersion", required: false, out formatVersion, out 
 202106        error = string.Empty;
 202107        return true;
 2108    }
 2109
 2110    /// <summary>
 2111    /// Resolves descriptor entrypoint/version fields for legacy and format-1.0 descriptors.
 2112    /// </summary>
 2113    /// <param name="descriptorText">Normalized service descriptor text.</param>
 2114    /// <param name="formatVersion">Optional format version token.</param>
 2115    /// <param name="normalizedFormatVersion">Normalized format marker used by runtime metadata.</param>
 2116    /// <param name="entryPoint">Resolved script entrypoint path.</param>
 2117    /// <param name="version">Optional parsed version string.</param>
 2118    /// <param name="error">Validation error details.</param>
 2119    /// <returns>True when entrypoint/version resolution succeeds.</returns>
 2120    private static bool TryResolveServiceDescriptorEntryPointAndVersion(
 2121        string descriptorText,
 2122        string formatVersion,
 2123        out string normalizedFormatVersion,
 2124        out string entryPoint,
 2125        out string? version,
 2126        out string error)
 2127    {
 202128        version = null;
 2129
 202130        if (string.IsNullOrWhiteSpace(formatVersion))
 2131        {
 112132            normalizedFormatVersion = "legacy";
 112133            if (!TryGetServiceDescriptorStringValue(descriptorText, "Script", required: false, out entryPoint, out error
 2134            {
 02135                return false;
 2136            }
 2137
 112138            if (string.IsNullOrWhiteSpace(entryPoint))
 2139            {
 32140                entryPoint = ServiceDefaultScriptFileName;
 2141            }
 2142
 112143            return TryResolveOptionalServiceDescriptorVersion(descriptorText, out version, out error);
 2144        }
 2145
 92146        var trimmedFormatVersion = formatVersion.Trim();
 92147        normalizedFormatVersion = trimmedFormatVersion;
 92148        if (!string.Equals(trimmedFormatVersion, "1.0", StringComparison.Ordinal))
 2149        {
 02150            entryPoint = string.Empty;
 02151            error = "Service descriptor FormatVersion must be '1.0'.";
 02152            return false;
 2153        }
 2154
 92155        return TryGetServiceDescriptorStringValue(descriptorText, "EntryPoint", required: true, out entryPoint, out erro
 92156            && TryResolveOptionalServiceDescriptorVersion(descriptorText, out version, out error);
 2157    }
 2158
 2159    /// <summary>
 2160    /// Resolves and validates optional descriptor version metadata.
 2161    /// </summary>
 2162    /// <param name="descriptorText">Normalized service descriptor text.</param>
 2163    /// <param name="version">Resolved version string when present.</param>
 2164    /// <param name="error">Validation error details.</param>
 2165    /// <returns>True when version is absent or parseable by <see cref="Version"/>.</returns>
 2166    private static bool TryResolveOptionalServiceDescriptorVersion(string descriptorText, out string? version, out strin
 2167    {
 202168        version = null;
 202169        _ = TryGetServiceDescriptorStringValue(descriptorText, "Version", required: false, out var rawVersion, out _);
 202170        if (string.IsNullOrWhiteSpace(rawVersion))
 2171        {
 02172            error = string.Empty;
 02173            return true;
 2174        }
 2175
 202176        var trimmedVersion = rawVersion.Trim();
 202177        if (!Version.TryParse(trimmedVersion, out _))
 2178        {
 12179            error = $"Service descriptor '{ServiceDescriptorFileName}' key 'Version' must be compatible with System.Vers
 12180            return false;
 2181        }
 2182
 192183        version = trimmedVersion;
 192184        error = string.Empty;
 192185        return true;
 2186    }
 2187
 2188    /// <summary>
 2189    /// Normalizes service descriptor text for regex parsing.
 2190    /// </summary>
 2191    /// <param name="descriptorText">Raw descriptor text.</param>
 2192    /// <returns>Descriptor text with PowerShell escaped newline sequences expanded.</returns>
 2193    private static string NormalizeServiceDescriptorText(string descriptorText)
 212194        => descriptorText
 212195            .Replace("`r`n", "\n", StringComparison.Ordinal)
 212196            .Replace("`n", "\n", StringComparison.Ordinal)
 212197            .Replace("`r", "\n", StringComparison.Ordinal);
 2198
 2199    /// <summary>
 2200    /// Reads a string-valued key from Service.psd1.
 2201    /// </summary>
 2202    /// <param name="descriptorText">Raw descriptor content.</param>
 2203    /// <param name="key">Descriptor key name.</param>
 2204    /// <param name="required">True when a missing key should fail validation.</param>
 2205    /// <param name="value">Resolved string value.</param>
 2206    /// <param name="error">Validation error details when required values are missing.</param>
 2207    /// <returns>True when key resolution succeeded for the required/optional mode.</returns>
 2208    private static bool TryGetServiceDescriptorStringValue(string descriptorText, string key, bool required, out string 
 2209    {
 1212210        var match = Regex.Match(
 1212211            descriptorText,
 1212212            $@"(?mi)(?:^|[;{{\r\n])\s*{Regex.Escape(key)}\s*=\s*(?:'(?<single>[^']*)'|""(?<double>[^""]*)"")",
 1212213            RegexOptions.CultureInvariant);
 2214
 1212215        if (!match.Success)
 2216        {
 282217            value = string.Empty;
 282218            error = required
 282219                ? $"Service descriptor '{ServiceDescriptorFileName}' is missing required key '{key}'."
 282220                : string.Empty;
 282221            return !required;
 2222        }
 2223
 932224        value = (match.Groups["single"].Success ? match.Groups["single"].Value : match.Groups["double"].Value).Trim();
 932225        if (required && string.IsNullOrWhiteSpace(value))
 2226        {
 02227            error = $"Service descriptor '{ServiceDescriptorFileName}' key '{key}' must not be empty.";
 02228            return false;
 2229        }
 2230
 932231        error = string.Empty;
 932232        return true;
 2233    }
 2234
 2235    /// <summary>
 2236    /// Reads a string-array key from Service.psd1 using PowerShell array syntax: Key = @( 'a', 'b' ).
 2237    /// </summary>
 2238    /// <param name="descriptorText">Raw descriptor content.</param>
 2239    /// <param name="key">Descriptor key name.</param>
 2240    /// <param name="values">Resolved array values.</param>
 2241    /// <param name="error">Validation error details.</param>
 2242    /// <returns>True when array resolution succeeds.</returns>
 2243    private static bool TryGetServiceDescriptorStringArrayValue(string descriptorText, string key, out string[] values, 
 2244    {
 382245        values = [];
 382246        error = string.Empty;
 2247
 382248        var arrayMatch = Regex.Match(
 382249            descriptorText,
 382250            $@"(?mis)(?:^|[;{{\r\n])\s*{Regex.Escape(key)}\s*=\s*@\((?<items>.*?)\)",
 382251            RegexOptions.CultureInvariant);
 2252
 382253        if (!arrayMatch.Success)
 2254        {
 362255            return true;
 2256        }
 2257
 22258        var itemsText = arrayMatch.Groups["items"].Value;
 22259        var itemMatches = Regex.Matches(
 22260            itemsText,
 22261            "'(?<single>(?:''|[^'])*)'|\"(?<double>(?:\"\"|[^\"])*)\"",
 22262            RegexOptions.CultureInvariant);
 2263
 22264        if (itemMatches.Count == 0 && !string.IsNullOrWhiteSpace(itemsText))
 2265        {
 02266            error = $"Service descriptor '{ServiceDescriptorFileName}' key '{key}' must be a string array, for example: 
 02267            return false;
 2268        }
 2269
 22270        values = [.. itemMatches
 22271            .Select(static match =>
 22272            {
 52273                var raw = match.Groups["single"].Success
 52274                    ? match.Groups["single"].Value.Replace("''", "'", StringComparison.Ordinal)
 52275                    : match.Groups["double"].Value.Replace("\"\"", "\"", StringComparison.Ordinal);
 52276                return raw.Trim();
 22277            })
 72278            .Where(static path => !string.IsNullOrWhiteSpace(path))];
 22279        return true;
 2280    }
 2281
 2282    /// <summary>
 2283    /// Resolves the script path for descriptor-driven service installs.
 2284    /// </summary>
 2285    /// <param name="requestedScriptPath">Script path requested on the command line.</param>
 2286    /// <param name="descriptor">Resolved service descriptor.</param>
 2287    /// <param name="resolvedScriptPath">Final script path relative to content root.</param>
 2288    /// <param name="error">Validation error details.</param>
 2289    /// <returns>True when the resolved script path is valid.</returns>
 2290    private static bool TryResolveServiceDescriptorScriptPath(string requestedScriptPath, ServiceInstallDescriptor descr
 2291    {
 112292        if (!string.IsNullOrWhiteSpace(requestedScriptPath))
 2293        {
 02294            resolvedScriptPath = string.Empty;
 02295            error = "An explicit script path is not supported when --package is used. Define EntryPoint in Service.psd1 
 02296            return false;
 2297        }
 2298
 112299        resolvedScriptPath = descriptor.EntryPoint;
 2300
 112301        if (Path.IsPathRooted(resolvedScriptPath))
 2302        {
 12303            error = $"Service descriptor '{ServiceDescriptorFileName}' EntryPoint/Script must be a relative path within 
 12304            return false;
 2305        }
 2306
 102307        error = string.Empty;
 102308        return true;
 2309    }
 2310
 2311    /// <summary>
 2312    /// Applies descriptor metadata to a resolved service script source.
 2313    /// </summary>
 2314    /// <param name="scriptSource">Resolved script source.</param>
 2315    /// <param name="descriptor">Descriptor metadata.</param>
 2316    /// <returns>Script source enriched with descriptor metadata.</returns>
 2317    private static ResolvedServiceScriptSource ApplyDescriptorMetadata(ResolvedServiceScriptSource scriptSource, Service
 102318        => new(
 102319            scriptSource.FullScriptPath,
 102320            scriptSource.FullContentRoot,
 102321            scriptSource.RelativeScriptPath,
 102322            scriptSource.TemporaryContentRootPath,
 102323            descriptor.Name,
 102324            descriptor.Description,
 102325            descriptor.Version,
 102326            descriptor.ServiceLogPath,
 102327            descriptor.PreservePaths,
 102328            descriptor.ApplicationDataFolders);
 2329
 2330    /// <summary>
 2331    /// Downloads and extracts an HTTP content-root archive into the supplied directory.
 2332    /// </summary>
 2333    /// <param name="command">Parsed service command.</param>
 2334    /// <param name="contentRootUri">HTTP(S) archive URI.</param>
 2335    /// <param name="temporaryRoot">Temporary root folder used for download output.</param>
 2336    /// <param name="downloadedContentRoot">Folder where the archive should be extracted.</param>
 2337    /// <param name="error">Error details when any stage fails.</param>
 2338    /// <returns>True when download, checksum, and extraction all succeed.</returns>
 2339    private static bool TryDownloadAndExtractHttpContentRoot(
 2340        ParsedCommand command,
 2341        Uri contentRootUri,
 2342        string temporaryRoot,
 2343        string downloadedContentRoot,
 2344        out string error)
 2345    {
 62346        if (!TryDownloadServiceContentRootArchive(
 62347                contentRootUri,
 62348                temporaryRoot,
 62349                command.ServiceContentRootBearerToken,
 62350                command.ServiceContentRootHeaders,
 62351                command.ServiceContentRootIgnoreCertificate,
 62352                out var downloadedArchivePath,
 62353                out error))
 2354        {
 32355            return false;
 2356        }
 2357
 2358        try
 2359        {
 32360            return
 32361                TryValidateServiceContentRootArchiveChecksum(command, downloadedArchivePath, out error) &&
 32362                TryExtractServiceContentRootArchive(downloadedArchivePath, downloadedContentRoot, out error);
 2363        }
 2364        finally
 2365        {
 2366            // Best-effort cleanup to avoid retaining large downloaded archives after extraction attempts.
 32367            TryDeleteFileQuietly(downloadedArchivePath);
 32368        }
 32369    }
 2370
 2371    /// <summary>
 2372    /// Resolves a relative script path from an already materialized content-root directory.
 2373    /// </summary>
 2374    /// <param name="requestedScriptPath">Requested relative script path.</param>
 2375    /// <param name="fullContentRoot">Absolute content-root path.</param>
 2376    /// <param name="escapedPathError">Error message used when the script path escapes the content root.</param>
 2377    /// <param name="missingScriptError">Error message used when the script file does not exist.</param>
 2378    /// <param name="temporaryContentRootPath">Optional temporary content-root path for cleanup ownership.</param>
 2379    /// <param name="scriptSource">Resolved script source details.</param>
 2380    /// <param name="error">Error details when validation fails.</param>
 2381    /// <returns>True when the script path resolves and exists inside the root.</returns>
 2382    private static bool TryResolveScriptFromResolvedContentRoot(
 2383        string requestedScriptPath,
 2384        string fullContentRoot,
 2385        string escapedPathError,
 2386        string missingScriptError,
 2387        string? temporaryContentRootPath,
 2388        out ResolvedServiceScriptSource scriptSource,
 2389        out string error)
 2390    {
 102391        scriptSource = CreateEmptyResolvedServiceScriptSource();
 102392        var fullScriptPathFromRoot = Path.GetFullPath(Path.Combine(fullContentRoot, requestedScriptPath));
 102393        if (!IsPathWithinDirectory(fullScriptPathFromRoot, fullContentRoot))
 2394        {
 02395            error = escapedPathError;
 02396            return false;
 2397        }
 2398
 102399        if (!File.Exists(fullScriptPathFromRoot))
 2400        {
 02401            error = missingScriptError;
 02402            return false;
 2403        }
 2404
 102405        var relativeScriptPath = Path.GetRelativePath(fullContentRoot, fullScriptPathFromRoot);
 102406        scriptSource = new ResolvedServiceScriptSource(fullScriptPathFromRoot, fullContentRoot, relativeScriptPath, temp
 102407        error = string.Empty;
 102408        return true;
 2409    }
 2410
 2411    /// <summary>
 2412    /// Creates an empty service-script-source placeholder.
 2413    /// </summary>
 2414    /// <returns>Empty resolved service script source value.</returns>
 2415    private static ResolvedServiceScriptSource CreateEmptyResolvedServiceScriptSource()
 402416        => new(string.Empty, null, string.Empty, null, null, null, null, null, [], []);
 2417
 2418    /// <summary>
 2419    /// Creates a temporary extraction directory for archive-based service content roots.
 2420    /// </summary>
 2421    /// <param name="serviceName">Optional service name for easier diagnostics.</param>
 2422    /// <returns>Newly created extraction directory path.</returns>
 2423    private static string CreateServiceContentRootExtractionDirectory(string? serviceName)
 2424    {
 102425        var safeServiceName = string.IsNullOrWhiteSpace(serviceName)
 102426            ? "service"
 102427            : string.Concat(serviceName.Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_'));
 2428
 102429        if (string.IsNullOrWhiteSpace(safeServiceName))
 2430        {
 02431            safeServiceName = "service";
 2432        }
 2433
 102434        var extractionRoot = Path.Combine(Path.GetTempPath(), "kestrun-content-root", safeServiceName, Guid.NewGuid().To
 102435        _ = Directory.CreateDirectory(extractionRoot);
 102436        return extractionRoot;
 2437    }
 2438
 2439    /// <summary>
 2440    /// Parses an HTTP(S) URI for service content-root input.
 2441    /// </summary>
 2442    /// <param name="contentRootInput">Raw content-root input token.</param>
 2443    /// <param name="uri">Parsed HTTP(S) URI.</param>
 2444    /// <returns>True when input is an absolute HTTP(S) URI.</returns>
 2445    private static bool TryParseServiceContentRootHttpUri(string contentRootInput, out Uri uri)
 2446    {
 242447        if (Uri.TryCreate(contentRootInput, UriKind.Absolute, out var parsed)
 242448            && parsed is not null
 242449            && (parsed.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
 242450                || parsed.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
 2451        {
 72452            uri = parsed;
 72453            return true;
 2454        }
 2455
 172456        uri = null!;
 172457        return false;
 2458    }
 2459
 2460    /// <summary>
 2461    /// Downloads a service content-root archive from HTTP(S) into a temporary directory.
 2462    /// </summary>
 2463    /// <param name="uri">HTTP(S) source URI.</param>
 2464    /// <param name="temporaryRoot">Temporary root directory for download output.</param>
 2465    /// <param name="archivePath">Downloaded archive path.</param>
 2466    /// <param name="error">Error details when download fails.</param>
 2467    /// <returns>True when archive is downloaded and has a supported extension.</returns>
 2468    private static bool TryDownloadServiceContentRootArchive(
 2469        Uri uri,
 2470        string temporaryRoot,
 2471        string? bearerToken,
 2472        string[] customHeaders,
 2473        bool ignoreCertificate,
 2474        out string archivePath,
 2475        out string error)
 2476    {
 62477        archivePath = string.Empty;
 62478        error = string.Empty;
 2479
 62480        if (ignoreCertificate && !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
 2481        {
 02482            error = "--content-root-ignore-certificate is only valid for HTTPS URLs.";
 02483            return false;
 2484        }
 2485
 2486        try
 2487        {
 62488            using var request = new HttpRequestMessage(HttpMethod.Get, uri);
 62489            if (!string.IsNullOrWhiteSpace(bearerToken))
 2490            {
 12491                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
 2492            }
 2493
 62494            if (!TryApplyServiceContentRootCustomHeaders(request, customHeaders, out error))
 2495            {
 32496                return false;
 2497            }
 2498
 32499            if (!ignoreCertificate)
 2500            {
 32501                using var response = ServiceContentRootHttpClient.Send(request, HttpCompletionOption.ResponseHeadersRead
 32502                if (!response.IsSuccessStatusCode)
 2503                {
 02504                    error = $"Failed to download service content root from '{uri}'. HTTP {(int)response.StatusCode} {res
 02505                    return false;
 2506                }
 2507
 32508                return TryWriteDownloadedContentRootArchive(temporaryRoot, uri, response, out archivePath, out error);
 2509            }
 2510
 02511            using var insecureHandler = new HttpClientHandler
 02512            {
 02513                ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidat
 02514            };
 02515            using var insecureClient = new HttpClient(insecureHandler)
 02516            {
 02517                Timeout = TimeSpan.FromMinutes(5),
 02518            };
 02519            insecureClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0"));
 02520            using var insecureResponse = insecureClient.Send(request, HttpCompletionOption.ResponseHeadersRead);
 02521            if (!insecureResponse.IsSuccessStatusCode)
 2522            {
 02523                error = $"Failed to download service content root from '{uri}'. HTTP {(int)insecureResponse.StatusCode} 
 02524                return false;
 2525            }
 2526
 02527            return TryWriteDownloadedContentRootArchive(temporaryRoot, uri, insecureResponse, out archivePath, out error
 2528        }
 02529        catch (Exception ex)
 2530        {
 02531            error = $"Failed to download service content root from '{uri}': {ex.Message}";
 02532            return false;
 2533        }
 62534    }
 2535
 2536    /// <summary>
 2537    /// Applies custom request headers used for service content-root URL downloads.
 2538    /// </summary>
 2539    /// <param name="request">HTTP request message to update.</param>
 2540    /// <param name="customHeaders">Custom header tokens in <c>name:value</c> format.</param>
 2541    /// <param name="error">Validation error when a header token cannot be applied.</param>
 2542    /// <returns>True when all headers are valid and applied to the request.</returns>
 2543    private static bool TryApplyServiceContentRootCustomHeaders(HttpRequestMessage request, IReadOnlyList<string> custom
 2544    {
 122545        error = string.Empty;
 292546        foreach (var headerToken in customHeaders)
 2547        {
 42548            if (string.IsNullOrWhiteSpace(headerToken))
 2549            {
 02550                error = "--content-root-header value cannot be empty. Use <name:value>.";
 02551                return false;
 2552            }
 2553
 42554            var separatorIndex = headerToken.IndexOf(':');
 42555            if (separatorIndex <= 0)
 2556            {
 12557                error = $"Invalid --content-root-header value '{headerToken}'. Use <name:value>.";
 12558                return false;
 2559            }
 2560
 32561            var headerName = headerToken[..separatorIndex].Trim();
 32562            var headerValue = headerToken[(separatorIndex + 1)..].Trim();
 32563            if (string.IsNullOrWhiteSpace(headerName))
 2564            {
 02565                error = $"Invalid --content-root-header value '{headerToken}'. Header name cannot be empty.";
 02566                return false;
 2567            }
 2568
 32569            if (headerName.Contains('\r') || headerName.Contains('\n'))
 2570            {
 12571                error = $"Invalid --content-root-header value '{headerToken}'. Header name cannot contain CR or LF chara
 12572                return false;
 2573            }
 2574
 22575            if (headerValue.Contains('\r') || headerValue.Contains('\n'))
 2576            {
 12577                error = $"Invalid --content-root-header value '{headerToken}'. Header value cannot contain CR or LF char
 12578                return false;
 2579            }
 2580
 12581            if (!request.Headers.TryAddWithoutValidation(headerName, headerValue))
 2582            {
 02583                error = $"Invalid --content-root-header value '{headerToken}'.";
 02584                return false;
 2585            }
 2586        }
 2587
 92588        return true;
 32589    }
 2590
 2591    /// <summary>
 2592    /// Writes a downloaded service content-root archive response to disk.
 2593    /// </summary>
 2594    /// <param name="temporaryRoot">Temporary root directory for archive output.</param>
 2595    /// <param name="uri">Source URI.</param>
 2596    /// <param name="response">HTTP response with archive payload.</param>
 2597    /// <param name="archivePath">Written archive path.</param>
 2598    /// <param name="error">Error details when write or validation fails.</param>
 2599    /// <returns>True when the archive is written and has a supported extension.</returns>
 2600    private static bool TryWriteDownloadedContentRootArchive(
 2601        string temporaryRoot,
 2602        Uri uri,
 2603        HttpResponseMessage response,
 2604        out string archivePath,
 2605        out string error)
 2606    {
 32607        archivePath = string.Empty;
 32608        error = string.Empty;
 2609
 32610        var resolvedFileName = TryResolveServiceContentRootArchiveFileName(uri, response)
 32611            ?? "content-root";
 32612        resolvedFileName = GetSafeServiceContentRootArchiveFileName(resolvedFileName, "content-root");
 2613
 32614        var provisionalArchivePath = Path.Combine(temporaryRoot, resolvedFileName);
 32615        using (var sourceStream = response.Content.ReadAsStream())
 32616        using (var destinationStream = File.Create(provisionalArchivePath))
 2617        {
 32618            sourceStream.CopyTo(destinationStream);
 32619        }
 2620
 32621        if (!TryResolveDownloadedServiceContentRootArchiveFileName(
 32622                uri,
 32623                resolvedFileName,
 32624                provisionalArchivePath,
 32625                response,
 32626                out var finalizedFileName,
 32627                out error))
 2628        {
 2629            try
 2630            {
 02631                if (File.Exists(provisionalArchivePath))
 2632                {
 02633                    File.Delete(provisionalArchivePath);
 2634                }
 02635            }
 02636            catch
 2637            {
 2638                // Ignore cleanup errors because the original archive-validation error is more actionable.
 02639            }
 02640            return false;
 2641        }
 2642
 32643        archivePath = provisionalArchivePath;
 32644        if (!string.Equals(finalizedFileName, resolvedFileName, StringComparison.OrdinalIgnoreCase))
 2645        {
 12646            var finalizedArchivePath = Path.Combine(temporaryRoot, finalizedFileName);
 12647            File.Move(provisionalArchivePath, finalizedArchivePath, overwrite: true);
 12648            archivePath = finalizedArchivePath;
 2649        }
 2650
 32651        return true;
 2652    }
 2653
 2654    /// <summary>
 2655    /// Converts an archive file name candidate to a filesystem-safe file name.
 2656    /// </summary>
 2657    /// <param name="candidate">Raw file name candidate from headers or URI metadata.</param>
 2658    /// <param name="fallbackFileName">Fallback file name when the candidate is empty or invalid.</param>
 2659    /// <returns>Filesystem-safe file name.</returns>
 2660    private static string GetSafeServiceContentRootArchiveFileName(string? candidate, string fallbackFileName)
 2661    {
 32662        var fileName = Path.GetFileName(candidate ?? string.Empty);
 32663        if (string.IsNullOrWhiteSpace(fileName))
 2664        {
 02665            return fallbackFileName;
 2666        }
 2667
 32668        var invalidChars = Path.GetInvalidFileNameChars();
 32669        var builder = new StringBuilder(fileName.Length);
 502670        foreach (var ch in fileName)
 2671        {
 222672            if (char.IsControl(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar || inval
 2673            {
 02674                _ = builder.Append('-');
 02675                continue;
 2676            }
 2677
 222678            _ = builder.Append(ch);
 2679        }
 2680
 32681        var sanitized = builder.ToString().Trim().Trim('.');
 32682        return string.IsNullOrWhiteSpace(sanitized) ? fallbackFileName : sanitized;
 2683    }
 2684
 2685    /// <summary>
 2686    /// Resolves a supported archive file name for a downloaded content-root payload.
 2687    /// </summary>
 2688    /// <param name="uri">Source URI.</param>
 2689    /// <param name="resolvedFileName">Initially resolved file name candidate.</param>
 2690    /// <param name="downloadedArchivePath">Downloaded archive payload path.</param>
 2691    /// <param name="response">HTTP response metadata.</param>
 2692    /// <param name="finalizedFileName">Finalized archive file name with supported extension.</param>
 2693    /// <param name="error">Validation error details when archive type cannot be resolved.</param>
 2694    /// <returns>True when a supported archive file name is resolved.</returns>
 2695    private static bool TryResolveDownloadedServiceContentRootArchiveFileName(
 2696        Uri uri,
 2697        string resolvedFileName,
 2698        string downloadedArchivePath,
 2699        HttpResponseMessage response,
 2700        out string finalizedFileName,
 2701        out string error)
 2702    {
 32703        finalizedFileName = resolvedFileName;
 32704        error = string.Empty;
 2705
 32706        if (IsSupportedServiceContentRootArchive(finalizedFileName))
 2707        {
 22708            return true;
 2709        }
 2710
 12711        var mediaType = response.Content.Headers.ContentType?.MediaType;
 12712        if (TryGetServiceContentRootArchiveExtensionFromMediaType(mediaType, out var archiveExtension)
 12713            || TryDetectServiceContentRootArchiveExtensionFromSignature(downloadedArchivePath, out archiveExtension))
 2714        {
 12715            finalizedFileName = BuildServiceContentRootArchiveFileName(resolvedFileName, archiveExtension);
 12716            return true;
 2717        }
 2718
 02719        error = $"Downloaded package from '{uri}' is not a supported archive. Supported extensions: {ServicePackageExten
 02720        return false;
 2721    }
 2722
 2723    /// <summary>
 2724    /// Maps content-type metadata to a preferred service content-root archive extension.
 2725    /// </summary>
 2726    /// <param name="mediaType">HTTP response media type.</param>
 2727    /// <param name="archiveExtension">Resolved archive extension when available.</param>
 2728    /// <returns>True when the media type maps to a supported archive extension.</returns>
 2729    private static bool TryGetServiceContentRootArchiveExtensionFromMediaType(string? mediaType, out string archiveExten
 2730    {
 62731        switch (mediaType?.ToLowerInvariant())
 2732        {
 2733            case "application/zip":
 2734            case "application/x-zip-compressed":
 12735                archiveExtension = ".zip";
 12736                return true;
 2737            case "application/x-tar":
 12738                archiveExtension = ".tar";
 12739                return true;
 2740            case "application/gzip":
 2741            case "application/x-gzip":
 22742                archiveExtension = ".tgz";
 22743                return true;
 2744            default:
 22745                archiveExtension = string.Empty;
 22746                return false;
 2747        }
 2748    }
 2749
 2750    /// <summary>
 2751    /// Detects archive extension from file signature when metadata does not provide a usable file name.
 2752    /// </summary>
 2753    /// <param name="archivePath">Downloaded archive payload path.</param>
 2754    /// <param name="archiveExtension">Detected archive extension.</param>
 2755    /// <returns>True when a supported archive signature is recognized.</returns>
 2756    private static bool TryDetectServiceContentRootArchiveExtensionFromSignature(string archivePath, out string archiveE
 2757    {
 52758        archiveExtension = string.Empty;
 2759        try
 2760        {
 52761            Span<byte> signature = stackalloc byte[512];
 52762            using var stream = File.OpenRead(archivePath);
 52763            var bytesRead = stream.Read(signature);
 52764            if (bytesRead <= 0)
 2765            {
 02766                return false;
 2767            }
 2768
 52769            if (bytesRead >= 4
 52770                && signature[0] == 0x50
 52771                && signature[1] == 0x4B
 52772                && ((signature[2] == 0x03 && signature[3] == 0x04)
 52773                    || (signature[2] == 0x05 && signature[3] == 0x06)
 52774                    || (signature[2] == 0x07 && signature[3] == 0x08)))
 2775            {
 12776                archiveExtension = ".zip";
 12777                return true;
 2778            }
 2779
 42780            if (bytesRead >= 2 && signature[0] == 0x1F && signature[1] == 0x8B)
 2781            {
 22782                archiveExtension = ".tgz";
 22783                return true;
 2784            }
 2785
 22786            if (bytesRead >= 262
 22787                && signature[257] == (byte)'u'
 22788                && signature[258] == (byte)'s'
 22789                && signature[259] == (byte)'t'
 22790                && signature[260] == (byte)'a'
 22791                && signature[261] == (byte)'r')
 2792            {
 12793                archiveExtension = ".tar";
 12794                return true;
 2795            }
 2796
 12797            return false;
 2798        }
 02799        catch
 2800        {
 02801            return false;
 2802        }
 52803    }
 2804
 2805    /// <summary>
 2806    /// Builds a normalized archive file name using the detected archive extension.
 2807    /// </summary>
 2808    /// <param name="resolvedFileName">Initially resolved file name candidate.</param>
 2809    /// <param name="archiveExtension">Detected archive extension.</param>
 2810    /// <returns>Normalized file name with archive extension.</returns>
 2811    private static string BuildServiceContentRootArchiveFileName(string resolvedFileName, string archiveExtension)
 2812    {
 42813        var baseName = Path.GetFileNameWithoutExtension(resolvedFileName);
 42814        if (archiveExtension.Equals(".tar.gz", StringComparison.OrdinalIgnoreCase)
 42815            && resolvedFileName.EndsWith(".tar", StringComparison.OrdinalIgnoreCase))
 2816        {
 12817            baseName = Path.GetFileNameWithoutExtension(baseName);
 2818        }
 2819
 42820        if (string.IsNullOrWhiteSpace(baseName))
 2821        {
 12822            baseName = "content-root";
 2823        }
 2824
 42825        return $"{baseName}{archiveExtension}";
 2826    }
 2827
 2828    /// <summary>
 2829    /// Resolves a best-effort archive file name from response headers and URI metadata.
 2830    /// </summary>
 2831    /// <param name="uri">Source URI.</param>
 2832    /// <param name="response">HTTP response.</param>
 2833    /// <returns>Resolved file name when available; otherwise null.</returns>
 2834    private static string? TryResolveServiceContentRootArchiveFileName(Uri uri, HttpResponseMessage response)
 2835    {
 62836        var contentDisposition = response.Content.Headers.ContentDisposition;
 62837        var dispositionFileName = contentDisposition?.FileNameStar ?? contentDisposition?.FileName;
 62838        if (!string.IsNullOrWhiteSpace(dispositionFileName))
 2839        {
 12840            var trimmed = dispositionFileName.Trim().Trim('"');
 12841            if (!string.IsNullOrWhiteSpace(trimmed))
 2842            {
 12843                return trimmed;
 2844            }
 2845        }
 2846
 52847        var uriFileName = Path.GetFileName(uri.AbsolutePath);
 52848        if (!string.IsNullOrWhiteSpace(uriFileName))
 2849        {
 42850            return uriFileName;
 2851        }
 2852
 2853        // Fall back to media-type-based extension inference when no usable file name metadata is available,
 2854        // to at least get a correct extension for archive type detection and validation even if the base name is generi
 12855        return TryGetServiceContentRootArchiveExtensionFromMediaType(
 12856            response.Content.Headers.ContentType?.MediaType,
 12857            out var archiveExtension)
 12858            ? BuildServiceContentRootArchiveFileName("content-root", archiveExtension)
 12859            : null;
 2860    }
 2861
 2862    /// <summary>
 2863    /// Returns true when the package archive path uses the supported extension.
 2864    /// </summary>
 2865    /// <param name="archivePath">Archive file path.</param>
 2866    /// <returns>True when archive extension is supported.</returns>
 2867    private static bool IsSupportedServiceContentRootArchive(string archivePath)
 2868    {
 142869        var lowerPath = archivePath.ToLowerInvariant();
 142870        return lowerPath.EndsWith(ServicePackageExtension, StringComparison.Ordinal)
 142871            || lowerPath.EndsWith(".zip", StringComparison.Ordinal)
 142872            || lowerPath.EndsWith(".tar", StringComparison.Ordinal)
 142873            || lowerPath.EndsWith(".tar.gz", StringComparison.Ordinal)
 142874            || lowerPath.EndsWith(".tgz", StringComparison.Ordinal);
 2875    }
 2876
 2877    /// <summary>
 2878    /// Validates a content-root archive checksum when a checksum was provided.
 2879    /// </summary>
 2880    /// <param name="command">Parsed service command.</param>
 2881    /// <param name="archivePath">Archive path to validate.</param>
 2882    /// <param name="error">Error details when checksum validation fails.</param>
 2883    /// <returns>True when checksum is valid or not requested.</returns>
 2884    private static bool TryValidateServiceContentRootArchiveChecksum(ParsedCommand command, string archivePath, out stri
 2885    {
 112886        error = string.Empty;
 112887        if (string.IsNullOrWhiteSpace(command.ServiceContentRootChecksum))
 2888        {
 72889            return true;
 2890        }
 2891
 42892        var expectedHash = command.ServiceContentRootChecksum.Trim();
 42893        if (!Regex.IsMatch(expectedHash, "^[0-9a-fA-F]+$"))
 2894        {
 12895            error = "--content-root-checksum must be a hexadecimal hash string.";
 12896            return false;
 2897        }
 2898
 32899        var algorithmName = string.IsNullOrWhiteSpace(command.ServiceContentRootChecksumAlgorithm)
 32900            ? "sha256"
 32901            : command.ServiceContentRootChecksumAlgorithm.Trim();
 2902
 32903        if (!TryCreateChecksumAlgorithm(algorithmName, out var algorithm, out var normalizedAlgorithmName, out error))
 2904        {
 02905            return false;
 2906        }
 2907
 32908        using (algorithm)
 32909        using (var stream = File.OpenRead(archivePath))
 2910        {
 32911            var actualHash = Convert.ToHexString(algorithm.ComputeHash(stream));
 32912            if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
 2913            {
 22914                error = $"Archive checksum mismatch for '{archivePath}'. Expected {normalizedAlgorithmName}:{expectedHas
 22915                return false;
 2916            }
 12917        }
 2918
 12919        return true;
 22920    }
 2921
 2922    /// <summary>
 2923    /// Creates a hash algorithm instance from a user-provided token.
 2924    /// </summary>
 2925    /// <param name="algorithmToken">Algorithm token from CLI.</param>
 2926    /// <param name="algorithm">Created hash algorithm instance.</param>
 2927    /// <param name="normalizedName">Normalized algorithm name.</param>
 2928    /// <param name="error">Validation error text when algorithm creation fails.</param>
 2929    /// <returns>True when the algorithm token is supported and can be created.</returns>
 2930    private static bool TryCreateChecksumAlgorithm(string algorithmToken, out HashAlgorithm algorithm, out string normal
 2931    {
 52932        algorithm = null!;
 52933        normalizedName = string.Empty;
 52934        error = string.Empty;
 2935
 52936        var compact = algorithmToken.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToLowerInvariant();
 2937
 52938        Func<HashAlgorithm>? algorithmFactory = compact switch
 52939        {
 02940            "md5" => MD5.Create,
 02941            "sha1" or "sha" => SHA1.Create,
 42942            "sha2" or "sha256" => SHA256.Create,
 02943            "sha384" => SHA384.Create,
 02944            "sha512" => SHA512.Create,
 12945            _ => null,
 52946        };
 2947
 52948        if (algorithmFactory is null)
 2949        {
 12950            error = "Unsupported --content-root-checksum-algorithm. Supported values: md5, sha1, sha256, sha384, sha512.
 12951            return false;
 2952        }
 2953
 42954        normalizedName = compact switch
 42955        {
 02956            "sha" => "sha1",
 12957            "sha2" => "sha256",
 32958            _ => compact,
 42959        };
 2960
 2961        try
 2962        {
 42963            algorithm = algorithmFactory();
 42964            return true;
 2965        }
 02966        catch (Exception ex)
 2967        {
 02968            error = $"Unable to create checksum algorithm '{normalizedName}'. The algorithm may be disabled by system po
 02969            algorithm = null!;
 02970            normalizedName = string.Empty;
 02971            return false;
 2972        }
 42973    }
 2974
 2975    /// <summary>
 2976    /// Extracts a supported archive into the specified target directory.
 2977    /// </summary>
 2978    /// <param name="archivePath">Archive file path.</param>
 2979    /// <param name="destinationDirectory">Extraction destination directory.</param>
 2980    /// <param name="error">Error details when extraction fails.</param>
 2981    /// <returns>True when extraction succeeds.</returns>
 2982    private static bool TryExtractServiceContentRootArchive(string archivePath, string destinationDirectory, out string 
 2983    {
 72984        error = string.Empty;
 2985
 2986        try
 2987        {
 72988            var lowerPath = archivePath.ToLowerInvariant();
 72989            if (lowerPath.EndsWith(ServicePackageExtension, StringComparison.Ordinal)
 72990                || lowerPath.EndsWith(".zip", StringComparison.Ordinal))
 2991            {
 52992                return TryExtractZipArchiveSafely(archivePath, destinationDirectory, out error);
 2993            }
 2994
 22995            if (lowerPath.EndsWith(".tar", StringComparison.Ordinal))
 2996            {
 02997                return TryExtractTarArchiveSafely(File.OpenRead(archivePath), destinationDirectory, out error);
 2998            }
 2999
 23000            if (lowerPath.EndsWith(".tar.gz", StringComparison.Ordinal) || lowerPath.EndsWith(".tgz", StringComparison.O
 3001            {
 23002                using var archiveStream = File.OpenRead(archivePath);
 23003                using var gzipStream = new GZipStream(archiveStream, CompressionMode.Decompress);
 23004                return TryExtractTarArchiveSafely(gzipStream, destinationDirectory, out error);
 3005            }
 3006
 03007            error = $"Unsupported package format. Supported extension: {ServicePackageExtension} (zip payload).";
 03008            return false;
 3009        }
 03010        catch (Exception ex)
 3011        {
 03012            error = $"Failed to extract service content archive '{archivePath}': {ex.Message}";
 03013            return false;
 3014        }
 73015    }
 3016
 3017    /// <summary>
 3018    /// Extracts a zip archive while enforcing destination path boundaries.
 3019    /// </summary>
 3020    /// <param name="archivePath">Archive file path.</param>
 3021    /// <param name="destinationDirectory">Extraction destination directory.</param>
 3022    /// <param name="error">Error details when extraction fails.</param>
 3023    /// <returns>True when extraction succeeds.</returns>
 3024    private static bool TryExtractZipArchiveSafely(string archivePath, string destinationDirectory, out string error)
 3025    {
 173026        error = string.Empty;
 173027        using var archive = ZipFile.OpenRead(archivePath);
 1543028        foreach (var entry in archive.Entries)
 3029        {
 603030            var fullDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, entry.FullName));
 603031            if (!IsPathWithinDirectory(fullDestinationPath, destinationDirectory))
 3032            {
 03033                error = $"Archive entry '{entry.FullName}' escapes extraction root.";
 03034                return false;
 3035            }
 3036
 603037            var isDirectory = string.IsNullOrEmpty(entry.Name)
 603038                || entry.FullName.EndsWith('/')
 603039                || entry.FullName.EndsWith('\\');
 603040            if (isDirectory)
 3041            {
 03042                _ = Directory.CreateDirectory(fullDestinationPath);
 03043                continue;
 3044            }
 3045
 603046            var parentDirectory = Path.GetDirectoryName(fullDestinationPath);
 603047            if (!string.IsNullOrWhiteSpace(parentDirectory))
 3048            {
 603049                _ = Directory.CreateDirectory(parentDirectory);
 3050            }
 3051
 603052            entry.ExtractToFile(fullDestinationPath, overwrite: true);
 3053        }
 3054
 173055        return true;
 173056    }
 3057
 3058    /// <summary>
 3059    /// Extracts a tar stream while enforcing destination path boundaries.
 3060    /// </summary>
 3061    /// <param name="archiveStream">Tar-formatted stream.</param>
 3062    /// <param name="destinationDirectory">Extraction destination directory.</param>
 3063    /// <param name="error">Error details when extraction fails.</param>
 3064    /// <returns>True when extraction succeeds.</returns>
 3065    private static bool TryExtractTarArchiveSafely(Stream archiveStream, string destinationDirectory, out string error)
 3066    {
 23067        error = string.Empty;
 23068        using var reader = new TarReader(archiveStream, leaveOpen: false);
 3069        TarEntry? entry;
 73070        while ((entry = reader.GetNextEntry()) is not null)
 3071        {
 53072            if (string.IsNullOrWhiteSpace(entry.Name))
 3073            {
 3074                continue;
 3075            }
 3076
 53077            var fullDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, entry.Name));
 53078            if (!IsPathWithinDirectory(fullDestinationPath, destinationDirectory))
 3079            {
 03080                error = $"Archive entry '{entry.Name}' escapes extraction root.";
 03081                return false;
 3082            }
 3083
 53084            if (entry.EntryType is TarEntryType.Directory)
 3085            {
 03086                _ = Directory.CreateDirectory(fullDestinationPath);
 03087                continue;
 3088            }
 3089
 53090            if (entry.EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile)
 3091            {
 53092                var parentDirectory = Path.GetDirectoryName(fullDestinationPath);
 53093                if (!string.IsNullOrWhiteSpace(parentDirectory))
 3094                {
 53095                    _ = Directory.CreateDirectory(parentDirectory);
 3096                }
 3097
 53098                if (entry.DataStream is null)
 3099                {
 03100                    using var emptyFile = File.Create(fullDestinationPath);
 03101                    continue;
 3102                }
 3103
 53104                using var output = File.Create(fullDestinationPath);
 53105                entry.DataStream.CopyTo(output);
 53106                continue;
 3107            }
 3108        }
 3109
 23110        return true;
 23111    }
 3112
 3113    /// <summary>
 3114    /// Checks whether a path is inside (or equal to) a given directory.
 3115    /// </summary>
 3116    /// <param name="candidatePath">Candidate absolute path.</param>
 3117    /// <param name="directoryPath">Directory absolute path.</param>
 3118    /// <returns>True when candidate is within the directory tree.</returns>
 3119    private static bool IsPathWithinDirectory(string candidatePath, string directoryPath)
 3120    {
 813121        var fullCandidate = Path.GetFullPath(candidatePath)
 813122            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 813123        var fullDirectory = Path.GetFullPath(directoryPath)
 813124            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 813125        var comparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
 813126            ? StringComparison.OrdinalIgnoreCase
 813127            : StringComparison.Ordinal;
 3128
 813129        return fullCandidate.Equals(fullDirectory, comparison)
 813130            || fullCandidate.StartsWith(fullDirectory + Path.DirectorySeparatorChar, comparison)
 813131            || fullCandidate.StartsWith(fullDirectory + Path.AltDirectorySeparatorChar, comparison);
 3132    }
 3133
 3134    /// <summary>
 3135    /// Resolves the deployment root path used for service bundles.
 3136    /// </summary>
 3137    /// <param name="deploymentRootOverride">Optional explicit root path.</param>
 3138    /// <param name="deploymentRoot">Resolved writable deployment root.</param>
 3139    /// <param name="error">Error details when no writable root is available.</param>
 3140    /// <returns>True when a writable deployment root is resolved.</returns>
 3141    private static bool TryResolveServiceDeploymentRoot(string? deploymentRootOverride, out string deploymentRoot, out s
 3142    {
 63143        deploymentRoot = string.Empty;
 63144        error = string.Empty;
 3145
 63146        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 3147        {
 63148            var overrideRoot = Path.GetFullPath(deploymentRootOverride);
 63149            if (!TryEnsureDirectoryWritable(overrideRoot, out var overrideError))
 3150            {
 13151                error = $"Unable to use deployment root '{deploymentRootOverride}': {overrideError}";
 13152                return false;
 3153            }
 3154
 53155            deploymentRoot = overrideRoot;
 53156            return true;
 3157        }
 3158
 03159        var failures = new List<string>();
 03160        foreach (var candidate in GetServiceDeploymentRootCandidates())
 3161        {
 03162            if (string.IsNullOrWhiteSpace(candidate))
 3163            {
 3164                continue;
 3165            }
 3166
 3167            try
 3168            {
 03169                var fullCandidate = Path.GetFullPath(candidate);
 03170                if (!TryEnsureDirectoryWritable(fullCandidate, out var candidateError))
 3171                {
 03172                    failures.Add($"{candidate} ({candidateError})");
 03173                    continue;
 3174                }
 3175
 03176                deploymentRoot = fullCandidate;
 03177                return true;
 3178            }
 03179            catch (Exception ex)
 3180            {
 03181                failures.Add($"{candidate} ({ex.Message})");
 03182            }
 3183        }
 3184
 03185        error = failures.Count == 0
 03186            ? "Unable to resolve a writable service deployment root."
 03187            : $"Unable to resolve a writable service deployment root. Attempted: {string.Join("; ", failures)}";
 03188        return false;
 03189    }
 3190
 3191    /// <summary>
 3192    /// Ensures a directory is writable by creating and deleting a short-lived probe file.
 3193    /// </summary>
 3194    /// <param name="directoryPath">Directory path to validate.</param>
 3195    /// <param name="error">Error details when the path is not writable.</param>
 3196    /// <returns>True when the directory can be created and written to.</returns>
 3197    private static bool TryEnsureDirectoryWritable(string directoryPath, out string error)
 3198    {
 63199        error = string.Empty;
 3200
 3201        try
 3202        {
 63203            _ = Directory.CreateDirectory(directoryPath);
 53204            var probePath = Path.Combine(directoryPath, $".kestrun-write-probe-{Guid.NewGuid():N}");
 53205            File.WriteAllText(probePath, "ok");
 53206            File.Delete(probePath);
 53207            return true;
 3208        }
 13209        catch (Exception ex)
 3210        {
 13211            error = ex.Message;
 13212            return false;
 3213        }
 63214    }
 3215
 3216    /// <summary>
 3217    /// Returns candidate deployment roots for service bundle storage.
 3218    /// </summary>
 3219    /// <returns>Candidate absolute or rooted paths in priority order.</returns>
 3220    private static IEnumerable<string> GetServiceDeploymentRootCandidates()
 3221    {
 103222        if (OperatingSystem.IsWindows())
 3223        {
 03224            yield return Path.Combine(
 03225                Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
 03226                ServiceDeploymentProductFolderName,
 03227                ServiceDeploymentServicesFolderName);
 03228            yield break;
 3229        }
 3230
 103231        if (OperatingSystem.IsLinux())
 3232        {
 103233            yield return "/var/kestrun/services";
 103234            yield return "/usr/local/kestrun/services";
 3235
 103236            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 103237            if (!string.IsNullOrWhiteSpace(userProfile))
 3238            {
 103239                yield return Path.Combine(userProfile, ".local", "share", "kestrun", "services");
 3240            }
 3241
 103242            yield break;
 3243        }
 3244
 03245        if (OperatingSystem.IsMacOS())
 3246        {
 03247            yield return "/usr/local/kestrun/services";
 3248
 03249            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 03250            if (!string.IsNullOrWhiteSpace(userProfile))
 3251            {
 03252                yield return Path.Combine(userProfile, "Library", "Application Support", "Kestrun", "services");
 3253            }
 3254
 03255            yield break;
 3256        }
 3257
 03258        yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Kestrun", 
 03259    }
 3260
 3261    /// <summary>
 3262    /// Removes service bundle directories for a given service name from known deployment roots.
 3263    /// </summary>
 3264    /// <param name="serviceName">Service name.</param>
 3265    private static void TryRemoveServiceBundle(string serviceName, string? deploymentRootOverride = null)
 3266    {
 13267        var serviceDirectoryName = GetServiceDeploymentDirectoryName(serviceName);
 13268        var printedPermissionHint = false;
 3269
 13270        var candidateRoots = new List<string>();
 13271        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 3272        {
 13273            candidateRoots.Add(deploymentRootOverride);
 3274        }
 3275
 13276        candidateRoots.AddRange(GetServiceDeploymentRootCandidates());
 3277
 103278        foreach (var candidateRoot in candidateRoots.Distinct(StringComparer.OrdinalIgnoreCase))
 3279        {
 43280            if (string.IsNullOrWhiteSpace(candidateRoot))
 3281            {
 3282                continue;
 3283            }
 3284
 43285            var serviceRoot = Path.Combine(candidateRoot, serviceDirectoryName);
 3286            try
 3287            {
 43288                if (OperatingSystem.IsWindows())
 3289                {
 03290                    TryDeleteDirectoryWithRetry(serviceRoot, maxAttempts: 15, initialDelayMs: 250);
 3291                }
 3292                else
 3293                {
 43294                    TryDeleteDirectoryWithRetry(serviceRoot);
 3295                }
 43296            }
 03297            catch (Exception ex)
 3298            {
 03299                if (IsExpectedUnixProtectedRootCleanupFailure(candidateRoot, ex, deploymentRootOverride))
 3300                {
 03301                    if (!printedPermissionHint)
 3302                    {
 03303                        Console.Error.WriteLine("Info: Skipping cleanup of root-owned service bundle locations. Use sudo
 03304                        printedPermissionHint = true;
 3305                    }
 3306
 03307                    continue;
 3308                }
 3309
 03310                Console.Error.WriteLine($"Warning: Failed to remove service bundle '{serviceRoot}': {ex.Message}");
 03311            }
 3312        }
 13313    }
 3314
 3315    /// <summary>
 3316    /// Returns true when cleanup failures are expected for protected Unix roots owned by another user.
 3317    /// </summary>
 3318    /// <param name="candidateRoot">Deployment root candidate being cleaned.</param>
 3319    /// <param name="exception">Raised exception.</param>
 3320    /// <param name="deploymentRootOverride">Optional explicit deployment root override.</param>
 3321    /// <returns>True when the error can be downgraded to informational output.</returns>
 3322    private static bool IsExpectedUnixProtectedRootCleanupFailure(string candidateRoot, Exception exception, string? dep
 3323    {
 03324        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 3325        {
 03326            return false;
 3327        }
 3328
 03329        if (exception is not UnauthorizedAccessException)
 3330        {
 03331            return false;
 3332        }
 3333
 03334        if (!(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()))
 3335        {
 03336            return false;
 3337        }
 3338
 03339        if (IsLikelyRunningAsRootOnUnix())
 3340        {
 03341            return false;
 3342        }
 3343
 3344        // Cleanup failures for protected system roots are expected when running as a non-root user on Unix, so downgrad
 03345        return IsProtectedUnixServiceRoot(candidateRoot);
 3346    }
 3347
 3348    /// <summary>
 3349    /// Returns true when the path is a protected system root used for service bundle fallback on Unix.
 3350    /// </summary>
 3351    /// <param name="candidateRoot">Deployment root candidate.</param>
 3352    /// <returns>True when path is a protected system root.</returns>
 3353    private static bool IsProtectedUnixServiceRoot(string candidateRoot)
 3354    {
 03355        if (string.IsNullOrWhiteSpace(candidateRoot))
 3356        {
 03357            return false;
 3358        }
 3359
 03360        var fullCandidate = Path.GetFullPath(candidateRoot)
 03361            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 3362
 03363        return string.Equals(fullCandidate, "/var/kestrun/services", StringComparison.Ordinal)
 03364            || string.Equals(fullCandidate, "/usr/local/kestrun/services", StringComparison.Ordinal);
 3365    }
 3366
 3367    /// <summary>
 3368    /// Deletes a directory recursively with retry/backoff for transient file-lock scenarios.
 3369    /// </summary>
 3370    /// <param name="directoryPath">Directory path to delete.</param>
 3371    /// <param name="maxAttempts">Maximum number of attempts.</param>
 3372    /// <param name="initialDelayMs">Initial delay between attempts in milliseconds.</param>
 3373    private static void TryDeleteDirectoryWithRetry(string directoryPath, int maxAttempts = 5, int initialDelayMs = 200)
 3374    {
 733375        if (!Directory.Exists(directoryPath))
 3376        {
 43377            return;
 3378        }
 3379
 693380        var attempt = 0;
 693381        var delayMs = initialDelayMs;
 693382        Exception? lastError = null;
 3383
 693384        while (attempt < maxAttempts)
 3385        {
 3386            try
 3387            {
 693388                Directory.Delete(directoryPath, recursive: true);
 693389                return;
 3390            }
 03391            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
 3392            {
 03393                lastError = ex;
 03394                attempt += 1;
 03395                if (attempt >= maxAttempts)
 3396                {
 03397                    break;
 3398                }
 3399
 03400                Thread.Sleep(delayMs);
 03401                delayMs = Math.Min(delayMs * 2, 2000);
 03402            }
 3403        }
 3404
 03405        if (lastError is not null)
 3406        {
 03407            throw lastError;
 3408        }
 693409    }
 3410
 3411    /// <summary>
 3412    /// Returns a filesystem-safe directory name for service deployment folders.
 3413    /// </summary>
 3414    /// <param name="serviceName">Service name.</param>
 3415    /// <returns>Sanitized directory name.</returns>
 3416    private static string GetServiceDeploymentDirectoryName(string serviceName)
 3417    {
 153418        var invalid = Path.GetInvalidFileNameChars();
 153419        var builder = new StringBuilder(serviceName.Length);
 4263420        foreach (var ch in serviceName)
 3421        {
 1983422            if (char.IsControl(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar || inval
 3423            {
 03424                _ = builder.Append('-');
 03425                continue;
 3426            }
 3427
 1983428            _ = builder.Append(ch);
 3429        }
 3430
 153431        var sanitized = builder.ToString().Trim().Trim('.');
 153432        return string.IsNullOrWhiteSpace(sanitized) ? "service" : sanitized;
 3433    }
 3434
 3435    /// <summary>
 3436    /// Resolves runtime RID segment for service runtime payloads.
 3437    /// </summary>
 3438    /// <param name="runtimeRid">RID segment (for example win-x64).</param>
 3439    /// <param name="error">Error details when runtime architecture is unsupported.</param>
 3440    /// <returns>True when runtime RID can be resolved.</returns>
 3441    private static bool TryGetServiceRuntimeRid(out string runtimeRid, out string error)
 3442    {
 443443        runtimeRid = string.Empty;
 443444        error = string.Empty;
 3445
 443446        var osPrefix = OperatingSystem.IsWindows()
 443447            ? "win"
 443448            : OperatingSystem.IsLinux()
 443449                ? "linux"
 443450                : OperatingSystem.IsMacOS()
 443451                    ? "osx"
 443452                    : string.Empty;
 3453
 443454        if (string.IsNullOrWhiteSpace(osPrefix))
 3455        {
 03456            error = "Service runtime bundling is not supported on this operating system.";
 03457            return false;
 3458        }
 3459
 443460        var architecture = RuntimeInformation.ProcessArchitecture switch
 443461        {
 443462            Architecture.X64 => "x64",
 03463            Architecture.Arm64 => "arm64",
 03464            _ => string.Empty,
 443465        };
 3466
 443467        if (string.IsNullOrWhiteSpace(architecture))
 3468        {
 03469            error = $"Service runtime bundling does not support process architecture '{RuntimeInformation.ProcessArchite
 03470            return false;
 3471        }
 3472
 443473        runtimeRid = $"{osPrefix}-{architecture}";
 443474        return true;
 3475    }
 3476
 3477    /// <summary>
 3478    /// Ensures execute permissions are present for service runtime files on Unix platforms.
 3479    /// </summary>
 3480    /// <param name="runtimePath">Runtime executable file path.</param>
 3481    [SupportedOSPlatform("linux")]
 3482    [SupportedOSPlatform("macos")]
 3483    private static void TryEnsureServiceRuntimeExecutablePermissions(string runtimePath)
 3484    {
 3485        try
 3486        {
 83487            var mode = File.GetUnixFileMode(runtimePath);
 83488            mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
 83489            File.SetUnixFileMode(runtimePath, mode);
 83490        }
 03491        catch
 3492        {
 3493            // Ignore permission update failures and let service startup report execution errors if needed.
 03494        }
 83495    }
 3496
 3497    /// <summary>
 3498    /// Normalizes service log path input; directory input gets the default file name.
 3499    /// </summary>
 3500    /// <param name="inputPath">Configured path input.</param>
 3501    /// <param name="defaultFileName">Default file name for directory-only inputs.</param>
 3502    /// <returns>Absolute log file path.</returns>
 3503    private static string NormalizeServiceLogPath(string inputPath, string defaultFileName)
 3504    {
 63505        var fullPath = Path.GetFullPath(inputPath);
 63506        return Directory.Exists(fullPath)
 63507            || inputPath.EndsWith('\\')
 63508            || inputPath.EndsWith('/')
 63509            ? Path.Combine(fullPath, defaultFileName)
 63510            : fullPath;
 3511    }
 3512
 3513    /// <summary>
 3514    /// Escapes XML-sensitive characters.
 3515    /// </summary>
 3516    /// <param name="input">Raw input string.</param>
 3517    /// <returns>Escaped XML value.</returns>
 3518    private static string EscapeXml(string input)
 3519    {
 113520        return input
 113521            .Replace("&", "&amp;", StringComparison.Ordinal)
 113522            .Replace("<", "&lt;", StringComparison.Ordinal)
 113523            .Replace(">", "&gt;", StringComparison.Ordinal)
 113524            .Replace("\"", "&quot;", StringComparison.Ordinal)
 113525            .Replace("'", "&apos;", StringComparison.Ordinal);
 3526    }
 3527
 3528    /// <summary>
 3529    /// Escapes one token for systemd ExecStart parsing.
 3530    /// </summary>
 3531    /// <param name="input">Raw token.</param>
 3532    /// <returns>Escaped token.</returns>
 3533    private static string EscapeSystemdToken(string input)
 3534    {
 173535        if (string.IsNullOrEmpty(input))
 3536        {
 03537            return "\"\"";
 3538        }
 3539
 173540        var escaped = input
 173541            .Replace("\\", "\\\\", StringComparison.Ordinal)
 173542            .Replace(" ", "\\ ", StringComparison.Ordinal)
 173543            .Replace("\"", "\\\"", StringComparison.Ordinal)
 173544            .Replace("'", "\\'", StringComparison.Ordinal)
 173545            .Replace(";", "\\;", StringComparison.Ordinal)
 173546            .Replace("$", "\\$", StringComparison.Ordinal);
 3547
 173548        return escaped;
 3549    }
 3550
 3551    /// <summary>
 3552    /// Builds a Windows command-line string with proper escaping for each token.
 3553    /// </summary>
 3554    /// <param name="exePath">Executable path.</param>
 3555    /// <param name="args">Command-line arguments.</param>
 3556    /// <returns>Full command line string.</returns>
 3557    private static string BuildWindowsCommandLine(string exePath, IReadOnlyList<string> args)
 3558    {
 13559        var all = new List<string>(1 + args.Count) { exePath };
 13560        all.AddRange(args);
 13561        return string.Join(" ", all.Select(EscapeWindowsCommandLineArgument));
 3562    }
 3563
 3564    /// <summary>
 3565    /// Escapes one command-line argument using Windows CreateProcess rules.
 3566    /// </summary>
 3567    /// <param name="arg">Input argument.</param>
 3568    /// <returns>Escaped argument string.</returns>
 3569    private static string EscapeWindowsCommandLineArgument(string arg)
 3570    {
 43571        if (arg.Length == 0)
 3572        {
 03573            return "\"\"";
 3574        }
 3575
 323576        var requiresQuotes = arg.Any(c => char.IsWhiteSpace(c) || c == '"');
 43577        if (!requiresQuotes)
 3578        {
 13579            return arg;
 3580        }
 3581
 33582        var result = new StringBuilder(arg.Length + 2);
 33583        _ = result.Append('"');
 33584        var backslashes = 0;
 1243585        foreach (var c in arg)
 3586        {
 593587            if (c == '\\')
 3588            {
 03589                backslashes += 1;
 03590                continue;
 3591            }
 3592
 593593            if (c == '"')
 3594            {
 03595                _ = result.Append('\\', (backslashes * 2) + 1);
 03596                _ = result.Append('"');
 03597                backslashes = 0;
 03598                continue;
 3599            }
 3600
 593601            if (backslashes > 0)
 3602            {
 03603                _ = result.Append('\\', backslashes);
 03604                backslashes = 0;
 3605            }
 3606
 593607            _ = result.Append(c);
 3608        }
 3609
 33610        if (backslashes > 0)
 3611        {
 03612            _ = result.Append('\\', backslashes * 2);
 3613        }
 3614
 33615        _ = result.Append('"');
 33616        return result.ToString();
 3617    }
 3618
 3619    /// <summary>
 3620    /// Builds a normalized systemd unit name.
 3621    /// </summary>
 3622    /// <param name="serviceName">Input service name.</param>
 3623    /// <returns>Sanitized unit file name.</returns>
 3624    private static string GetLinuxUnitName(string serviceName)
 3625    {
 6613626        var safeName = new string([.. serviceName.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' ? c : '-
 3627
 233628        return safeName.EndsWith(".service", StringComparison.OrdinalIgnoreCase)
 233629            ? safeName
 233630            : $"{safeName}.service";
 3631    }
 3632
 3633    /// <summary>
 3634    /// Returns true when the current Linux process is likely running as root.
 3635    /// </summary>
 3636    /// <returns>True when username resolves to root on Linux.</returns>
 13637    private static bool IsLikelyRunningAsRootOnLinux() => OperatingSystem.IsLinux() && string.Equals(Environment.UserNam
 3638
 3639    /// <summary>
 3640    /// Returns true when the current Unix process is likely running as root.
 3641    /// </summary>
 3642    /// <returns>True when username resolves to root on Linux or macOS.</returns>
 03643    private static bool IsLikelyRunningAsRootOnUnix() => (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
 03644        && string.Equals(Environment.UserName, "root", StringComparison.Ordinal);
 3645
 3646    /// <summary>
 3647    /// Writes actionable guidance for common user-level systemd failures on Linux.
 3648    /// </summary>
 3649    /// <param name="result">Captured process result from a failed systemctl --user call.</param>
 3650    private static void WriteLinuxUserSystemdFailureHint(ProcessResult result)
 3651    {
 13652        if (!OperatingSystem.IsLinux())
 3653        {
 03654            return;
 3655        }
 3656
 13657        var diagnostics = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
 13658        if (string.IsNullOrWhiteSpace(diagnostics))
 3659        {
 03660            return;
 3661        }
 3662
 13663        if (!diagnostics.Contains("Failed to connect to bus", StringComparison.OrdinalIgnoreCase)
 13664            && !diagnostics.Contains("No medium found", StringComparison.OrdinalIgnoreCase)
 13665            && !diagnostics.Contains("Access denied", StringComparison.OrdinalIgnoreCase)
 13666            && !diagnostics.Contains("Permission denied", StringComparison.OrdinalIgnoreCase))
 3667        {
 03668            return;
 3669        }
 3670
 13671        Console.Error.WriteLine("Hint: Linux service commands use user-level systemd units (systemctl --user).");
 13672        Console.Error.WriteLine("Run install/start/stop/query/remove as the same non-root user that installed the unit."
 13673        Console.Error.WriteLine("If running over SSH or a headless session, enable linger: sudo loginctl enable-linger <
 13674    }
 3675
 3676    /// <summary>
 3677    /// Runs a process and captures output for diagnostics.
 3678    /// </summary>
 3679    /// <param name="fileName">Executable to run.</param>
 3680    /// <param name="arguments">Argument tokens.</param>
 3681    /// <returns>Process result data.</returns>
 3682    private static ProcessResult RunProcess(string fileName, IReadOnlyList<string> arguments, bool writeStandardOutput =
 3683    {
 113684        var startInfo = new ProcessStartInfo
 113685        {
 113686            FileName = fileName,
 113687            UseShellExecute = false,
 113688            RedirectStandardOutput = true,
 113689            RedirectStandardError = true,
 113690            CreateNoWindow = true,
 113691        };
 3692
 803693        foreach (var argument in arguments)
 3694        {
 293695            startInfo.ArgumentList.Add(argument);
 3696        }
 3697
 113698        using var process = Process.Start(startInfo);
 93699        if (process is null)
 3700        {
 03701            return new ProcessResult(1, string.Empty, $"Failed to start process: {fileName}");
 3702        }
 3703
 93704        var output = process.StandardOutput.ReadToEnd();
 93705        var error = process.StandardError.ReadToEnd();
 93706        process.WaitForExit();
 3707
 93708        if (writeStandardOutput && !string.IsNullOrWhiteSpace(output))
 3709        {
 13710            Console.WriteLine(output.TrimEnd());
 3711        }
 3712
 93713        return new ProcessResult(process.ExitCode, output, error);
 93714    }
 3715
 3716    /// <summary>
 3717    /// Captures child process execution results.
 3718    /// </summary>
 3719    /// <param name="ExitCode">Process exit code.</param>
 3720    /// <param name="Output">Captured standard output.</param>
 3721    /// <param name="Error">Captured standard error.</param>
 533722    private sealed record ProcessResult(int ExitCode, string Output, string Error);
 3723
 3724    /// <summary>
 3725    /// Resolves a module manifest path for run mode, preferring bundled service payload when no explicit path is provid
 3726    /// </summary>
 3727    /// <param name="kestrunManifestPath">Optional explicit manifest path.</param>
 3728    /// <param name="kestrunFolder">Optional module folder path.</param>
 3729    /// <returns>Absolute path to the resolved module manifest, or null when not found.</returns>
 3730    private static string? ResolveRunModuleManifestPath(string? kestrunManifestPath, string? kestrunFolder)
 3731    {
 03732        if (!string.IsNullOrWhiteSpace(kestrunManifestPath) || !string.IsNullOrWhiteSpace(kestrunFolder))
 3733        {
 03734            return LocateModuleManifest(kestrunManifestPath, kestrunFolder);
 3735        }
 3736
 03737        if (TryResolvePowerShellModulesPayloadFromToolDistribution(out var modulesPayloadPath))
 3738        {
 03739            var bundledManifestPath = Path.Combine(modulesPayloadPath, ModuleName, ModuleManifestFileName);
 03740            if (File.Exists(bundledManifestPath))
 3741            {
 03742                return Path.GetFullPath(bundledManifestPath);
 3743            }
 3744        }
 3745
 03746        return LocateModuleManifest(null, null);
 3747    }
 3748
 3749    /// <summary>
 3750    /// Builds arguments for direct foreground run mode on the dedicated service-host executable.
 3751    /// </summary>
 3752    /// <param name="runnerExecutablePath">Runner executable path.</param>
 3753    /// <param name="scriptPath">Absolute script path.</param>
 3754    /// <param name="moduleManifestPath">Absolute module manifest path.</param>
 3755    /// <param name="scriptArguments">Script arguments.</param>
 3756    /// <param name="discoverPowerShellHome">When true, pass --discover-pshome.</param>
 3757    /// <returns>Ordered argument list.</returns>
 3758    private static IReadOnlyList<string> BuildDedicatedServiceHostRunArguments(
 3759        string runnerExecutablePath,
 3760        string scriptPath,
 3761        string moduleManifestPath,
 3762        IReadOnlyList<string> scriptArguments,
 3763        bool discoverPowerShellHome)
 3764    {
 03765        var arguments = new List<string>(12 + scriptArguments.Count)
 03766        {
 03767            "--runner-exe",
 03768            Path.GetFullPath(runnerExecutablePath),
 03769            "--run",
 03770            Path.GetFullPath(scriptPath),
 03771            "--kestrun-manifest",
 03772            Path.GetFullPath(moduleManifestPath),
 03773        };
 3774
 03775        if (discoverPowerShellHome)
 3776        {
 03777            arguments.Add("--discover-pshome");
 3778        }
 3779
 03780        if (scriptArguments.Count > 0)
 3781        {
 03782            arguments.Add("--arguments");
 03783            arguments.AddRange(scriptArguments);
 3784        }
 3785
 03786        return arguments;
 3787    }
 3788
 3789    /// <summary>
 3790    /// Resolves whether service-host should auto-discover PSHOME for the selected manifest path.
 3791    /// </summary>
 3792    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param>
 3793    /// <returns>True when --discover-pshome should be used.</returns>
 3794    private static bool ShouldDiscoverPowerShellHomeForManifest(string moduleManifestPath)
 3795    {
 03796        var fullManifestPath = Path.GetFullPath(moduleManifestPath);
 03797        var moduleDirectory = Path.GetDirectoryName(fullManifestPath);
 03798        if (string.IsNullOrWhiteSpace(moduleDirectory))
 3799        {
 03800            return true;
 3801        }
 3802
 03803        var moduleRoot = Directory.GetParent(moduleDirectory);
 03804        var serviceRoot = moduleRoot?.Parent?.FullName;
 03805        if (string.IsNullOrWhiteSpace(serviceRoot))
 3806        {
 03807            return true;
 3808        }
 3809
 03810        var modulesDirectory = Path.Combine(serviceRoot, "Modules");
 03811        return !Directory.Exists(modulesDirectory);
 3812    }
 3813
 3814    /// <summary>
 3815    /// Resolves the current executable path when available, otherwise falls back to the provided value.
 3816    /// </summary>
 3817    /// <param name="fallbackPath">Fallback path when current process path is unavailable.</param>
 3818    /// <returns>Absolute executable path.</returns>
 3819    private static string ResolveCurrentProcessPathOrFallback(string fallbackPath)
 03820        => !string.IsNullOrWhiteSpace(Environment.ProcessPath) && File.Exists(Environment.ProcessPath)
 03821            ? Path.GetFullPath(Environment.ProcessPath)
 03822            : Path.GetFullPath(fallbackPath);
 3823
 3824    /// <summary>
 3825    /// Parses runner-specific global options and returns arguments to pass into command parsing.
 3826    /// </summary>
 3827    /// <param name="args">Raw process arguments.</param>
 3828    /// <returns>Normalized argument set and global option flags.</returns>
 3829    private static GlobalOptions ParseGlobalOptions(string[] args)
 3830    {
 43831        var commandArgs = new List<string>(args.Length);
 43832        var skipGalleryCheck = false;
 43833        var passthroughArguments = false;
 3834
 283835        foreach (var arg in args)
 3836        {
 103837            if (!passthroughArguments && arg is "--arguments" or "--")
 3838            {
 13839                passthroughArguments = true;
 13840                commandArgs.Add(arg);
 13841                continue;
 3842            }
 3843
 93844            if (!passthroughArguments && IsNoCheckOption(arg))
 3845            {
 13846                skipGalleryCheck = true;
 13847                continue;
 3848            }
 3849
 83850            commandArgs.Add(arg);
 3851        }
 3852
 43853        return new GlobalOptions([.. commandArgs], skipGalleryCheck);
 3854    }
 3855
 3856    /// <summary>
 3857    /// Determines whether the token disables gallery version checks.
 3858    /// </summary>
 3859    /// <param name="token">Argument token.</param>
 3860    /// <returns>True when the token disables gallery checks.</returns>
 3861    private static bool IsNoCheckOption(string token)
 253862        => string.Equals(token, NoCheckOption, StringComparison.OrdinalIgnoreCase)
 253863            || string.Equals(token, NoCheckAliasOption, StringComparison.OrdinalIgnoreCase);
 3864
 3865    /// <summary>
 3866    /// Validates install preconditions for module install operations.
 3867    /// </summary>
 3868    /// <param name="moduleRoot">Root folder for module versions.</param>
 3869    /// <param name="scopeToken">Module scope token for messaging.</param>
 3870    /// <param name="errorText">Validation error details when install should not proceed.</param>
 3871    /// <returns>True when install can proceed.</returns>
 3872    private static bool TryValidateInstallAction(string moduleRoot, string scopeToken, out string errorText)
 3873    {
 13874        errorText = string.Empty;
 13875        if (GetInstalledModuleRecords(moduleRoot).Count == 0)
 3876        {
 03877            return true;
 3878        }
 3879
 13880        errorText = $"{ModuleName} module is already installed in {scopeToken} scope. Use '{ProductName} module update' 
 13881        return false;
 3882    }
 3883
 3884    /// <summary>
 3885    /// Validates update preconditions for module update operations.
 3886    /// </summary>
 3887    /// <param name="moduleRoot">Root folder for module versions.</param>
 3888    /// <param name="packageVersion">Resolved target package version.</param>
 3889    /// <param name="force">When true, overwrite is allowed.</param>
 3890    /// <param name="errorText">Validation error details when update should not proceed.</param>
 3891    /// <returns>True when update can proceed.</returns>
 3892    private static bool TryValidateUpdateAction(string moduleRoot, string packageVersion, bool force, out string errorTe
 3893    {
 23894        errorText = string.Empty;
 23895        if (force)
 3896        {
 13897            return true;
 3898        }
 3899
 13900        var destinationModuleDirectory = Path.Combine(moduleRoot, packageVersion);
 13901        if (!Directory.Exists(destinationModuleDirectory))
 3902        {
 03903            return true;
 3904        }
 3905
 13906        errorText = $"Module version '{packageVersion}' is already installed at '{destinationModuleDirectory}'. Use '{Pr
 13907        return false;
 3908    }
 3909
 3910    /// <summary>
 3911    /// Reads the package version from a nupkg file payload.
 3912    /// </summary>
 3913    /// <param name="packageBytes">Package bytes.</param>
 3914    /// <param name="packageVersion">Parsed package version.</param>
 3915    /// <returns>True when a version was discovered.</returns>
 3916    private static bool TryReadPackageVersion(byte[] packageBytes, out string packageVersion)
 3917    {
 23918        packageVersion = string.Empty;
 3919
 23920        using var stream = new MemoryStream(packageBytes, writable: false);
 23921        using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
 43922        var nuspecEntry = archive.Entries.FirstOrDefault(static entry => entry.FullName.EndsWith(".nuspec", StringCompar
 23923        if (nuspecEntry is null)
 3924        {
 13925            return false;
 3926        }
 3927
 13928        using var reader = new StreamReader(nuspecEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
 13929        var nuspecText = reader.ReadToEnd();
 13930        if (string.IsNullOrWhiteSpace(nuspecText))
 3931        {
 03932            return false;
 3933        }
 3934
 13935        var document = XDocument.Parse(nuspecText);
 13936        var versionElement = document.Descendants()
 53937            .FirstOrDefault(static element => string.Equals(element.Name.LocalName, "version", StringComparison.OrdinalI
 13938        if (versionElement is null)
 3939        {
 03940            return false;
 3941        }
 3942
 13943        packageVersion = versionElement.Value.Trim();
 13944        return TryNormalizeModuleVersion(packageVersion, out packageVersion);
 23945    }
 3946
 3947    /// <summary>
 3948    /// Maps package entry paths to relative module payload paths.
 3949    /// </summary>
 3950    /// <param name="entryPath">Original package entry path.</param>
 3951    /// <param name="relativePath">Mapped relative payload path.</param>
 3952    /// <returns>True when the entry belongs to module payload content.</returns>
 3953    private static bool TryGetPackagePayloadPath(string entryPath, out string relativePath)
 3954    {
 53955        relativePath = string.Empty;
 53956        if (string.IsNullOrWhiteSpace(entryPath))
 3957        {
 03958            return false;
 3959        }
 3960
 53961        var normalizedPath = entryPath.Replace('\\', '/').TrimStart('/');
 53962        if (string.IsNullOrWhiteSpace(normalizedPath) || normalizedPath.EndsWith('/'))
 3963        {
 03964            return false;
 3965        }
 3966
 53967        if (normalizedPath.Equals("[Content_Types].xml", StringComparison.OrdinalIgnoreCase)
 53968            || normalizedPath.StartsWith("_rels/", StringComparison.OrdinalIgnoreCase)
 53969            || normalizedPath.StartsWith("package/", StringComparison.OrdinalIgnoreCase)
 53970            || normalizedPath.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase))
 3971        {
 13972            return false;
 3973        }
 3974
 43975        if (normalizedPath.StartsWith("tools/", StringComparison.OrdinalIgnoreCase))
 3976        {
 43977            normalizedPath = normalizedPath["tools/".Length..];
 3978        }
 03979        else if (normalizedPath.StartsWith("content/", StringComparison.OrdinalIgnoreCase))
 3980        {
 03981            normalizedPath = normalizedPath["content/".Length..];
 3982        }
 03983        else if (normalizedPath.StartsWith("contentFiles/any/any/", StringComparison.OrdinalIgnoreCase))
 3984        {
 03985            normalizedPath = normalizedPath["contentFiles/any/any/".Length..];
 3986        }
 3987
 43988        relativePath = normalizedPath.TrimStart('/');
 43989        return !string.IsNullOrWhiteSpace(relativePath);
 3990    }
 3991
 3992    /// <summary>
 3993    /// Copies all files recursively from one directory to another.
 3994    /// </summary>
 3995    /// <param name="sourceDirectory">Source directory.</param>
 3996    /// <param name="destinationDirectory">Destination directory.</param>
 3997    /// <param name="showProgress">When true, writes interactive progress bars.</param>
 3998    private static void CopyDirectoryContents(string sourceDirectory, string destinationDirectory, bool showProgress)
 23999        => CopyDirectoryContents(sourceDirectory, destinationDirectory, showProgress, "Installing files", exclusionPatte
 4000
 4001    /// <summary>
 4002    /// Copies all files recursively from one directory to another.
 4003    /// </summary>
 4004    /// <param name="sourceDirectory">Source directory.</param>
 4005    /// <param name="destinationDirectory">Destination directory.</param>
 4006    /// <param name="showProgress">When true, writes interactive progress bars.</param>
 4007    /// <param name="progressLabel">Progress bar label for file copy operations.</param>
 4008    /// <param name="exclusionPatterns">Optional wildcard patterns (relative to <paramref name="sourceDirectory"/>) for 
 4009    private static void CopyDirectoryContents(
 4010        string sourceDirectory,
 4011        string destinationDirectory,
 4012        bool showProgress,
 4013        string progressLabel,
 4014        IReadOnlyList<string>? exclusionPatterns)
 4015    {
 274016        _ = Directory.CreateDirectory(destinationDirectory);
 4017
 274018        var exclusionRegexes = BuildCopyExclusionRegexes(exclusionPatterns);
 274019        var sourceFilePaths = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
 424020            .Where(sourceFilePath => !ShouldExcludeCopyFile(sourceDirectory, sourceFilePath, exclusionRegexes))
 274021            .ToList();
 274022        using var copyProgress = showProgress
 274023            ? new ConsoleProgressBar(progressLabel, sourceFilePaths.Count, FormatFileProgressDetail)
 274024            : null;
 274025        var copiedFiles = 0;
 274026        copyProgress?.Report(0);
 4027
 1384028        foreach (var sourceFilePath in sourceFilePaths)
 4029        {
 424030            var relativePath = Path.GetRelativePath(sourceDirectory, sourceFilePath);
 424031            var destinationFilePath = Path.Combine(destinationDirectory, relativePath);
 424032            var destinationFileDirectory = Path.GetDirectoryName(destinationFilePath);
 424033            if (!string.IsNullOrWhiteSpace(destinationFileDirectory))
 4034            {
 424035                _ = Directory.CreateDirectory(destinationFileDirectory);
 4036            }
 4037
 424038            File.Copy(sourceFilePath, destinationFilePath, overwrite: true);
 424039            copiedFiles++;
 424040            copyProgress?.Report(copiedFiles);
 4041        }
 4042
 274043        copyProgress?.Complete(copiedFiles);
 274044    }
 4045
 4046    /// <summary>
 4047    /// Determines whether a source file should be excluded from a directory copy operation.
 4048    /// </summary>
 4049    /// <param name="sourceDirectory">Source directory root.</param>
 4050    /// <param name="sourceFilePath">Absolute source file path.</param>
 4051    /// <param name="exclusionRegexes">Compiled exclusion regexes.</param>
 4052    /// <returns>True when the file should be excluded.</returns>
 4053    private static bool ShouldExcludeCopyFile(string sourceDirectory, string sourceFilePath, IReadOnlyList<Regex> exclus
 4054    {
 424055        if (exclusionRegexes.Count == 0)
 4056        {
 324057            return false;
 4058        }
 4059
 104060        var relativePath = NormalizeCopyPath(Path.GetRelativePath(sourceDirectory, sourceFilePath));
 404061        return exclusionRegexes.Any(regex => regex.IsMatch(relativePath));
 4062    }
 4063
 4064    /// <summary>
 4065    /// Compiles wildcard exclusion patterns used by directory copy operations.
 4066    /// </summary>
 4067    /// <param name="exclusionPatterns">Wildcard exclusion patterns.</param>
 4068    /// <returns>Compiled regex list for path matching.</returns>
 4069    private static List<Regex> BuildCopyExclusionRegexes(IReadOnlyList<string>? exclusionPatterns)
 4070    {
 274071        if (exclusionPatterns is null || exclusionPatterns.Count == 0)
 4072        {
 214073            return [];
 4074        }
 4075
 64076        var regexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant;
 64077        if (OperatingSystem.IsWindows())
 4078        {
 04079            regexOptions |= RegexOptions.IgnoreCase;
 4080        }
 4081
 64082        var regexes = new List<Regex>(exclusionPatterns.Count);
 484083        foreach (var exclusionPattern in exclusionPatterns)
 4084        {
 184085            var normalizedPattern = NormalizeCopyPath(exclusionPattern);
 184086            if (string.IsNullOrWhiteSpace(normalizedPattern))
 4087            {
 4088                continue;
 4089            }
 4090
 184091            var regexPattern = $"^{Regex.Escape(normalizedPattern).Replace(@"\*", ".*").Replace(@"\?", ".")}$";
 184092            regexes.Add(new Regex(regexPattern, regexOptions, TimeSpan.FromMilliseconds(250)));
 4093        }
 4094
 64095        return regexes;
 4096    }
 4097
 4098    /// <summary>
 4099    /// Normalizes a relative path for wildcard matching.
 4100    /// </summary>
 4101    /// <param name="relativePath">Relative path or wildcard pattern.</param>
 4102    /// <returns>Normalized slash-separated path without leading dot prefixes.</returns>
 4103    private static string NormalizeCopyPath(string relativePath)
 4104    {
 284105        if (string.IsNullOrWhiteSpace(relativePath))
 4106        {
 04107            return string.Empty;
 4108        }
 4109
 284110        var normalizedPath = relativePath.Trim().Replace('\\', '/');
 284111        while (normalizedPath.StartsWith("./", StringComparison.Ordinal))
 4112        {
 04113            normalizedPath = normalizedPath[2..];
 4114        }
 4115
 284116        return normalizedPath.TrimStart('/');
 4117    }
 4118
 4119    /// <summary>
 4120    /// Removes all installed module files and folders for the selected scope.
 4121    /// </summary>
 4122    /// <param name="moduleRoot">Module root directory to remove.</param>
 4123    /// <param name="showProgress">When true, writes interactive progress bars.</param>
 4124    /// <param name="errorText">Error details when removal fails.</param>
 4125    /// <returns>True when removal succeeds.</returns>
 4126    private static bool TryRemoveInstalledModule(string moduleRoot, bool showProgress, out string errorText)
 4127    {
 24128        errorText = string.Empty;
 4129
 24130        if (!Directory.Exists(moduleRoot))
 4131        {
 14132            return true;
 4133        }
 4134
 4135        try
 4136        {
 14137            var filePaths = Directory.EnumerateFiles(moduleRoot, "*", SearchOption.AllDirectories).ToList();
 14138            using var fileProgress = showProgress
 14139                ? new ConsoleProgressBar("Removing files", filePaths.Count, FormatFileProgressDetail)
 14140                : null;
 14141            var removedFiles = 0;
 14142            fileProgress?.Report(0);
 4143
 64144            foreach (var filePath in filePaths)
 4145            {
 4146                try
 4147                {
 24148                    File.SetAttributes(filePath, FileAttributes.Normal);
 24149                }
 04150                catch
 4151                {
 4152                    // Best-effort normalization; delete may still succeed without changing attributes.
 04153                }
 4154
 24155                File.Delete(filePath);
 24156                removedFiles++;
 24157                fileProgress?.Report(removedFiles);
 4158            }
 4159
 14160            fileProgress?.Complete(removedFiles);
 4161
 14162            var directoryPaths = Directory.EnumerateDirectories(moduleRoot, "*", SearchOption.AllDirectories)
 24163                .OrderByDescending(path => path.Length)
 14164                .ToList();
 4165
 14166            using var directoryProgress = showProgress
 14167                ? new ConsoleProgressBar("Removing folders", directoryPaths.Count + 1, FormatFileProgressDetail)
 14168                : null;
 14169            var removedDirectories = 0;
 14170            directoryProgress?.Report(0);
 4171
 64172            foreach (var directoryPath in directoryPaths)
 4173            {
 24174                Directory.Delete(directoryPath, recursive: false);
 24175                removedDirectories++;
 24176                directoryProgress?.Report(removedDirectories);
 4177            }
 4178
 14179            Directory.Delete(moduleRoot, recursive: false);
 14180            removedDirectories++;
 14181            directoryProgress?.Report(removedDirectories);
 14182            directoryProgress?.Complete(removedDirectories);
 4183
 14184            return true;
 4185        }
 04186        catch (Exception ex)
 4187        {
 04188            errorText = ex.Message;
 04189            return false;
 4190        }
 14191    }
 4192
 4193    /// <summary>
 4194    /// Copies one stream into another while reporting transfer progress.
 4195    /// </summary>
 4196    /// <param name="source">Source stream.</param>
 4197    /// <param name="destination">Destination stream.</param>
 4198    /// <param name="progress">Optional progress reporter.</param>
 4199    private static void CopyStreamWithProgress(Stream source, Stream destination, ConsoleProgressBar? progress)
 4200    {
 24201        var buffer = new byte[81920];
 24202        var totalCopied = 0L;
 24203        progress?.Report(0);
 4204
 04205        while (true)
 4206        {
 34207            var bytesRead = source.Read(buffer, 0, buffer.Length);
 34208            if (bytesRead <= 0)
 4209            {
 4210                break;
 4211            }
 4212
 14213            destination.Write(buffer, 0, bytesRead);
 14214            totalCopied += bytesRead;
 14215            progress?.Report(totalCopied);
 4216        }
 4217
 24218        progress?.Complete(totalCopied);
 24219    }
 4220
 4221    /// <summary>
 4222    /// Formats byte transfer progress details.
 4223    /// </summary>
 4224    /// <param name="current">Current transferred bytes.</param>
 4225    /// <param name="total">Total bytes when known.</param>
 4226    /// <returns>Formatted progress text.</returns>
 4227    private static string FormatByteProgressDetail(long current, long? total)
 14228        => total.HasValue
 14229            ? $"{FormatByteSize(current)} / {FormatByteSize(total.Value)}"
 14230            : FormatByteSize(current);
 4231
 4232    /// <summary>
 4233    /// Formats file count progress details.
 4234    /// </summary>
 4235    /// <param name="current">Current processed file count.</param>
 4236    /// <param name="total">Total file count when known.</param>
 4237    /// <returns>Formatted progress text.</returns>
 4238    private static string FormatFileProgressDetail(long current, long? total)
 14239        => total.HasValue
 14240            ? $"{current}/{total.Value} files"
 14241            : $"{current} files";
 4242
 4243    /// <summary>
 4244    /// Formats progress details for service bundle preparation steps.
 4245    /// </summary>
 4246    /// <param name="current">Current completed step number.</param>
 4247    /// <param name="total">Total step count.</param>
 4248    /// <returns>Formatted step progress detail.</returns>
 4249    private static string FormatServiceBundleStepProgressDetail(long current, long? total)
 4250    {
 14251        var stepLabel = current switch
 14252        {
 04253            <= 0 => "initializing",
 04254            1 => "creating folders",
 04255            2 => "copying runtime",
 14256            3 => "copying module",
 04257            _ => "copying script",
 14258        };
 4259
 14260        return total.HasValue
 14261            ? $"step {Math.Min(current, total.Value)}/{total.Value} ({stepLabel})"
 14262            : $"step {current} ({stepLabel})";
 4263    }
 4264
 4265    /// <summary>
 4266    /// Formats a byte value to a readable unit string.
 4267    /// </summary>
 4268    /// <param name="bytes">Byte count.</param>
 4269    /// <returns>Human-readable byte text.</returns>
 4270    private static string FormatByteSize(long bytes)
 4271    {
 34272        var unitIndex = 0;
 34273        var value = (double)Math.Max(0, bytes);
 34274        var units = new[] { "B", "KB", "MB", "GB", "TB" };
 4275
 64276        while (value >= 1024d && unitIndex < units.Length - 1)
 4277        {
 34278            value /= 1024d;
 34279            unitIndex++;
 4280        }
 4281
 34282        return unitIndex == 0
 34283            ? $"{bytes} {units[unitIndex]}"
 34284            : $"{value:0.##} {units[unitIndex]}";
 4285    }
 4286
 4287    /// <summary>
 4288    /// Prints an update warning when a newer PowerShell Gallery version exists.
 4289    /// </summary>
 4290    /// <param name="moduleManifestPath">Resolved local module manifest path.</param>
 4291    /// <param name="logPath">Optional log path for warning output; when omitted, warning is written to stderr.</param>
 4292    private static void WarnIfNewerGalleryVersionExists(string moduleManifestPath, string? logPath = null)
 4293    {
 04294        if (!TryGetLatestInstalledModuleVersionText(ModuleStorageScope.Local, out var installedVersion)
 04295            && !TryGetLatestInstalledModuleVersionText(ModuleStorageScope.Global, out installedVersion)
 04296            && !TryGetInstalledModuleVersionText(moduleManifestPath, out installedVersion))
 4297        {
 04298            return;
 4299        }
 4300
 04301        if (!TryGetLatestGalleryVersionString(out var galleryVersion, out _))
 4302        {
 04303            return;
 4304        }
 4305
 04306        if (CompareModuleVersionValues(galleryVersion, installedVersion) <= 0)
 4307        {
 04308            return;
 4309        }
 4310
 04311        var warningMessage =
 04312            $"WARNING: A newer {ModuleName} module is available on PowerShell Gallery ({galleryVersion}). "
 04313            + $"Current version: {installedVersion}. Use '{ProductName} module update' or {NoCheckOption} to suppress th
 4314
 04315        WriteWarningToLogOrConsole(warningMessage, logPath);
 04316    }
 4317
 4318    /// <summary>
 4319    /// Writes a warning to a configured log file when available; otherwise stderr.
 4320    /// </summary>
 4321    /// <param name="message">Warning message.</param>
 4322    /// <param name="logPath">Optional log path.</param>
 4323    private static void WriteWarningToLogOrConsole(string message, string? logPath)
 4324    {
 14325        switch (string.IsNullOrWhiteSpace(logPath))
 4326        {
 4327            case true:
 04328                Console.Error.WriteLine(message);
 04329                return;
 4330
 4331            case false:
 4332                try
 4333                {
 14334                    var resolvedPath = NormalizeServiceLogPath(logPath, defaultFileName: "kestrun-tool-service.log");
 14335                    var directory = Path.GetDirectoryName(resolvedPath);
 14336                    if (!string.IsNullOrWhiteSpace(directory))
 4337                    {
 14338                        _ = Directory.CreateDirectory(directory);
 4339                    }
 4340
 14341                    File.AppendAllText(resolvedPath, $"{DateTime.UtcNow:O} {message}{Environment.NewLine}", Encoding.UTF
 14342                    return;
 4343                }
 04344                catch
 4345                {
 04346                    Console.Error.WriteLine(message);
 04347                    return;
 4348                }
 4349        }
 14350    }
 4351
 4352    /// <summary>
 4353    /// Attempts to read the latest installed module version text from a selected scope.
 4354    /// </summary>
 4355    /// <param name="scope">Module storage scope.</param>
 4356    /// <param name="versionText">Installed semantic version text when available.</param>
 4357    /// <returns>True when an installed version was found in the scope.</returns>
 4358    private static bool TryGetLatestInstalledModuleVersionText(ModuleStorageScope scope, out string versionText)
 4359    {
 04360        var modulePath = GetPowerShellModulePath(scope);
 04361        var moduleRoot = Path.Combine(modulePath, ModuleName);
 04362        return TryGetLatestInstalledModuleVersionTextFromModuleRoot(moduleRoot, out versionText);
 4363    }
 4364
 4365    /// <summary>
 4366    /// Attempts to read the latest installed module version text from a specific module root path.
 4367    /// </summary>
 4368    /// <param name="moduleRoot">Root directory containing versioned module folders.</param>
 4369    /// <param name="versionText">Installed semantic version text when available.</param>
 4370    /// <returns>True when an installed version was found in the module root.</returns>
 4371    private static bool TryGetLatestInstalledModuleVersionTextFromModuleRoot(string moduleRoot, out string versionText)
 4372    {
 14373        versionText = string.Empty;
 14374        var records = GetInstalledModuleRecords(moduleRoot);
 14375        if (records.Count == 0)
 4376        {
 04377            return false;
 4378        }
 4379
 14380        versionText = records[0].Version;
 14381        return !string.IsNullOrWhiteSpace(versionText);
 4382    }
 4383
 4384    /// <summary>
 4385    /// Attempts to read the local Kestrun module semantic version from manifest metadata.
 4386    /// </summary>
 4387    /// <param name="moduleManifestPath">Path to Kestrun.psd1.</param>
 4388    /// <param name="versionText">Installed semantic version text when available.</param>
 4389    /// <returns>True when a version was read.</returns>
 4390    private static bool TryGetInstalledModuleVersionText(string moduleManifestPath, out string versionText)
 4391    {
 14392        versionText = string.Empty;
 4393
 14394        if (TryReadModuleSemanticVersionFromManifest(moduleManifestPath, out var manifestVersionText))
 4395        {
 04396            versionText = manifestVersionText;
 04397            return true;
 4398        }
 4399
 14400        var versionDirectory = Path.GetFileName(Path.GetDirectoryName(moduleManifestPath));
 14401        if (TryNormalizeModuleVersion(versionDirectory, out var normalizedVersionDirectory))
 4402        {
 14403            versionText = normalizedVersionDirectory;
 14404            return true;
 4405        }
 4406
 04407        return false;
 4408    }
 4409
 4410    /// <summary>
 4411    /// Attempts to read module semantic version (including prerelease) from a PowerShell module manifest file.
 4412    /// </summary>
 4413    /// <param name="manifestPath">Manifest path.</param>
 4414    /// <param name="versionText">Semantic version text when present.</param>
 4415    /// <returns>True when semantic version was discovered.</returns>
 4416    private static bool TryReadModuleSemanticVersionFromManifest(string manifestPath, out string versionText)
 4417    {
 94418        versionText = string.Empty;
 94419        if (!TryReadModuleVersionFromManifest(manifestPath, out var baseVersion))
 4420        {
 34421            return false;
 4422        }
 4423
 64424        var semanticVersion = baseVersion;
 4425        try
 4426        {
 64427            var content = File.ReadAllText(manifestPath);
 64428            var prereleaseMatch = ModulePrereleasePatternRegex.Match(content);
 64429            if (prereleaseMatch.Success)
 4430            {
 24431                var prereleaseValue = prereleaseMatch.Groups["value"].Value.Trim();
 24432                if (!string.IsNullOrWhiteSpace(prereleaseValue)
 24433                    && !baseVersion.Contains('-', StringComparison.Ordinal)
 24434                    && !baseVersion.Contains('+', StringComparison.Ordinal))
 4435                {
 24436                    semanticVersion = $"{baseVersion}-{prereleaseValue}";
 4437                }
 4438            }
 64439        }
 04440        catch
 4441        {
 4442            // Fall back to ModuleVersion when Prerelease inspection fails.
 04443        }
 4444
 64445        versionText = semanticVersion;
 64446        return !string.IsNullOrWhiteSpace(versionText);
 4447    }
 4448
 4449    /// <summary>
 4450    /// Attempts to query the latest Kestrun module version string from PowerShell Gallery.
 4451    /// </summary>
 4452    /// <param name="version">Latest gallery version string when available.</param>
 4453    /// <param name="errorText">Error details when discovery fails.</param>
 4454    /// <returns>True when latest gallery version was discovered.</returns>
 4455    private static bool TryGetLatestGalleryVersionString(out string version, out string errorText)
 04456        => TryGetLatestGalleryVersionStringFromClient(GalleryHttpClient, out version, out errorText);
 4457
 4458    /// <summary>
 4459    /// Attempts to query the latest Kestrun module version string from PowerShell Gallery using the specified HTTP clie
 4460    /// </summary>
 4461    /// <param name="httpClient">HTTP client used for the gallery request.</param>
 4462    /// <param name="version">Latest gallery version string when available.</param>
 4463    /// <param name="errorText">Error details when discovery fails.</param>
 4464    /// <returns>True when latest gallery version was discovered.</returns>
 4465    private static bool TryGetLatestGalleryVersionStringFromClient(HttpClient httpClient, out string version, out string
 4466    {
 14467        version = string.Empty;
 14468        if (!TryGetGalleryModuleVersionsFromClient(httpClient, out var versions, out errorText))
 4469        {
 04470            return false;
 4471        }
 4472
 14473        var latestVersion = versions[0];
 64474        for (var index = 1; index < versions.Count; index++)
 4475        {
 24476            if (CompareModuleVersionValues(versions[index], latestVersion) > 0)
 4477            {
 14478                latestVersion = versions[index];
 4479            }
 4480        }
 4481
 14482        version = latestVersion;
 14483        return !string.IsNullOrWhiteSpace(version);
 4484    }
 4485
 4486    /// <summary>
 4487    /// Queries all available Kestrun module versions from PowerShell Gallery.
 4488    /// </summary>
 4489    /// <param name="versions">Discovered gallery versions.</param>
 4490    /// <param name="errorText">Error details when discovery fails.</param>
 4491    /// <returns>True when at least one version was discovered.</returns>
 4492    private static bool TryGetGalleryModuleVersions(out List<string> versions, out string errorText)
 04493        => TryGetGalleryModuleVersionsFromClient(GalleryHttpClient, out versions, out errorText);
 4494
 4495    /// <summary>
 4496    /// Queries all available Kestrun module versions from PowerShell Gallery using the specified HTTP client.
 4497    /// </summary>
 4498    /// <param name="httpClient">HTTP client used for the gallery request.</param>
 4499    /// <param name="versions">Discovered gallery versions.</param>
 4500    /// <param name="errorText">Error details when discovery fails.</param>
 4501    /// <returns>True when at least one version was discovered.</returns>
 4502    private static bool TryGetGalleryModuleVersionsFromClient(HttpClient httpClient, out List<string> versions, out stri
 4503    {
 34504        versions = [];
 34505        errorText = string.Empty;
 4506
 4507        try
 4508        {
 34509            var requestUri = $"{PowerShellGalleryApiBaseUri}/FindPackagesById()?id='{Uri.EscapeDataString(ModuleName)}'"
 34510            using var response = httpClient.GetAsync(requestUri).GetAwaiter().GetResult();
 34511            if (!response.IsSuccessStatusCode)
 4512            {
 14513                var reason = string.IsNullOrWhiteSpace(response.ReasonPhrase)
 14514                    ? "Unknown error"
 14515                    : response.ReasonPhrase;
 14516                errorText = $"PowerShell Gallery request failed with HTTP {(int)response.StatusCode} ({reason}).";
 14517                return false;
 4518            }
 4519
 24520            var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
 24521            if (string.IsNullOrWhiteSpace(content))
 4522            {
 04523                errorText = "PowerShell Gallery response was empty.";
 04524                return false;
 4525            }
 4526
 24527            return TryParseGalleryModuleVersions(content, out versions, out errorText);
 4528        }
 04529        catch (Exception ex)
 4530        {
 04531            errorText = ex.Message;
 04532            return false;
 4533        }
 34534    }
 4535
 4536    /// <summary>
 4537    /// Parses gallery feed XML and extracts module versions.
 4538    /// </summary>
 4539    /// <param name="content">Gallery feed XML payload.</param>
 4540    /// <param name="versions">Discovered gallery versions.</param>
 4541    /// <param name="errorText">Error details when parsing fails.</param>
 4542    /// <returns>True when at least one version was discovered.</returns>
 4543    private static bool TryParseGalleryModuleVersions(string content, out List<string> versions, out string errorText)
 4544    {
 34545        versions = [];
 34546        errorText = string.Empty;
 4547
 4548        try
 4549        {
 34550            var document = XDocument.Parse(content);
 24551            var discoveredVersions = document.Descendants()
 164552                .Where(static element => string.Equals(element.Name.LocalName, "Version", StringComparison.OrdinalIgnore
 74553                .Select(static element => element.Value.Trim())
 74554                .Where(static versionText => !string.IsNullOrWhiteSpace(versionText))
 24555                .Distinct(StringComparer.OrdinalIgnoreCase)
 24556                .ToList();
 4557
 24558            if (discoveredVersions.Count == 0)
 4559            {
 04560                errorText = $"Module '{ModuleName}' was not found on PowerShell Gallery.";
 04561                return false;
 4562            }
 4563
 24564            versions = discoveredVersions;
 24565            return true;
 4566        }
 14567        catch (Exception ex)
 4568        {
 14569            errorText = ex.Message;
 14570            return false;
 4571        }
 34572    }
 4573
 4574    /// <summary>
 4575    /// Creates the shared HTTP client used for PowerShell Gallery requests.
 4576    /// </summary>
 4577    /// <returns>Configured HTTP client instance.</returns>
 4578    private static HttpClient CreateGalleryHttpClient()
 4579    {
 14580        var client = new HttpClient
 14581        {
 14582            Timeout = TimeSpan.FromSeconds(60),
 14583        };
 4584
 14585        client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0"));
 14586        return client;
 4587    }
 4588
 4589    /// <summary>
 4590    /// Creates the shared HTTP client used for service content-root archive downloads.
 4591    /// </summary>
 4592    /// <returns>Configured HTTP client instance.</returns>
 4593    private static HttpClient CreateServiceContentRootHttpClient()
 4594    {
 14595        var client = new HttpClient
 14596        {
 14597            Timeout = TimeSpan.FromMinutes(5),
 14598        };
 4599
 14600        client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0"));
 14601        return client;
 4602    }
 4603
 4604    /// <summary>
 4605    /// Parses a module version value into a comparable <see cref="Version"/> instance.
 4606    /// </summary>
 4607    /// <param name="rawValue">Raw version string.</param>
 4608    /// <param name="version">Parsed version.</param>
 4609    /// <returns>True when parsing succeeds.</returns>
 4610    private static bool TryParseVersionValue(string? rawValue, out Version version)
 4611    {
 164612        version = new Version(0, 0);
 164613        if (string.IsNullOrWhiteSpace(rawValue))
 4614        {
 04615            return false;
 4616        }
 4617
 164618        var normalized = rawValue.Trim();
 164619        var suffixIndex = normalized.IndexOfAny(['-', '+']);
 164620        if (suffixIndex >= 0)
 4621        {
 64622            normalized = normalized[..suffixIndex];
 4623        }
 4624
 164625        if (!Version.TryParse(normalized, out var parsedVersion) || parsedVersion is null)
 4626        {
 14627            return false;
 4628        }
 4629
 154630        version = parsedVersion;
 154631        return true;
 4632    }
 4633
 4634    /// <summary>
 4635    /// Normalizes a module version value to the stable numeric folder format used by PowerShell module installs.
 4636    /// </summary>
 4637    /// <param name="rawValue">Raw version token that may include prerelease/build suffixes.</param>
 4638    /// <param name="normalizedVersion">Normalized numeric version text.</param>
 4639    /// <returns>True when normalization succeeds.</returns>
 4640    private static bool TryNormalizeModuleVersion(string? rawValue, out string normalizedVersion)
 4641    {
 54642        normalizedVersion = string.Empty;
 54643        if (!TryParseVersionValue(rawValue, out var parsedVersion))
 4644        {
 04645            return false;
 4646        }
 4647
 54648        normalizedVersion = parsedVersion.ToString();
 54649        return true;
 4650    }
 4651
 4652    /// <summary>
 4653    /// Tries to parse a module storage scope token.
 4654    /// </summary>
 4655    /// <param name="scopeToken">Scope token.</param>
 4656    /// <param name="scope">Parsed scope value.</param>
 4657    /// <returns>True when parsing succeeds.</returns>
 4658    private static bool TryParseModuleScope(string? scopeToken, out ModuleStorageScope scope)
 4659    {
 34660        scope = ModuleStorageScope.Local;
 34661        if (string.IsNullOrWhiteSpace(scopeToken))
 4662        {
 04663            return false;
 4664        }
 4665
 34666        if (string.Equals(scopeToken, ModuleScopeLocalValue, StringComparison.OrdinalIgnoreCase))
 4667        {
 04668            scope = ModuleStorageScope.Local;
 04669            return true;
 4670        }
 4671
 34672        if (string.Equals(scopeToken, ModuleScopeGlobalValue, StringComparison.OrdinalIgnoreCase))
 4673        {
 24674            scope = ModuleStorageScope.Global;
 24675            return true;
 4676        }
 4677
 14678        return false;
 4679    }
 4680
 4681    /// <summary>
 4682    /// Gets a stable scope token for messages and help text.
 4683    /// </summary>
 4684    /// <param name="scope">Module storage scope.</param>
 4685    /// <returns>Normalized scope token.</returns>
 4686    private static string GetScopeToken(ModuleStorageScope scope)
 24687        => scope == ModuleStorageScope.Global ? ModuleScopeGlobalValue : ModuleScopeLocalValue;
 4688
 4689    /// <summary>
 4690    /// Compares two module version strings.
 4691    /// </summary>
 4692    /// <param name="leftVersion">Left version.</param>
 4693    /// <param name="rightVersion">Right version.</param>
 4694    /// <returns>Comparison result compatible with <see cref="IComparer{T}"/>.</returns>
 4695    private static int CompareModuleVersionValues(string? leftVersion, string? rightVersion)
 4696    {
 44697        if (ReferenceEquals(leftVersion, rightVersion))
 4698        {
 04699            return 0;
 4700        }
 4701
 44702        if (string.IsNullOrWhiteSpace(leftVersion))
 4703        {
 04704            return -1;
 4705        }
 4706
 44707        if (string.IsNullOrWhiteSpace(rightVersion))
 4708        {
 04709            return 1;
 4710        }
 4711
 44712        if (TryParseVersionValue(leftVersion, out var leftParsed)
 44713            && TryParseVersionValue(rightVersion, out var rightParsed))
 4714        {
 44715            var comparison = leftParsed.CompareTo(rightParsed);
 44716            if (comparison != 0)
 4717            {
 34718                return comparison;
 4719            }
 4720
 14721            var leftHasPrerelease = HasPrereleaseSuffix(leftVersion);
 14722            var rightHasPrerelease = HasPrereleaseSuffix(rightVersion);
 14723            if (leftHasPrerelease != rightHasPrerelease)
 4724            {
 04725                return leftHasPrerelease ? -1 : 1;
 4726            }
 4727        }
 4728
 14729        return string.Compare(leftVersion.Trim(), rightVersion.Trim(), StringComparison.OrdinalIgnoreCase);
 4730    }
 4731
 4732    /// <summary>
 4733    /// Determines whether a module version string includes prerelease suffix data.
 4734    /// </summary>
 4735    /// <param name="versionText">Version string to inspect.</param>
 4736    /// <returns>True when prerelease or build suffix exists.</returns>
 4737    private static bool HasPrereleaseSuffix(string versionText)
 24738        => versionText.Contains('-', StringComparison.Ordinal) || versionText.Contains('+', StringComparison.Ordinal);
 4739
 4740    /// <summary>
 4741    /// Reads ModuleVersion from a PowerShell module manifest file.
 4742    /// </summary>
 4743    /// <param name="manifestPath">Manifest path.</param>
 4744    /// <param name="versionText">ModuleVersion text when present.</param>
 4745    /// <returns>True when ModuleVersion was discovered.</returns>
 4746    private static bool TryReadModuleVersionFromManifest(string manifestPath, out string versionText)
 4747    {
 94748        versionText = string.Empty;
 4749
 4750        try
 4751        {
 94752            var content = File.ReadAllText(manifestPath);
 94753            var match = ModuleVersionPatternRegex.Match(content);
 94754            if (!match.Success)
 4755            {
 34756                return false;
 4757            }
 4758
 64759            versionText = match.Groups["value"].Value.Trim();
 64760            return !string.IsNullOrWhiteSpace(versionText);
 4761        }
 04762        catch
 4763        {
 04764            return false;
 4765        }
 94766    }
 4767
 4768    /// <summary>
 4769    /// Enumerates installed module manifest records from the user module root.
 4770    /// </summary>
 4771    /// <param name="moduleRoot">Module root path.</param>
 4772    /// <returns>Installed module records sorted by version descending.</returns>
 4773    private static List<InstalledModuleRecord> GetInstalledModuleRecords(string moduleRoot)
 4774    {
 44775        var records = new List<InstalledModuleRecord>();
 44776        if (!Directory.Exists(moduleRoot))
 4777        {
 04778            return records;
 4779        }
 4780
 44781        var seenManifestPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 164782        foreach (var manifestPath in Directory.EnumerateFiles(moduleRoot, ModuleManifestFileName, SearchOption.AllDirect
 4783        {
 44784            if (!seenManifestPaths.Add(manifestPath))
 4785            {
 4786                continue;
 4787            }
 4788
 44789            var versionDirectory = Path.GetFileName(Path.GetDirectoryName(manifestPath));
 44790            string? versionText = null;
 4791
 44792            if (TryReadModuleSemanticVersionFromManifest(manifestPath, out var manifestSemanticVersion))
 4793            {
 24794                versionText = manifestSemanticVersion;
 4795            }
 4796
 44797            if (string.IsNullOrWhiteSpace(versionText)
 44798                && !string.IsNullOrWhiteSpace(versionDirectory)
 44799                && TryNormalizeModuleVersion(versionDirectory, out var normalizedVersionDirectory))
 4800            {
 24801                versionText = normalizedVersionDirectory;
 4802            }
 4803
 44804            if (string.IsNullOrWhiteSpace(versionText))
 4805            {
 04806                versionText = versionDirectory;
 4807            }
 4808
 44809            if (string.IsNullOrWhiteSpace(versionText))
 4810            {
 4811                continue;
 4812            }
 4813
 44814            records.Add(new InstalledModuleRecord(versionText, manifestPath));
 4815        }
 4816
 44817        records.Sort(static (left, right) => CompareModuleVersionValues(right.Version, left.Version));
 44818        return records;
 4819    }
 4820
 4821    /// <summary>
 4822    /// Gets the module storage path for a selected scope.
 4823    /// </summary>
 4824    /// <param name="scope">Module storage scope.</param>
 4825    /// <returns>Absolute module storage path.</returns>
 4826    private static string GetPowerShellModulePath(ModuleStorageScope scope)
 04827        => scope == ModuleStorageScope.Global
 04828            ? GetGlobalPowerShellModulePath()
 04829            : GetDefaultPowerShellModulePath();
 4830
 4831    /// <summary>
 4832    /// Gets the default all-users PowerShell module path for the active OS.
 4833    /// </summary>
 4834    /// <returns>Absolute all-users module path.</returns>
 4835    private static string GetGlobalPowerShellModulePath()
 4836    {
 04837        if (OperatingSystem.IsWindows())
 4838        {
 04839            var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
 04840            var root = string.IsNullOrWhiteSpace(programFiles) ? @"C:\Program Files" : programFiles;
 04841            return Path.Combine(root, "PowerShell", "Modules");
 4842        }
 4843
 04844        return "/usr/local/share/powershell/Modules";
 4845    }
 4846
 4847    /// <summary>
 4848    /// Gets the default current-user PowerShell module path for the active OS.
 4849    /// </summary>
 4850    /// <returns>Absolute module path.</returns>
 4851    private static string GetDefaultPowerShellModulePath()
 4852    {
 14853        var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 14854        if (OperatingSystem.IsWindows())
 4855        {
 04856            var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
 04857            var root = string.IsNullOrWhiteSpace(documents) ? userHome : documents;
 04858            return Path.Combine(root, "PowerShell", "Modules");
 4859        }
 4860
 14861        return Path.Combine(userHome, ".local", "share", "powershell", "Modules");
 4862    }
 4863
 4864    /// <summary>
 4865    /// Writes a consistent module-not-found message with remediation guidance.
 4866    /// </summary>
 4867    /// <param name="kestrunManifestPath">Optional explicit manifest path argument.</param>
 4868    /// <param name="kestrunFolder">Optional explicit module folder argument.</param>
 4869    /// <param name="writeLine">Output writer callback.</param>
 4870    private static void WriteModuleNotFoundMessage(string? kestrunManifestPath, string? kestrunFolder, Action<string> wr
 4871    {
 94872        if (!string.IsNullOrWhiteSpace(kestrunManifestPath))
 4873        {
 64874            writeLine($"Unable to locate manifest file: {Path.GetFullPath(kestrunManifestPath)}");
 4875        }
 34876        else if (!string.IsNullOrWhiteSpace(kestrunFolder))
 4877        {
 14878            writeLine($"Unable to locate {ModuleManifestFileName} in folder: {Path.GetFullPath(kestrunFolder)}");
 4879        }
 4880        else
 4881        {
 24882            writeLine($"Unable to locate {ModuleManifestFileName} under the executable folder or PSModulePath.");
 4883        }
 4884
 94885        writeLine($"No {ModuleName} module was found. Use '{ProductName} module install' to install it from PowerShell G
 94886    }
 4887
 4888    /// <summary>
 4889    /// Tries to parse command-line arguments into a concrete command payload.
 4890    /// </summary>
 4891    /// <param name="args">Raw command-line arguments.</param>
 4892    /// <param name="parsedCommand">Parsed command payload.</param>
 4893    /// <param name="error">Error message when parsing fails.</param>
 4894    /// <returns>True when parsing succeeds.</returns>
 4895    private static bool TryParseArguments(string[] args, out ParsedCommand parsedCommand, out string error)
 4896    {
 954897        parsedCommand = new ParsedCommand(CommandMode.Run, string.Empty, false, [], null, null, null, false, null, null,
 954898        if (args.Length == 0)
 4899        {
 04900            error = $"No command provided. Use '{ProductName} help' to list commands.";
 04901            return false;
 4902        }
 4903
 954904        if (!TryParseLeadingKestrunOptions(args, out var commandTokenIndex, out var kestrunFolder, out var kestrunManife
 4905        {
 04906            return false;
 4907        }
 4908
 954909        if (commandTokenIndex >= args.Length)
 4910        {
 04911            error = $"No command provided. Use '{ProductName} help' to list commands.";
 04912            return false;
 4913        }
 4914
 954915        return TryParseCommandFromToken(args, commandTokenIndex, kestrunFolder, kestrunManifestPath, out parsedCommand, 
 4916    }
 4917
 4918    /// <summary>
 4919    /// Parses leading global Kestrun options that may appear before the command token.
 4920    /// </summary>
 4921    /// <param name="args">Raw command-line arguments.</param>
 4922    /// <param name="commandTokenIndex">Index of the command token after global options.</param>
 4923    /// <param name="kestrunFolder">Optional module folder path supplied via global option.</param>
 4924    /// <param name="kestrunManifestPath">Optional module manifest path supplied via global option.</param>
 4925    /// <param name="error">Error message when a required option value is missing.</param>
 4926    /// <returns>True when leading options are parsed successfully.</returns>
 4927    private static bool TryParseLeadingKestrunOptions(
 4928        string[] args,
 4929        out int commandTokenIndex,
 4930        out string? kestrunFolder,
 4931        out string? kestrunManifestPath,
 4932        out string error)
 4933    {
 974934        commandTokenIndex = 0;
 974935        kestrunFolder = null;
 974936        kestrunManifestPath = null;
 974937        error = string.Empty;
 4938
 1004939        while (commandTokenIndex < args.Length)
 4940        {
 1004941            var current = args[commandTokenIndex];
 1004942            if (current is "--kestrun-folder" or "-k")
 4943            {
 24944                if (!TryConsumeLeadingOptionValue(args, ref commandTokenIndex, "--kestrun-folder", out var folderValue, 
 4945                {
 04946                    return false;
 4947                }
 4948
 24949                kestrunFolder = folderValue;
 24950                continue;
 4951            }
 4952
 984953            if (current is "--kestrun-manifest" or "-m")
 4954            {
 24955                if (!TryConsumeLeadingOptionValue(args, ref commandTokenIndex, "--kestrun-manifest", out var manifestVal
 4956                {
 14957                    return false;
 4958                }
 4959
 14960                kestrunManifestPath = manifestValue;
 4961                continue;
 4962            }
 4963
 4964            break;
 4965        }
 4966
 964967        return true;
 4968    }
 4969
 4970    /// <summary>
 4971    /// Consumes a global option value and advances the parse index.
 4972    /// </summary>
 4973    /// <param name="args">Raw command-line arguments.</param>
 4974    /// <param name="index">Current option index, advanced when consumption succeeds.</param>
 4975    /// <param name="optionName">Canonical option name used in diagnostics.</param>
 4976    /// <param name="value">Consumed option value.</param>
 4977    /// <param name="error">Error text when the value is missing.</param>
 4978    /// <returns>True when the option value is consumed.</returns>
 4979    private static bool TryConsumeLeadingOptionValue(string[] args, ref int index, string optionName, out string value, 
 4980    {
 44981        value = string.Empty;
 44982        if (index + 1 >= args.Length)
 4983        {
 14984            error = $"Missing value for {optionName}.";
 14985            return false;
 4986        }
 4987
 34988        value = args[index + 1];
 34989        index += 2;
 34990        error = string.Empty;
 34991        return true;
 4992    }
 4993
 4994    /// <summary>
 4995    /// Dispatches command parsing based on the selected command token.
 4996    /// </summary>
 4997    /// <param name="args">Raw command-line arguments.</param>
 4998    /// <param name="commandTokenIndex">Index of the command token.</param>
 4999    /// <param name="kestrunFolder">Optional module folder path.</param>
 5000    /// <param name="kestrunManifestPath">Optional module manifest path.</param>
 5001    /// <param name="parsedCommand">Parsed command payload.</param>
 5002    /// <param name="error">Error message when dispatch fails.</param>
 5003    /// <returns>True when command parsing succeeds.</returns>
 5004    private static bool TryParseCommandFromToken(
 5005        string[] args,
 5006        int commandTokenIndex,
 5007        string? kestrunFolder,
 5008        string? kestrunManifestPath,
 5009        out ParsedCommand parsedCommand,
 5010        out string error)
 5011    {
 965012        var commandToken = args[commandTokenIndex];
 965013        if (string.Equals(commandToken, "run", StringComparison.OrdinalIgnoreCase))
 5014        {
 85015            return TryParseRunArguments(args, commandTokenIndex + 1, kestrunFolder, kestrunManifestPath, out parsedComma
 5016        }
 5017
 885018        if (string.Equals(commandToken, "service", StringComparison.OrdinalIgnoreCase))
 5019        {
 765020            return TryParseServiceArguments(args, commandTokenIndex + 1, kestrunFolder, kestrunManifestPath, out parsedC
 5021        }
 5022
 125023        if (string.Equals(commandToken, "module", StringComparison.OrdinalIgnoreCase))
 5024        {
 105025            return TryParseModuleArguments(args, commandTokenIndex + 1, out parsedCommand, out error);
 5026        }
 5027
 25028        parsedCommand = new ParsedCommand(CommandMode.Run, string.Empty, false, [], null, null, null, false, null, null,
 25029        error = $"Unknown command: {commandToken}. Use '{ProductName} help' to list commands.";
 25030        return false;
 5031    }
 5032
 5033    /// <summary>
 5034    /// Handles help/info/version command routing before command parsing.
 5035    /// </summary>
 5036    /// <param name="args">Raw command-line arguments.</param>
 5037    /// <param name="exitCode">Exit code for the handled command.</param>
 5038    /// <returns>True when a meta command was handled.</returns>
 5039    private static bool TryHandleMetaCommands(string[] args, out int exitCode)
 5040    {
 105041        exitCode = 0;
 105042        var filtered = FilterGlobalOptions(args);
 105043        if (filtered.Count == 0)
 5044        {
 15045            PrintUsage();
 15046            return true;
 5047        }
 5048
 95049        if (IsHelpToken(filtered[0]) || string.Equals(filtered[0], "help", StringComparison.OrdinalIgnoreCase))
 5050        {
 45051            if (filtered.Count == 1)
 5052            {
 25053                PrintUsage();
 25054                return true;
 5055            }
 5056
 25057            if (filtered.Count == 2 && TryGetHelpTopic(filtered[1], out var topic))
 5058            {
 15059                PrintHelpForTopic(topic);
 15060                return true;
 5061            }
 5062
 15063            Console.Error.WriteLine("Unknown help topic. Use 'kestrun help' to list available topics.");
 15064            exitCode = 2;
 15065            return true;
 5066        }
 5067
 55068        if (filtered.Count == 2
 55069            && TryGetHelpTopic(filtered[0], out var commandTopic)
 55070            && (IsHelpToken(filtered[1]) || string.Equals(filtered[1], "help", StringComparison.OrdinalIgnoreCase)))
 5071        {
 15072            PrintHelpForTopic(commandTopic);
 15073            return true;
 5074        }
 5075
 45076        if (filtered.Count == 1 && string.Equals(filtered[0], "version", StringComparison.OrdinalIgnoreCase))
 5077        {
 15078            PrintVersion();
 15079            return true;
 5080        }
 5081
 35082        if (filtered.Count == 1 && string.Equals(filtered[0], "info", StringComparison.OrdinalIgnoreCase))
 5083        {
 15084            PrintInfo();
 15085            return true;
 5086        }
 5087
 25088        return false;
 5089    }
 5090
 5091    /// <summary>
 5092    /// Checks whether an argument token requests usage help.
 5093    /// </summary>
 5094    /// <param name="token">Command-line token to inspect.</param>
 5095    /// <returns>True when the token is a help switch.</returns>
 105096    private static bool IsHelpToken(string token) => token is "-h" or "--help" or "/?";
 5097
 5098    /// <summary>
 5099    /// Tries to map a help topic token to a known command topic.
 5100    /// </summary>
 5101    /// <param name="token">Input help topic token.</param>
 5102    /// <param name="topic">Normalized topic when recognized.</param>
 5103    /// <returns>True when the topic is recognized.</returns>
 5104    private static bool TryGetHelpTopic(string token, out string topic)
 5105    {
 35106        topic = token.ToLowerInvariant();
 35107        return topic is "run" or "service" or "module" or "info" or "version";
 5108    }
 5109
 5110    /// <summary>
 5111    /// Filters known global options injected by launchers from command-line args.
 5112    /// </summary>
 5113    /// <param name="args">Raw command-line arguments.</param>
 5114    /// <returns>Arguments without known global options and their values.</returns>
 5115    private static List<string> FilterGlobalOptions(string[] args)
 5116    {
 115117        var filtered = new List<string>(args.Length);
 505118        for (var index = 0; index < args.Length; index++)
 5119        {
 145120            if (args[index] is "--kestrun-folder" or "-k" or "--kestrun-manifest" or "-m")
 5121            {
 05122                index += 1;
 05123                continue;
 5124            }
 5125
 145126            if (IsNoCheckOption(args[index]))
 5127            {
 5128                continue;
 5129            }
 5130
 135131            filtered.Add(args[index]);
 5132        }
 5133
 115134        return filtered;
 5135    }
 5136
 5137    /// <summary>
 5138    /// Prints command usage and discovery hints.
 5139    /// </summary>
 5140    private static void PrintUsage()
 5141    {
 55142        Console.WriteLine("Usage:");
 55143        Console.WriteLine("  kestrun <command> [options]");
 55144        Console.WriteLine();
 55145        Console.WriteLine("Global options:");
 55146        Console.WriteLine($"  {NoCheckOption}          Skip PowerShell Gallery update check warnings.");
 55147        Console.WriteLine();
 55148        Console.WriteLine("Commands:");
 55149        Console.WriteLine("  run       Run a PowerShell script (default script: ./Service.ps1)");
 55150        Console.WriteLine("  module    Manage Kestrun module (install/update/remove/info)");
 55151        Console.WriteLine("  service   Manage service lifecycle (install/update/remove/start/stop/query/info)");
 55152        Console.WriteLine("  info      Show runtime/build diagnostics");
 55153        Console.WriteLine("  version   Show tool version");
 55154        Console.WriteLine();
 55155        Console.WriteLine("Help topics:");
 55156        Console.WriteLine("  kestrun run help");
 55157        Console.WriteLine("  kestrun module help");
 55158        Console.WriteLine("  kestrun service help");
 55159        Console.WriteLine("  kestrun info help");
 55160        Console.WriteLine("  kestrun version help");
 55161    }
 5162
 5163    /// <summary>
 5164    /// Prints detailed help for a specific topic.
 5165    /// </summary>
 5166    /// <param name="topic">Help topic.</param>
 5167    private static void PrintHelpForTopic(string topic)
 5168    {
 5169        switch (topic)
 5170        {
 5171            case "run":
 35172                Console.WriteLine("Usage:");
 35173                Console.WriteLine("  kestrun [--nocheck] [--kestrun-folder <folder>] [--kestrun-manifest <path-to-Kestru
 35174                Console.WriteLine();
 35175                Console.WriteLine("Options:");
 35176                Console.WriteLine("  --script <path>             Optional named script path (alternative to positional <
 35177                Console.WriteLine("  --kestrun-manifest <path>   Use an explicit Kestrun.psd1 manifest file.");
 35178                Console.WriteLine("  --arguments <args...>       Pass remaining values to the script as script arguments
 35179                Console.WriteLine();
 35180                Console.WriteLine("Notes:");
 35181                Console.WriteLine("  - If no script is provided, ./Service.ps1 is used.");
 35182                Console.WriteLine("  - Script arguments must be passed after --arguments (or --).");
 35183                Console.WriteLine("  - Use --kestrun-manifest to pin a specific Kestrun.psd1 file.");
 35184                Console.WriteLine($"  - If {ModuleName} is missing, run '{ProductName} module install'.");
 35185                break;
 5186
 5187            case "module":
 15188                Console.WriteLine("Usage:");
 15189                Console.WriteLine($"  {ProductName} module install [{ModuleVersionOption} <version>] [{ModuleScopeOption
 15190                Console.WriteLine($"  {ProductName} module update [{ModuleVersionOption} <version>] [{ModuleScopeOption}
 15191                Console.WriteLine($"  {ProductName} module remove [{ModuleScopeOption} <{ModuleScopeLocalValue}|{ModuleS
 15192                Console.WriteLine($"  {ProductName} module info [{ModuleScopeOption} <{ModuleScopeLocalValue}|{ModuleSco
 15193                Console.WriteLine();
 15194                Console.WriteLine("Options:");
 15195                Console.WriteLine($"  {ModuleVersionOption} <version>      Optional specific version for install/update.
 15196                Console.WriteLine($"  {ModuleScopeOption} <scope>         Module storage scope: '{ModuleScopeLocalValue}
 15197                Console.WriteLine($"  {ModuleForceOption}                 Overwrite existing target version folder for u
 15198                Console.WriteLine();
 15199                Console.WriteLine("Notes:");
 15200                Console.WriteLine($"  - install: fails when Kestrun is already installed; use '{ProductName} module upda
 15201                Console.WriteLine($"  - update: updates to latest when no --version is provided and fails if the target 
 15202                Console.WriteLine("  - remove: removes all installed versions from the selected scope and shows deletion
 15203                Console.WriteLine("  - info: shows installed module versions and latest Gallery version for the selected
 15204                Console.WriteLine("  - Windows global scope for install/update/remove prompts for elevation (UAC) when n
 15205                break;
 5206
 5207            case "service":
 15208                Console.WriteLine("Usage:");
 15209                Console.WriteLine("  kestrun [--nocheck] [--kestrun-manifest <path-to-Kestrun.psd1>] service install [--
 15210                Console.WriteLine("  kestrun [--nocheck] service update --name <service-name> [--package <path-or-url-to
 15211                Console.WriteLine("  kestrun service remove --name <service-name>");
 15212                Console.WriteLine("  kestrun service start --name <service-name> [--json | --raw]");
 15213                Console.WriteLine("  kestrun service stop --name <service-name> [--json | --raw]");
 15214                Console.WriteLine("  kestrun service query --name <service-name> [--json | --raw]");
 15215                Console.WriteLine("  kestrun service info [--name <service-name>] [--json]");
 15216                Console.WriteLine();
 15217                Console.WriteLine("Options (service install):");
 15218                Console.WriteLine("  --package <path-or-url>     Required .krpack (zip) package containing Service.psd1 
 15219                Console.WriteLine("  --content-root-checksum <h> Verify package checksum before extraction (hex string).
 15220                Console.WriteLine("  --content-root-checksum-algorithm <name>  Hash algorithm: md5, sha1, sha256, sha384
 15221                Console.WriteLine("  --content-root-bearer-token <token>  Add Authorization: Bearer <token> for HTTP(S) 
 15222                Console.WriteLine("  --content-root-header <name:value>  Add custom HTTP request header for HTTP(S) pack
 15223                Console.WriteLine("  --content-root-ignore-certificate  Ignore HTTPS certificate validation for package 
 15224                Console.WriteLine("  --deployment-root <folder>  Override where per-service bundles are created (default
 15225                Console.WriteLine("  --runtime-source <path-or-url>  Override runtime acquisition using a local folder, 
 15226                Console.WriteLine("  --runtime-package <path>    Use an explicit local Kestrun.Service.<rid> .nupkg for 
 15227                Console.WriteLine("  --runtime-version <version> Override the runtime package version (defaults to the c
 15228                Console.WriteLine("  --runtime-package-id <id>   Override the runtime package id (defaults to Kestrun.Se
 15229                Console.WriteLine("  --runtime-cache <folder>    Override the local runtime package cache directory.");
 15230                Console.WriteLine("  --kestrun-manifest <path>   Use an explicit Kestrun.psd1 manifest for the service r
 15231                Console.WriteLine("  --service-log-path <path>   Set service bootstrap/operation log file path.");
 15232                Console.WriteLine("  --service-user <account>    Run installed service/daemon under a specific OS accoun
 15233                Console.WriteLine("  --service-password <secret> Password for --service-user on Windows service accounts
 15234                Console.WriteLine("  --arguments <args...>       Pass remaining values to the installed script.");
 15235                Console.WriteLine("  --kestrun                   For service update: use repository module at src/PowerS
 15236                Console.WriteLine("  --kestrun-module <path>     For service update: module manifest path or folder to r
 15237                Console.WriteLine("  --failback                  For service update: restore application/module from lat
 15238                Console.WriteLine("  --json                      For service start/stop/query/info: output JSON instead 
 15239                Console.WriteLine("  --raw                       For service start/stop/query: output native OS command 
 15240                Console.WriteLine();
 15241                Console.WriteLine("Notes:");
 15242                Console.WriteLine("  - install registers the service/daemon but does not auto-start it.");
 15243                Console.WriteLine("  - update fails when the service is running; stop it first.");
 15244                Console.WriteLine("  - update requires at least one of --package or --kestrun-module/--kestrun-manifest 
 15245                Console.WriteLine("  - --kestrun updates bundled module only when repository module version is newer; ot
 15246                Console.WriteLine("  - --failback restores from latest backup and fails when no backup is available.");
 15247                Console.WriteLine("  - info without --name lists installed Kestrun services.");
 15248                Console.WriteLine("  - Service name and entry point are read from Service.psd1 in the package.");
 15249                Console.WriteLine("  - Service.psd1 requires FormatVersion='1.0', Name, EntryPoint, and Description.");
 15250                Console.WriteLine("  - Package file must use .krpack extension and contain zip content.");
 15251                Console.WriteLine("  - install resolves a runtime package for the current RID using Kestrun.Service.<rid
 15252                Console.WriteLine("  - install caches canonical runtime packages under packages/<id>/<version>/<id>.<ver
 15253                Console.WriteLine("  - install can be used without --package to prefetch a runtime package into cache on
 15254                Console.WriteLine("  - install does not fall back to the runtime bundled with Kestrun.Tool when package 
 15255                Console.WriteLine("  - use --runtime-package for offline installs or --runtime-source to point at a loca
 15256                Console.WriteLine("  - --content-root-checksum is validated against the package file before extraction."
 15257                Console.WriteLine("  - --content-root-bearer-token is used for HTTP(S) package URLs and HTTP(S) runtime-
 15258                Console.WriteLine("  - --content-root-header is used for HTTP(S) package URLs and HTTP(S) runtime-source
 15259                Console.WriteLine("  - --content-root-ignore-certificate applies only to HTTPS package URLs/runtime-sour
 15260                Console.WriteLine("  - --deployment-root overrides the OS default bundle root used during install and re
 15261                Console.WriteLine("  - --service-user enables platform account mapping: Windows service account, Linux s
 15262                Console.WriteLine("  - install snapshots runtime/module/script plus the dedicated service host from the 
 15263                Console.WriteLine("  - install shows progress bars during bundle staging in interactive terminals.");
 15264                Console.WriteLine("  - bundle roots: Windows %ProgramData%\\Kestrun\\services; Linux /var/kestrun/servic
 15265                Console.WriteLine("  - remove/start/stop/query require --name and do not accept script paths.");
 15266                Console.WriteLine($"  - Use '{ProductName} module install' before service install when {ModuleName} is n
 15267                break;
 5268
 5269            case "info":
 15270                Console.WriteLine("Usage:");
 15271                Console.WriteLine("  kestrun info");
 15272                Console.WriteLine();
 15273                Console.WriteLine("Shows runtime and build diagnostics (framework, OS, architecture, and binary paths)."
 15274                break;
 5275
 5276            case "version":
 15277                Console.WriteLine("Usage:");
 15278                Console.WriteLine("  kestrun version");
 15279                Console.WriteLine();
 15280                Console.WriteLine("Shows the kestrun tool version.");
 5281                break;
 5282        }
 15283    }
 5284
 5285    /// <summary>
 5286    /// Prints the KestrunTool version.
 5287    /// </summary>
 5288    private static void PrintVersion()
 5289    {
 25290        var version = GetProductVersion();
 25291        Console.WriteLine($"{ProductName} {version}");
 25292    }
 5293
 5294    /// <summary>
 5295    /// Prints diagnostic information about the KestrunTool build and runtime.
 5296    /// </summary>
 5297    private static void PrintInfo()
 5298    {
 25299        var version = GetProductVersion();
 25300        var assembly = typeof(Program).Assembly;
 25301        var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVe
 5302
 25303        Console.WriteLine($"Product: {ProductName}");
 25304        Console.WriteLine($"Version: {version}");
 25305        Console.WriteLine($"InformationalVersion: {informationalVersion}");
 25306        Console.WriteLine($"Framework: {RuntimeInformation.FrameworkDescription}");
 25307        Console.WriteLine($"OS: {RuntimeInformation.OSDescription}");
 25308        Console.WriteLine($"OSArchitecture: {RuntimeInformation.OSArchitecture}");
 25309        Console.WriteLine($"ProcessArchitecture: {RuntimeInformation.ProcessArchitecture}");
 25310        Console.WriteLine($"ExecutableDirectory: {GetExecutableDirectory()}");
 25311        Console.WriteLine($"BaseDirectory: {Path.GetFullPath(AppContext.BaseDirectory)}");
 25312    }
 5313
 5314    /// <summary>
 5315    /// Gets the product version from assembly metadata.
 5316    /// </summary>
 5317    /// <returns>Product version string.</returns>
 5318    private static string GetProductVersion()
 5319    {
 145320        var assembly = typeof(Program).Assembly;
 145321        var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVe
 145322        return !string.IsNullOrWhiteSpace(informationalVersion) ?
 145323        informationalVersion : assembly.GetName().Version?.ToString() ?? "0.0.0";
 5324    }
 5325
 5326    /// <summary>
 5327    /// Locates Kestrun.psd1 without launching an external pwsh process.
 5328    /// </summary>
 5329    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 5330    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 5331    /// <returns>Absolute manifest path when found; otherwise null.</returns>
 5332    private static string? LocateModuleManifest(string? kestrunManifestPath, string? kestrunFolder)
 5333    {
 105334        if (!string.IsNullOrWhiteSpace(kestrunManifestPath))
 5335        {
 95336            var explicitPath = Path.GetFullPath(kestrunManifestPath);
 95337            var explicitManifest = Directory.Exists(explicitPath)
 95338                ? Path.Combine(explicitPath, ModuleManifestFileName)
 95339                : explicitPath;
 95340            return File.Exists(explicitManifest) ? explicitManifest : null;
 5341        }
 5342
 15343        if (!string.IsNullOrWhiteSpace(kestrunFolder))
 5344        {
 15345            var explicitFolder = Path.GetFullPath(kestrunFolder);
 15346            var explicitCandidate = Path.Combine(explicitFolder, ModuleManifestFileName);
 15347            return File.Exists(explicitCandidate) ? explicitCandidate : null;
 5348        }
 5349
 05350        foreach (var candidate in EnumerateExecutableManifestCandidates())
 5351        {
 05352            if (File.Exists(candidate))
 5353            {
 05354                return Path.GetFullPath(candidate);
 5355            }
 5356        }
 5357
 05358        foreach (var candidate in EnumerateModulePathManifestCandidates())
 5359        {
 05360            if (File.Exists(candidate))
 5361            {
 05362                return Path.GetFullPath(candidate);
 5363            }
 5364        }
 5365
 05366        return null;
 05367    }
 5368
 5369    /// <summary>
 5370    /// Enumerates candidate locations for Kestrun.psd1 under the executable folder.
 5371    /// </summary>
 5372    /// <returns>Absolute manifest path when found; otherwise null.</returns>
 5373    private static IEnumerable<string> EnumerateExecutableManifestCandidates()
 5374    {
 05375        var executableDirectory = GetExecutableDirectory();
 5376
 05377        yield return Path.Combine(executableDirectory, ModuleManifestFileName);
 05378        yield return Path.Combine(executableDirectory, ModuleName, ModuleManifestFileName);
 5379
 05380        var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory);
 05381        if (!string.Equals(baseDirectory, executableDirectory, StringComparison.OrdinalIgnoreCase))
 5382        {
 05383            yield return Path.Combine(baseDirectory, ModuleManifestFileName);
 05384            yield return Path.Combine(baseDirectory, ModuleName, ModuleManifestFileName);
 5385        }
 05386    }
 5387
 5388    /// <summary>
 5389    /// Gets the directory where the executable file is located.
 5390    /// </summary>
 5391    /// <returns>Absolute executable directory path.</returns>
 5392    private static string GetExecutableDirectory()
 5393    {
 235394        var processPath = Environment.ProcessPath;
 235395        if (!string.IsNullOrWhiteSpace(processPath))
 5396        {
 235397            var processDirectory = Path.GetDirectoryName(processPath);
 235398            if (!string.IsNullOrWhiteSpace(processDirectory))
 5399            {
 235400                return Path.GetFullPath(processDirectory);
 5401            }
 5402        }
 5403
 05404        return Path.GetFullPath(AppContext.BaseDirectory);
 5405    }
 5406
 5407    /// <summary>
 5408    /// Enumerates candidate manifest paths under PSModulePath entries.
 5409    /// </summary>
 5410    /// <returns>Potential manifest file paths from PSModulePath.</returns>
 5411    private static IEnumerable<string> EnumerateModulePathManifestCandidates()
 5412    {
 05413        var moduleRoots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 5414
 05415        var modulePathRaw = Environment.GetEnvironmentVariable("PSModulePath");
 05416        if (!string.IsNullOrWhiteSpace(modulePathRaw))
 5417        {
 05418            foreach (var root in modulePathRaw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringS
 5419            {
 05420                if (!string.IsNullOrWhiteSpace(root))
 5421                {
 05422                    _ = moduleRoots.Add(root);
 5423                }
 5424            }
 5425        }
 5426
 5427        // Service and elevated contexts can have an incomplete PSModulePath.
 5428        // Always include the conventional user/global module roots as discovery fallbacks.
 05429        _ = moduleRoots.Add(GetDefaultPowerShellModulePath());
 05430        _ = moduleRoots.Add(GetGlobalPowerShellModulePath());
 5431
 05432        foreach (var root in moduleRoots)
 5433        {
 05434            var moduleDirectory = Path.Combine(root, ModuleName);
 05435            yield return Path.Combine(moduleDirectory, ModuleManifestFileName);
 5436
 05437            if (!Directory.Exists(moduleDirectory))
 5438            {
 5439                continue;
 5440            }
 5441
 05442            var versionDirectories = Directory.EnumerateDirectories(moduleDirectory)
 5443                .OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase);
 5444
 05445            foreach (var versionDirectory in versionDirectories)
 5446            {
 05447                yield return Path.Combine(versionDirectory, ModuleManifestFileName);
 5448            }
 05449        }
 05450    }
 5451}

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/Program.ModuleCommands.cs

#LineLine coverage
 1using System.IO.Compression;
 2using System.Net;
 3
 4namespace Kestrun.Tool;
 5
 6internal static partial class Program
 7{
 8    /// <summary>
 9    /// Executes a module management command.
 10    /// </summary>
 11    /// <param name="command">Parsed module command.</param>
 12    /// <returns>Process exit code.</returns>
 13    private static int ManageModuleCommand(ParsedCommand command)
 14    {
 015        return command.Mode switch
 016        {
 017            CommandMode.ModuleInstall => ManageModuleFromGallery(ModuleCommandAction.Install, command.ModuleVersion, com
 018            CommandMode.ModuleUpdate => ManageModuleFromGallery(ModuleCommandAction.Update, command.ModuleVersion, comma
 019            CommandMode.ModuleRemove => ManageModuleFromGallery(ModuleCommandAction.Remove, null, command.ModuleScope, f
 020            CommandMode.ModuleInfo => PrintModuleInfo(command.ModuleScope),
 021            _ => throw new InvalidOperationException($"Unsupported module mode: {command.Mode}"),
 022        };
 23    }
 24
 25    /// <summary>
 26    /// Prints module installation details for local and gallery versions.
 27    /// </summary>
 28    /// <returns>Process exit code.</returns>
 29    private static int PrintModuleInfo(ModuleStorageScope scope)
 30    {
 031        var modulePath = GetPowerShellModulePath(scope);
 032        var moduleRoot = Path.Combine(modulePath, ModuleName);
 033        var records = GetInstalledModuleRecords(moduleRoot);
 034        var latestInstalledVersionText = records.Count > 0 ? records[0].Version : null;
 35
 036        Console.WriteLine($"Module name: {ModuleName}");
 037        Console.WriteLine($"Selected module scope: {GetScopeToken(scope)}");
 038        Console.WriteLine($"Module path root: {modulePath}");
 39
 040        if (records.Count == 0)
 41        {
 042            Console.WriteLine("Installed versions: none");
 43        }
 44        else
 45        {
 046            Console.WriteLine("Installed versions:");
 047            foreach (var record in records)
 48            {
 049                Console.WriteLine($"  - {record.Version} ({Path.GetDirectoryName(record.ManifestPath)})");
 50            }
 51        }
 52
 053        if (TryGetLatestGalleryVersionString(out var galleryVersion, out _))
 54        {
 055            Console.WriteLine($"Latest PowerShell Gallery version: {galleryVersion}");
 56
 057            if (!string.IsNullOrWhiteSpace(latestInstalledVersionText)
 058                && CompareModuleVersionValues(galleryVersion, latestInstalledVersionText) > 0)
 59            {
 060                Console.WriteLine($"Update available: run '{ProductName} module update'.");
 61            }
 62        }
 63        else
 64        {
 065            Console.WriteLine("Latest PowerShell Gallery version: unavailable");
 66        }
 67
 068        return 0;
 69    }
 70
 71    /// <summary>
 72    /// Installs, updates, or removes the Kestrun module in the current-user module path.
 73    /// </summary>
 74    /// <param name="action">Module action to perform.</param>
 75    /// <param name="version">Optional specific module version.</param>
 76    /// <param name="scope">Module installation scope.</param>
 77    /// <param name="force">When true, update overwrites an existing target version folder.</param>
 78    /// <returns>Process exit code.</returns>
 79    private static int ManageModuleFromGallery(ModuleCommandAction action, string? version, ModuleStorageScope scope, bo
 80    {
 081        var modulePath = GetPowerShellModulePath(scope);
 082        var moduleRoot = Path.Combine(modulePath, ModuleName);
 83
 084        if (action == ModuleCommandAction.Remove)
 85        {
 086            return HandleModuleRemoveAction(moduleRoot, scope, !Console.IsOutputRedirected);
 87        }
 88
 089        if (!TryValidateModuleInstallAction(action, moduleRoot, scope, out var installValidationError))
 90        {
 091            Console.Error.WriteLine(installValidationError);
 092            return 1;
 93        }
 94
 095        if (!TryExecuteModuleInstallOrUpdate(action, version, moduleRoot, force, out var installedVersion, out var insta
 96        {
 097            WriteModuleActionFailure(action, errorText);
 098            return 1;
 99        }
 100
 0101        WriteModuleInstallOrUpdateSuccess(action, scope, moduleRoot, installedVersion, installedManifestPath);
 0102        return 0;
 103    }
 104
 105    /// <summary>
 106    /// Handles module remove operation output and error handling.
 107    /// </summary>
 108    /// <param name="moduleRoot">Root module directory.</param>
 109    /// <param name="scope">Selected module scope.</param>
 110    /// <param name="showProgress">True to show progress output.</param>
 111    /// <returns>Process exit code.</returns>
 112    private static int HandleModuleRemoveAction(string moduleRoot, ModuleStorageScope scope, bool showProgress)
 113    {
 0114        if (!TryRemoveInstalledModule(moduleRoot, showProgress, out var removeErrorText))
 115        {
 0116            Console.Error.WriteLine($"Failed to remove '{ModuleName}' module.");
 0117            if (!string.IsNullOrWhiteSpace(removeErrorText))
 118            {
 0119                Console.Error.WriteLine(removeErrorText);
 120            }
 121
 0122            return 1;
 123        }
 124
 0125        Console.WriteLine($"{ModuleName} module removed from {GetScopeToken(scope)} module path.");
 0126        Console.WriteLine($"Module root: {moduleRoot}");
 0127        return 0;
 128    }
 129
 130    /// <summary>
 131    /// Validates install-specific preconditions for module actions.
 132    /// </summary>
 133    /// <param name="action">Module action.</param>
 134    /// <param name="moduleRoot">Root module directory.</param>
 135    /// <param name="scope">Selected module scope.</param>
 136    /// <param name="errorText">Validation error details.</param>
 137    /// <returns>True when validation passes or action is not install.</returns>
 138    private static bool TryValidateModuleInstallAction(
 139        ModuleCommandAction action,
 140        string moduleRoot,
 141        ModuleStorageScope scope,
 142        out string errorText)
 143    {
 0144        errorText = string.Empty;
 0145        return action != ModuleCommandAction.Install
 0146            || TryValidateInstallAction(moduleRoot, GetScopeToken(scope), out errorText);
 147    }
 148
 149    /// <summary>
 150    /// Executes module install/update from PowerShell Gallery.
 151    /// </summary>
 152    /// <param name="action">Module action.</param>
 153    /// <param name="version">Optional requested version.</param>
 154    /// <param name="moduleRoot">Root module directory.</param>
 155    /// <param name="force">True when update may overwrite existing destination.</param>
 156    /// <param name="installedVersion">Installed version text.</param>
 157    /// <param name="installedManifestPath">Installed manifest path.</param>
 158    /// <param name="errorText">Execution error details.</param>
 159    /// <returns>True when install/update succeeds.</returns>
 160    private static bool TryExecuteModuleInstallOrUpdate(
 161        ModuleCommandAction action,
 162        string? version,
 163        string moduleRoot,
 164        bool force,
 165        out string installedVersion,
 166        out string installedManifestPath,
 167        out string errorText)
 168    {
 0169        return TryInstallOrUpdateModuleFromGallery(
 0170            action,
 0171            version,
 0172            moduleRoot,
 0173            !Console.IsOutputRedirected,
 0174            force,
 0175            out installedVersion,
 0176            out installedManifestPath,
 0177            out errorText);
 178    }
 179
 180    /// <summary>
 181    /// Writes a standardized module action failure message.
 182    /// </summary>
 183    /// <param name="action">Module action that failed.</param>
 184    /// <param name="errorText">Optional error details.</param>
 185    private static void WriteModuleActionFailure(ModuleCommandAction action, string errorText)
 186    {
 0187        Console.Error.WriteLine($"Failed to {action.ToString().ToLowerInvariant()} '{ModuleName}' module.");
 0188        if (!string.IsNullOrWhiteSpace(errorText))
 189        {
 0190            Console.Error.WriteLine(errorText);
 191        }
 0192    }
 193
 194    /// <summary>
 195    /// Writes a standardized success message for module install/update actions.
 196    /// </summary>
 197    /// <param name="action">Completed module action.</param>
 198    /// <param name="scope">Selected module scope.</param>
 199    /// <param name="moduleRoot">Root module directory.</param>
 200    /// <param name="installedVersion">Installed version text.</param>
 201    /// <param name="installedManifestPath">Installed manifest path.</param>
 202    private static void WriteModuleInstallOrUpdateSuccess(
 203        ModuleCommandAction action,
 204        ModuleStorageScope scope,
 205        string moduleRoot,
 206        string installedVersion,
 207        string installedManifestPath)
 208    {
 0209        var installedPath = Path.GetDirectoryName(installedManifestPath) ?? Path.Combine(moduleRoot, installedVersion);
 0210        var versionSuffix = string.IsNullOrWhiteSpace(installedVersion)
 0211            ? string.Empty
 0212            : $" (version {installedVersion})";
 213
 0214        if (action == ModuleCommandAction.Install)
 215        {
 0216            Console.WriteLine($"{ModuleName} module installed{versionSuffix} to {GetScopeToken(scope)} scope.");
 217        }
 218        else
 219        {
 0220            Console.WriteLine($"{ModuleName} module updated{versionSuffix} in {GetScopeToken(scope)} scope.");
 221        }
 222
 0223        Console.WriteLine($"Module path: {installedPath}");
 0224    }
 225
 226    /// <summary>
 227    /// Downloads a module package from PowerShell Gallery and installs it into the user module path.
 228    /// </summary>
 229    /// <param name="action">Module action being executed.</param>
 230    /// <param name="requestedVersion">Optional requested package version.</param>
 231    /// <param name="moduleRoot">Root folder for module versions.</param>
 232    /// <param name="installedVersion">Installed module version.</param>
 233    /// <param name="installedManifestPath">Installed manifest path.</param>
 234    /// <param name="errorText">Error details when installation fails.</param>
 235    /// <returns>True when install/update succeeds.</returns>
 236    private static bool TryInstallOrUpdateModuleFromGallery(
 237        ModuleCommandAction action,
 238        string? requestedVersion,
 239        string moduleRoot,
 240        bool showProgress,
 241        bool force,
 242        out string installedVersion,
 243        out string installedManifestPath,
 244        out string errorText)
 245    {
 0246        installedVersion = string.Empty;
 0247        installedManifestPath = string.Empty;
 0248        if (!TryDownloadModulePackage(requestedVersion, showProgress, out var packageBytes, out var packageVersion, out 
 249        {
 0250            return false;
 251        }
 252
 0253        if (action == ModuleCommandAction.Update
 0254            && !TryValidateUpdateAction(moduleRoot, packageVersion, force, out errorText))
 255        {
 0256            return false;
 257        }
 258
 0259        if (!TryExtractModulePackage(packageBytes, packageVersion, moduleRoot, showProgress, force, out installedManifes
 260        {
 0261            return false;
 262        }
 263
 0264        installedVersion = packageVersion;
 0265        return true;
 266    }
 267
 268    /// <summary>
 269    /// Downloads the Kestrun nupkg package from PowerShell Gallery.
 270    /// </summary>
 271    /// <param name="requestedVersion">Optional requested package version.</param>
 272    /// <param name="packageBytes">Downloaded package payload.</param>
 273    /// <param name="packageVersion">Resolved package version from nuspec metadata.</param>
 274    /// <param name="errorText">Error details when download fails.</param>
 275    /// <returns>True when the package download succeeds.</returns>
 276    private static bool TryDownloadModulePackage(
 277        string? requestedVersion,
 278        bool showProgress,
 279        out byte[] packageBytes,
 280        out string packageVersion,
 281        out string errorText)
 282    {
 0283        packageBytes = [];
 0284        packageVersion = string.Empty;
 0285        errorText = string.Empty;
 286
 287        try
 288        {
 0289            var normalizedVersion = NormalizeRequestedModuleVersion(requestedVersion);
 0290            var packageUrl = BuildGalleryPackageUrl(normalizedVersion);
 291
 0292            using var request = new HttpRequestMessage(HttpMethod.Get, packageUrl);
 0293            using var response = GalleryHttpClient.Send(request, HttpCompletionOption.ResponseHeadersRead);
 0294            if (!TryHandlePackageDownloadResponseStatus(
 0295                    response,
 0296                    normalizedVersion,
 0297                    showProgress,
 0298                    out packageBytes,
 0299                    out packageVersion,
 0300                    out errorText))
 301            {
 0302                return false;
 303            }
 304
 0305            if (!TryDownloadPackagePayload(response, showProgress, out packageBytes, out errorText))
 306            {
 0307                return false;
 308            }
 309            // Attempt to resolve package version from nuspec metadata, with fallback to normalized version input when r
 0310            if (!TryResolveDownloadedPackageVersion(packageBytes, normalizedVersion, out packageVersion, out errorText))
 311            {
 0312                return false;
 313            }
 314            // Package download and version resolution succeeded.
 0315            return true;
 316        }
 0317        catch (Exception ex)
 318        {
 0319            errorText = ex.Message;
 0320            return false;
 321        }
 0322    }
 323
 324    /// <summary>
 325    /// Normalizes optional module version input used for gallery package requests.
 326    /// </summary>
 327    /// <param name="requestedVersion">Requested version text.</param>
 328    /// <returns>Trimmed version text or null when not specified.</returns>
 329    private static string? NormalizeRequestedModuleVersion(string? requestedVersion)
 2330        => string.IsNullOrWhiteSpace(requestedVersion) ? null : requestedVersion.Trim();
 331
 332    /// <summary>
 333    /// Builds the PowerShell Gallery package URL for a module and optional version.
 334    /// </summary>
 335    /// <param name="normalizedVersion">Optional normalized version.</param>
 336    /// <returns>Gallery package URL.</returns>
 337    private static string BuildGalleryPackageUrl(string? normalizedVersion)
 338    {
 2339        return string.IsNullOrWhiteSpace(normalizedVersion)
 2340            ? $"{PowerShellGalleryApiBaseUri}/package/{Uri.EscapeDataString(ModuleName)}"
 2341            : $"{PowerShellGalleryApiBaseUri}/package/{Uri.EscapeDataString(ModuleName)}/{Uri.EscapeDataString(normalize
 342    }
 343
 344    /// <summary>
 345    /// Validates HTTP status and handles not-found fallback for module package downloads.
 346    /// </summary>
 347    /// <param name="response">HTTP response from gallery.</param>
 348    /// <param name="normalizedVersion">Optional normalized version.</param>
 349    /// <param name="showProgress">True to show download progress.</param>
 350    /// <param name="packageBytes">Downloaded package bytes when fallback succeeds.</param>
 351    /// <param name="packageVersion">Downloaded package version when fallback succeeds.</param>
 352    /// <param name="errorText">Error details when status handling fails.</param>
 353    /// <returns>True when status is acceptable and caller should continue payload processing.</returns>
 354    private static bool TryHandlePackageDownloadResponseStatus(
 355        HttpResponseMessage response,
 356        string? normalizedVersion,
 357        bool showProgress,
 358        out byte[] packageBytes,
 359        out string packageVersion,
 360        out string errorText)
 361    {
 1362        packageBytes = [];
 1363        packageVersion = string.Empty;
 1364        errorText = string.Empty;
 365
 1366        if (response.IsSuccessStatusCode)
 367        {
 0368            return true;
 369        }
 370
 1371        if (response.StatusCode == HttpStatusCode.NotFound)
 372        {
 1373            if (string.IsNullOrWhiteSpace(normalizedVersion)
 1374                && TryGetLatestGalleryVersionString(out var latestVersion, out _)
 1375                && !string.IsNullOrWhiteSpace(latestVersion))
 376            {
 0377                return TryDownloadModulePackage(latestVersion, showProgress, out packageBytes, out packageVersion, out e
 378            }
 379
 1380            errorText = string.IsNullOrWhiteSpace(normalizedVersion)
 1381                ? $"Module '{ModuleName}' was not found on PowerShell Gallery."
 1382                : $"Module '{ModuleName}' version '{normalizedVersion}' was not found on PowerShell Gallery.";
 1383            return false;
 384        }
 385
 0386        var reason = string.IsNullOrWhiteSpace(response.ReasonPhrase)
 0387            ? "Unknown error"
 0388            : response.ReasonPhrase;
 0389        errorText = $"PowerShell Gallery request failed with HTTP {(int)response.StatusCode} ({reason}).";
 0390        return false;
 391    }
 392
 393    /// <summary>
 394    /// Downloads package bytes from a successful gallery response stream.
 395    /// </summary>
 396    /// <param name="response">Successful HTTP response.</param>
 397    /// <param name="showProgress">True to display progress output.</param>
 398    /// <param name="packageBytes">Downloaded package bytes.</param>
 399    /// <param name="errorText">Error details when payload is empty.</param>
 400    /// <returns>True when payload download succeeds with non-empty content.</returns>
 401    private static bool TryDownloadPackagePayload(HttpResponseMessage response, bool showProgress, out byte[] packageByt
 402    {
 2403        errorText = string.Empty;
 404
 2405        var contentLength = response.Content.Headers.ContentLength;
 2406        using var responseStream = response.Content.ReadAsStreamAsync().GetAwaiter().GetResult();
 2407        using var packageStream = contentLength.HasValue && contentLength.Value > 0 && contentLength.Value <= int.MaxVal
 2408            ? new MemoryStream((int)contentLength.Value)
 2409            : new MemoryStream();
 410
 2411        using var downloadProgress = showProgress
 2412            ? new ConsoleProgressBar("Downloading package", contentLength, FormatByteProgressDetail)
 2413            : null;
 414
 2415        CopyStreamWithProgress(responseStream, packageStream, downloadProgress);
 2416        packageBytes = packageStream.ToArray();
 2417        if (packageBytes.Length > 0)
 418        {
 1419            return true;
 420        }
 421
 1422        errorText = "Downloaded package was empty.";
 1423        return false;
 2424    }
 425
 426    /// <summary>
 427    /// Resolves module version from package metadata, with version-input fallback normalization.
 428    /// </summary>
 429    /// <param name="packageBytes">Downloaded package bytes.</param>
 430    /// <param name="normalizedVersion">Optional normalized requested version.</param>
 431    /// <param name="packageVersion">Resolved package version.</param>
 432    /// <param name="errorText">Error details when version resolution fails.</param>
 433    /// <returns>True when package version is resolved.</returns>
 434    private static bool TryResolveDownloadedPackageVersion(
 435        byte[] packageBytes,
 436        string? normalizedVersion,
 437        out string packageVersion,
 438        out string errorText)
 439    {
 1440        errorText = string.Empty;
 441
 1442        if (TryReadPackageVersion(packageBytes, out packageVersion))
 443        {
 0444            return true;
 445        }
 446
 1447        if (!string.IsNullOrWhiteSpace(normalizedVersion))
 448        {
 1449            if (TryNormalizeModuleVersion(normalizedVersion, out packageVersion))
 450            {
 1451                return true;
 452            }
 453
 0454            errorText = $"Unable to normalize package version '{normalizedVersion}' for module folder naming.";
 0455            return false;
 456        }
 457
 0458        packageVersion = string.Empty;
 0459        errorText = "Unable to determine package version from downloaded metadata.";
 0460        return false;
 461    }
 462
 463    /// <summary>
 464    /// Extracts a module package payload and installs it under the versioned module directory.
 465    /// </summary>
 466    /// <param name="packageBytes">Downloaded package bytes.</param>
 467    /// <param name="packageVersion">Package version used for destination folder naming.</param>
 468    /// <param name="moduleRoot">Root directory for module versions.</param>
 469    /// <param name="installedManifestPath">Installed module manifest path.</param>
 470    /// <param name="errorText">Error details when extraction fails.</param>
 471    /// <returns>True when package extraction and install succeed.</returns>
 472    private static bool TryExtractModulePackage(
 473        byte[] packageBytes,
 474        string packageVersion,
 475        string moduleRoot,
 476        bool showProgress,
 477        bool allowOverwrite,
 478        out string installedManifestPath,
 479        out string errorText)
 480    {
 1481        installedManifestPath = string.Empty;
 1482        errorText = string.Empty;
 483
 1484        if (packageVersion.IndexOfAny(Path.GetInvalidFileNameChars()) >= 0)
 485        {
 0486            errorText = $"Invalid package version '{packageVersion}' for filesystem install path.";
 0487            return false;
 488        }
 489
 1490        var stagingPath = Path.Combine(Path.GetTempPath(), $"{ProductName}-module-{Guid.NewGuid():N}");
 1491        var comparisonType = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal
 492
 493        try
 494        {
 1495            _ = Directory.CreateDirectory(stagingPath);
 496
 1497            using var packageStream = new MemoryStream(packageBytes, writable: false);
 1498            using var archive = new ZipArchive(packageStream, ZipArchiveMode.Read, leaveOpen: false);
 499
 1500            if (!TryCollectModulePayloadEntries(archive, out var payloadEntries, out errorText))
 501            {
 0502                return false;
 503            }
 504
 1505            var shouldStripModulePrefix = ShouldStripModulePrefix(payloadEntries);
 1506            if (!TryExtractPayloadEntriesToStaging(payloadEntries, stagingPath, comparisonType, shouldStripModulePrefix,
 507            {
 0508                return false;
 509            }
 510
 1511            if (!TryResolveExtractedManifestPath(stagingPath, out var manifestPath, out errorText))
 512            {
 0513                return false;
 514            }
 515            // Install extracted module from staging to final destination, with optional overwrite for updates.
 1516            return TryInstallExtractedModule(manifestPath, moduleRoot, packageVersion, showProgress, allowOverwrite, out
 517        }
 0518        catch (Exception ex)
 519        {
 0520            errorText = ex.Message;
 0521            return false;
 522        }
 523        finally
 524        {
 1525            TryDeleteDirectoryQuietly(stagingPath);
 1526        }
 1527    }
 528
 529    /// <summary>
 530    /// Collects package entries that belong to the module payload.
 531    /// </summary>
 532    /// <param name="archive">Opened package archive.</param>
 533    /// <param name="payloadEntries">Collected payload entries and relative paths.</param>
 534    /// <param name="errorText">Error details when no payload entries are found.</param>
 535    /// <returns>True when payload entries are discovered.</returns>
 536    private static bool TryCollectModulePayloadEntries(
 537        ZipArchive archive,
 538        out List<(ZipArchiveEntry Entry, string RelativePath)> payloadEntries,
 539        out string errorText)
 540    {
 3541        payloadEntries = [];
 3542        errorText = string.Empty;
 543
 16544        foreach (var entry in archive.Entries)
 545        {
 5546            if (TryGetPackagePayloadPath(entry.FullName, out var relativePath))
 547            {
 4548                payloadEntries.Add((entry, relativePath));
 549            }
 550        }
 551
 3552        if (payloadEntries.Count > 0)
 553        {
 2554            return true;
 555        }
 556
 1557        errorText = "Package did not contain any module payload files.";
 1558        return false;
 559    }
 560
 561    /// <summary>
 562    /// Determines whether all payload entries share a top-level module-name folder prefix.
 563    /// </summary>
 564    /// <param name="payloadEntries">Collected payload entries.</param>
 565    /// <returns>True when module prefix should be stripped while extracting.</returns>
 566    private static bool ShouldStripModulePrefix(IReadOnlyList<(ZipArchiveEntry Entry, string RelativePath)> payloadEntri
 567    {
 1568        return payloadEntries.All(static payloadEntry =>
 1569        {
 2570            var separatorIndex = payloadEntry.RelativePath.IndexOf('/');
 2571            return separatorIndex > 0
 2572                && string.Equals(payloadEntry.RelativePath[..separatorIndex], ModuleName, StringComparison.OrdinalIgnore
 1573        });
 574    }
 575
 576    /// <summary>
 577    /// Extracts payload entries into staging while enforcing path traversal protection.
 578    /// </summary>
 579    /// <param name="payloadEntries">Collected payload entries.</param>
 580    /// <param name="stagingPath">Extraction staging path.</param>
 581    /// <param name="comparisonType">Path comparison mode for security checks.</param>
 582    /// <param name="shouldStripModulePrefix">True when module-name prefix should be removed.</param>
 583    /// <param name="showProgress">True to show extraction progress.</param>
 584    /// <param name="errorText">Error details when extraction fails.</param>
 585    /// <returns>True when extraction succeeds.</returns>
 586    private static bool TryExtractPayloadEntriesToStaging(
 587        IReadOnlyList<(ZipArchiveEntry Entry, string RelativePath)> payloadEntries,
 588        string stagingPath,
 589        StringComparison comparisonType,
 590        bool shouldStripModulePrefix,
 591        bool showProgress,
 592        out string errorText)
 593    {
 1594        errorText = string.Empty;
 595
 1596        using var extractProgress = showProgress
 1597            ? new ConsoleProgressBar("Extracting package", payloadEntries.Count, FormatFileProgressDetail)
 1598            : null;
 1599        var extractedEntryCount = 0;
 1600        extractProgress?.Report(0);
 601
 1602        var fullStagingPath = Path.GetFullPath(stagingPath);
 1603        var fullStagingPathWithSeparator = Path.EndsInDirectorySeparator(fullStagingPath)
 1604            ? fullStagingPath
 1605            : fullStagingPath + Path.DirectorySeparatorChar;
 606
 6607        foreach (var (Entry, RelativePath) in payloadEntries)
 608        {
 2609            var relativePath = NormalizePayloadRelativePath(RelativePath, shouldStripModulePrefix);
 2610            if (string.IsNullOrWhiteSpace(relativePath))
 611            {
 612                continue;
 613            }
 614
 2615            if (!TryResolveSafeStagingDestination(
 2616                    stagingPath,
 2617                    fullStagingPathWithSeparator,
 2618                    Entry.FullName,
 2619                    relativePath,
 2620                    comparisonType,
 2621                    out var destinationPath,
 2622                    out errorText))
 623            {
 0624                return false;
 625            }
 626
 2627            var destinationDirectory = Path.GetDirectoryName(destinationPath);
 2628            if (!string.IsNullOrWhiteSpace(destinationDirectory))
 629            {
 2630                _ = Directory.CreateDirectory(destinationDirectory);
 631            }
 632
 2633            Entry.ExtractToFile(destinationPath, overwrite: true);
 2634            extractedEntryCount++;
 2635            extractProgress?.Report(extractedEntryCount);
 636        }
 637
 1638        extractProgress?.Complete(extractedEntryCount);
 1639        return true;
 1640    }
 641
 642    /// <summary>
 643    /// Normalizes extracted payload relative paths and optionally strips module-name prefix.
 644    /// </summary>
 645    /// <param name="relativePath">Raw payload relative path.</param>
 646    /// <param name="shouldStripModulePrefix">True when module prefix should be stripped.</param>
 647    /// <returns>Normalized relative path for extraction.</returns>
 648    private static string NormalizePayloadRelativePath(string relativePath, bool shouldStripModulePrefix)
 649    {
 2650        if (!shouldStripModulePrefix)
 651        {
 0652            return relativePath;
 653        }
 654
 2655        var separatorIndex = relativePath.IndexOf('/');
 2656        return separatorIndex >= 0
 2657            ? relativePath[(separatorIndex + 1)..]
 2658            : relativePath;
 659    }
 660
 661    /// <summary>
 662    /// Resolves and validates a destination path inside staging for one payload entry.
 663    /// </summary>
 664    /// <param name="stagingPath">Staging root path.</param>
 665    /// <param name="fullStagingPathWithSeparator">Normalized staging root with trailing separator.</param>
 666    /// <param name="entryFullName">Original package entry path.</param>
 667    /// <param name="relativePath">Normalized relative payload path.</param>
 668    /// <param name="comparisonType">Path comparison mode.</param>
 669    /// <param name="destinationPath">Resolved destination path.</param>
 670    /// <param name="errorText">Error details when traversal is detected.</param>
 671    /// <returns>True when destination path is safe for extraction.</returns>
 672    private static bool TryResolveSafeStagingDestination(
 673        string stagingPath,
 674        string fullStagingPathWithSeparator,
 675        string entryFullName,
 676        string relativePath,
 677        StringComparison comparisonType,
 678        out string destinationPath,
 679        out string errorText)
 680    {
 3681        errorText = string.Empty;
 3682        destinationPath = Path.GetFullPath(Path.Combine(stagingPath, relativePath));
 3683        if (destinationPath.StartsWith(fullStagingPathWithSeparator, comparisonType))
 684        {
 2685            return true;
 686        }
 687
 1688        errorText = $"Package entry '{entryFullName}' resolves outside staging directory.";
 1689        return false;
 690    }
 691
 692    /// <summary>
 693    /// Resolves the extracted module manifest path from staging.
 694    /// </summary>
 695    /// <param name="stagingPath">Extraction staging path.</param>
 696    /// <param name="manifestPath">Resolved manifest path.</param>
 697    /// <param name="errorText">Error details when manifest is missing.</param>
 698    /// <returns>True when manifest path is resolved.</returns>
 699    private static bool TryResolveExtractedManifestPath(string stagingPath, out string manifestPath, out string errorTex
 700    {
 2701        errorText = string.Empty;
 2702        manifestPath = Directory.EnumerateFiles(stagingPath, ModuleManifestFileName, SearchOption.AllDirectories)
 4703            .FirstOrDefault(static path => path is not null) ?? string.Empty;
 704
 2705        if (!string.IsNullOrWhiteSpace(manifestPath))
 706        {
 2707            return true;
 708        }
 709
 0710        errorText = $"Package payload did not contain '{ModuleManifestFileName}'.";
 0711        return false;
 712    }
 713
 714    /// <summary>
 715    /// Installs extracted module files into the versioned module destination folder.
 716    /// </summary>
 717    /// <param name="manifestPath">Resolved staging manifest path.</param>
 718    /// <param name="moduleRoot">Root module folder.</param>
 719    /// <param name="packageVersion">Package version folder name.</param>
 720    /// <param name="showProgress">True to show copy progress.</param>
 721    /// <param name="allowOverwrite">True when existing destination can be replaced.</param>
 722    /// <param name="installedManifestPath">Installed manifest destination path.</param>
 723    /// <param name="errorText">Error details when destination cannot be prepared.</param>
 724    /// <returns>True when install copy succeeds.</returns>
 725    private static bool TryInstallExtractedModule(
 726        string manifestPath,
 727        string moduleRoot,
 728        string packageVersion,
 729        bool showProgress,
 730        bool allowOverwrite,
 731        out string installedManifestPath,
 732        out string errorText)
 733    {
 3734        installedManifestPath = string.Empty;
 3735        errorText = string.Empty;
 736
 3737        var sourceModuleDirectory = Path.GetDirectoryName(manifestPath)!;
 3738        var destinationModuleDirectory = Path.Combine(moduleRoot, packageVersion);
 739
 3740        if (Directory.Exists(destinationModuleDirectory))
 741        {
 2742            if (!allowOverwrite)
 743            {
 1744                errorText = $"Target module version folder already exists: {destinationModuleDirectory}";
 1745                return false;
 746            }
 747
 1748            Directory.Delete(destinationModuleDirectory, recursive: true);
 749        }
 750
 2751        CopyDirectoryContents(sourceModuleDirectory, destinationModuleDirectory, showProgress);
 2752        installedManifestPath = Path.Combine(destinationModuleDirectory, ModuleManifestFileName);
 2753        return true;
 754    }
 755
 756    /// <summary>
 757    /// Best-effort directory cleanup used for temporary extraction staging folders.
 758    /// </summary>
 759    /// <param name="directoryPath">Directory path to delete.</param>
 760    private static void TryDeleteDirectoryQuietly(string directoryPath)
 761    {
 762        try
 763        {
 1764            if (Directory.Exists(directoryPath))
 765            {
 1766                Directory.Delete(directoryPath, recursive: true);
 767            }
 1768        }
 0769        catch
 770        {
 771            // Cleanup failures are non-fatal for module install flow.
 0772        }
 1773    }
 774
 775    /// <summary>
 776    /// Parses arguments for module install/update/remove/info commands.
 777    /// </summary>
 778    /// <param name="args">Raw command-line arguments.</param>
 779    /// <param name="startIndex">Index after module token.</param>
 780    /// <param name="parsedCommand">Parsed command payload.</param>
 781    /// <param name="error">Error message when parsing fails.</param>
 782    /// <returns>True when parsing succeeds.</returns>
 783    private static bool TryParseModuleArguments(string[] args, int startIndex, out ParsedCommand parsedCommand, out stri
 784    {
 12785        parsedCommand = CreateDefaultModuleParsedCommand();
 786
 12787        if (!TryResolveModuleAction(args, startIndex, out var parseState, out error))
 788        {
 0789            return false;
 790        }
 791
 12792        if (!TryParseModuleOptionLoop(args, startIndex + 1, parseState, out error))
 793        {
 5794            return false;
 795        }
 796
 7797        parsedCommand = CreateParsedModuleCommand(parseState);
 7798        return true;
 799    }
 800
 801    /// <summary>
 802    /// Holds mutable parse state for module command options.
 803    /// </summary>
 804    private sealed class ModuleParseState
 805    {
 15806        public required string ActionToken { get; init; }
 807
 34808        public required CommandMode Mode { get; init; }
 809
 11810        public string? ModuleVersion { get; set; }
 811
 9812        public ModuleStorageScope ModuleScope { get; set; } = ModuleStorageScope.Local;
 813
 5814        public bool ModuleScopeSet { get; set; }
 815
 9816        public bool ModuleForce { get; set; }
 817
 4818        public bool ModuleForceSet { get; set; }
 819    }
 820
 821    /// <summary>
 822    /// Creates the default parsed command placeholder used during module argument parsing.
 823    /// </summary>
 824    /// <returns>Default parsed command for module mode.</returns>
 825    private static ParsedCommand CreateDefaultModuleParsedCommand()
 12826        => new(CommandMode.ModuleInfo, string.Empty, false, [], null, null, null, false, null, null, null, null, ModuleS
 827
 828    /// <summary>
 829    /// Resolves and validates the module action token into parsing state.
 830    /// </summary>
 831    /// <param name="args">Raw command-line arguments.</param>
 832    /// <param name="startIndex">Index after module token.</param>
 833    /// <param name="parseState">Initialized module parse state.</param>
 834    /// <param name="error">Error message when action parsing fails.</param>
 835    /// <returns>True when a module action is resolved successfully.</returns>
 836    private static bool TryResolveModuleAction(string[] args, int startIndex, out ModuleParseState parseState, out strin
 837    {
 12838        parseState = null!;
 12839        error = string.Empty;
 840
 12841        if (startIndex >= args.Length)
 842        {
 0843            error = "Missing module action. Use 'module install', 'module update', 'module remove', or 'module info'.";
 0844            return false;
 845        }
 846
 12847        var actionToken = args[startIndex];
 12848        var mode = actionToken.ToLowerInvariant() switch
 12849        {
 6850            "install" => CommandMode.ModuleInstall,
 2851            "update" => CommandMode.ModuleUpdate,
 2852            "remove" => CommandMode.ModuleRemove,
 2853            "info" => CommandMode.ModuleInfo,
 0854            _ => (CommandMode?)null,
 12855        };
 856
 12857        if (mode is null)
 858        {
 0859            error = $"Unknown module action: {actionToken}. Use 'module install', 'module update', 'module remove', or '
 0860            return false;
 861        }
 862
 12863        parseState = new ModuleParseState
 12864        {
 12865            ActionToken = actionToken,
 12866            Mode = mode.Value,
 12867        };
 12868        return true;
 869    }
 870
 871    /// <summary>
 872    /// Parses module command options from the remaining argument tokens.
 873    /// </summary>
 874    /// <param name="args">Raw command-line arguments.</param>
 875    /// <param name="startIndex">First option index after action token.</param>
 876    /// <param name="parseState">Mutable module parse state.</param>
 877    /// <param name="error">Error message when option parsing fails.</param>
 878    /// <returns>True when module options parse successfully.</returns>
 879    private static bool TryParseModuleOptionLoop(string[] args, int startIndex, ModuleParseState parseState, out string 
 880    {
 12881        error = string.Empty;
 12882        var index = startIndex;
 883
 18884        while (index < args.Length)
 885        {
 11886            if (!TryConsumeModuleOption(args, parseState, ref index, out error))
 887            {
 5888                return false;
 889            }
 890        }
 891
 7892        return true;
 893    }
 894
 895    /// <summary>
 896    /// Attempts to consume one module option at the current parser index.
 897    /// </summary>
 898    /// <param name="args">Raw command-line arguments.</param>
 899    /// <param name="parseState">Mutable module parse state.</param>
 900    /// <param name="index">Current parser index.</param>
 901    /// <param name="error">Error message when the current token is invalid.</param>
 902    /// <returns>True when parsing can continue.</returns>
 903    private static bool TryConsumeModuleOption(string[] args, ModuleParseState parseState, ref int index, out string err
 904    {
 11905        error = string.Empty;
 11906        var current = args[index];
 11907        var acceptsVersion = parseState.Mode is CommandMode.ModuleInstall or CommandMode.ModuleUpdate;
 908
 11909        if (current is ModuleScopeOption or "-s")
 910        {
 3911            return TryConsumeModuleScopeOption(args, parseState, ref index, out error);
 912        }
 913
 8914        if (current is ModuleVersionOption or "-v")
 915        {
 4916            return TryConsumeModuleVersionOption(args, parseState, acceptsVersion, ref index, out error);
 917        }
 918
 4919        if (current is ModuleForceOption or "-f")
 920        {
 4921            return TryConsumeModuleForceOption(parseState, ref index, out error);
 922        }
 923
 0924        if (current.StartsWith("--", StringComparison.Ordinal))
 925        {
 0926            error = $"Unknown option: {current}";
 0927            return false;
 928        }
 929
 0930        error = $"Unexpected argument for module {parseState.ActionToken}: {current}";
 0931        return false;
 932    }
 933
 934    /// <summary>
 935    /// Consumes and validates the module scope option value.
 936    /// </summary>
 937    /// <param name="args">Raw command-line arguments.</param>
 938    /// <param name="parseState">Mutable module parse state.</param>
 939    /// <param name="index">Current parser index.</param>
 940    /// <param name="error">Error message when scope parsing fails.</param>
 941    /// <returns>True when scope option is valid.</returns>
 942    private static bool TryConsumeModuleScopeOption(string[] args, ModuleParseState parseState, ref int index, out strin
 943    {
 3944        error = string.Empty;
 945
 3946        if (!TryConsumeOptionValue(args, ref index, ModuleScopeOption, out var scopeToken, out error,
 3947                $"Missing value for {ModuleScopeOption}. Use '{ModuleScopeLocalValue}' or '{ModuleScopeGlobalValue}'."))
 948        {
 0949            return false;
 950        }
 951
 3952        if (parseState.ModuleScopeSet)
 953        {
 0954            error = $"Module scope was provided multiple times. Use {ModuleScopeOption} once.";
 0955            return false;
 956        }
 957
 3958        if (!TryParseModuleScope(scopeToken, out var moduleScope))
 959        {
 1960            error = $"Unknown module scope: {scopeToken}. Use '{ModuleScopeLocalValue}' or '{ModuleScopeGlobalValue}'.";
 1961            return false;
 962        }
 963
 2964        parseState.ModuleScope = moduleScope;
 2965        parseState.ModuleScopeSet = true;
 2966        return true;
 967    }
 968
 969    /// <summary>
 970    /// Consumes and validates the module version option value.
 971    /// </summary>
 972    /// <param name="args">Raw command-line arguments.</param>
 973    /// <param name="parseState">Mutable module parse state.</param>
 974    /// <param name="acceptsVersion">True when current action accepts version option.</param>
 975    /// <param name="index">Current parser index.</param>
 976    /// <param name="error">Error message when version parsing fails.</param>
 977    /// <returns>True when version option is valid.</returns>
 978    private static bool TryConsumeModuleVersionOption(
 979        string[] args,
 980        ModuleParseState parseState,
 981        bool acceptsVersion,
 982        ref int index,
 983        out string error)
 984    {
 4985        error = string.Empty;
 986
 4987        if (!acceptsVersion)
 988        {
 1989            error = $"Module {parseState.ActionToken} does not accept {ModuleVersionOption}.";
 1990            return false;
 991        }
 992
 3993        if (!TryConsumeOptionValue(args, ref index, ModuleVersionOption, out var versionValue, out error))
 994        {
 1995            return false;
 996        }
 997
 2998        if (!string.IsNullOrWhiteSpace(parseState.ModuleVersion))
 999        {
 01000            error = $"Module version was provided multiple times. Use {ModuleVersionOption} once.";
 01001            return false;
 1002        }
 1003
 21004        parseState.ModuleVersion = versionValue;
 21005        return true;
 1006    }
 1007
 1008    /// <summary>
 1009    /// Consumes and validates the module force option.
 1010    /// </summary>
 1011    /// <param name="parseState">Mutable module parse state.</param>
 1012    /// <param name="index">Current parser index.</param>
 1013    /// <param name="error">Error message when force option is invalid.</param>
 1014    /// <returns>True when force option is valid.</returns>
 1015    private static bool TryConsumeModuleForceOption(ModuleParseState parseState, ref int index, out string error)
 1016    {
 41017        error = string.Empty;
 1018
 41019        if (parseState.Mode != CommandMode.ModuleUpdate)
 1020        {
 21021            error = $"Module {parseState.ActionToken} does not accept {ModuleForceOption}.";
 21022            return false;
 1023        }
 1024
 21025        if (parseState.ModuleForceSet)
 1026        {
 01027            error = $"{ModuleForceOption} was provided multiple times. Use {ModuleForceOption} once.";
 01028            return false;
 1029        }
 1030
 21031        parseState.ModuleForce = true;
 21032        parseState.ModuleForceSet = true;
 21033        index += 1;
 21034        return true;
 1035    }
 1036
 1037    /// <summary>
 1038    /// Consumes a single option value and advances parser index.
 1039    /// </summary>
 1040    /// <param name="args">Raw command-line arguments.</param>
 1041    /// <param name="index">Current parser index.</param>
 1042    /// <param name="optionName">Option name used in default missing-value messages.</param>
 1043    /// <param name="value">Parsed option value.</param>
 1044    /// <param name="error">Error message when value is missing.</param>
 1045    /// <param name="missingValueError">Optional custom missing-value error text.</param>
 1046    /// <returns>True when value is present and consumed.</returns>
 1047    private static bool TryConsumeOptionValue(
 1048        string[] args,
 1049        ref int index,
 1050        string optionName,
 1051        out string value,
 1052        out string error,
 1053        string? missingValueError = null)
 1054    {
 31055        value = string.Empty;
 31056        error = string.Empty;
 1057
 31058        if (index + 1 >= args.Length)
 1059        {
 01060            error = missingValueError ?? $"Missing value for {optionName}.";
 01061            return false;
 1062        }
 1063
 31064        value = args[index + 1];
 31065        index += 2;
 31066        return true;
 1067    }
 1068
 1069    /// <summary>
 1070    /// Creates the final parsed command payload from module parse state.
 1071    /// </summary>
 1072    /// <param name="parseState">Completed parse state.</param>
 1073    /// <returns>Parsed command payload.</returns>
 1074    private static ParsedCommand CreateParsedModuleCommand(ModuleParseState parseState)
 71075        => new(parseState.Mode, string.Empty, false, [], null, null, null, false, null, null, null, parseState.ModuleVer
 1076}

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/Program.Records.cs

#LineLine coverage
 1using System.Text.RegularExpressions;
 2
 3namespace Kestrun.Tool;
 4
 5internal static partial class Program
 6{
 7    private const string ModuleManifestFileName = "Kestrun.psd1";
 8    private const string ServiceDescriptorFileName = "Service.psd1";
 9    private const string ServicePackageExtension = ".krpack";
 10    private const string RuntimePackageExtension = ".nupkg";
 11    private const string RuntimePackageManifestFileName = "runtime-manifest.json";
 12    private const string RuntimePackageExtractionCompleteMarkerFileName = ".runtime-extraction-complete";
 13    private const string ModuleName = "Kestrun";
 14    private const string RunDefaultScriptFileName = "Service.ps1";
 15    private const string ServiceDefaultScriptFileName = "Service.ps1";
 16    private const string ProductName = "kestrun";
 17    private const string RuntimePackageIdPrefix = "Kestrun.Service";
 18    private const string DefaultNuGetServiceIndexUrl = "https://api.nuget.org/v3/index.json";
 19    private const string ServiceDeploymentProductFolderName = "Kestrun";
 20    private const string ServiceDeploymentServicesFolderName = "Services";
 21    private const string ServiceBundleRuntimeDirectoryName = "Runtime";
 22    private const string ServiceBundleModulesDirectoryName = "Modules";
 23    private const string ServiceBundleScriptDirectoryName = "Application";
 24    private const string WindowsServiceRuntimeBinaryName = "kestrun.exe";
 25    private const string UnixServiceRuntimeBinaryName = "kestrun";
 26    private const string ModuleVersionOption = "--version";
 27    private const string ModuleScopeOption = "--scope";
 28    private const string ModuleForceOption = "--force";
 29    private const string ModuleScopeLocalValue = "local";
 30    private const string ModuleScopeGlobalValue = "global";
 31    private const string NoCheckOption = "--nocheck";
 32    private const string NoCheckAliasOption = "--no-check";
 33    private const string RawOption = "--raw";
 34    private const string PowerShellGalleryApiBaseUri = "https://www.powershellgallery.com/api/v2";
 135    private static readonly Regex ModuleVersionPatternRegex = ModuleVersionRegex();
 136    private static readonly Regex ModulePrereleasePatternRegex = ModulePrereleaseRegex();
 137    private static readonly HttpClient GalleryHttpClient = CreateGalleryHttpClient();
 138    private static readonly HttpClient ServiceContentRootHttpClient = CreateServiceContentRootHttpClient();
 139    private static readonly string[] ServiceBundleModuleExclusionPatterns =
 140    [
 141        "lib/runtimes/*",
 142        "lib/net8.0/*",
 143        "lib/Microsoft.CodeAnalysis/4*/*",
 144    ];
 45    private enum CommandMode
 46    {
 47        Run,
 48        ModuleInstall,
 49        ModuleUpdate,
 50        ModuleRemove,
 51        ModuleInfo,
 52        ServiceInstall,
 53        ServiceUpdate,
 54        ServiceRemove,
 55        ServiceStart,
 56        ServiceStop,
 57        ServiceQuery,
 58        ServiceInfo,
 59    }
 60
 61    private enum ModuleCommandAction
 62    {
 63        Install,
 64        Update,
 65        Remove,
 66    }
 67
 68    private enum ModuleStorageScope
 69    {
 70        Local,
 71        Global,
 72    }
 73
 27774    private sealed record ParsedCommand(
 4275        CommandMode Mode,
 2576        string ScriptPath,
 2477        bool ScriptPathProvided,
 278        string[] ScriptArguments,
 1379        string? KestrunFolder,
 2080        string? KestrunManifestPath,
 3481        string? ServiceName,
 2082        bool ServiceNameProvided,
 983        string? ServiceLogPath,
 284        string? ServiceUser,
 185        string? ServicePassword,
 486        string? ModuleVersion,
 487        ModuleStorageScope ModuleScope,
 288        bool ModuleForce,
 3289        string? ServiceContentRoot,
 690        string? ServiceDeploymentRoot,
 891        string? ServiceRuntimeSource,
 892        string? ServiceRuntimePackage,
 793        string? ServiceRuntimeVersion,
 594        string? ServiceRuntimePackageId,
 695        string? ServiceRuntimeCache,
 3796        string? ServiceContentRootChecksum,
 697        string? ServiceContentRootChecksumAlgorithm,
 3398        string? ServiceContentRootBearerToken,
 3399        bool ServiceContentRootIgnoreCertificate,
 33100        string[] ServiceContentRootHeaders,
 7101        bool ServiceFailback = false,
 13102        bool ServiceUseRepositoryKestrun = false,
 9103        bool JsonOutput = false,
 287104        bool RawOutput = false);
 105
 0106    private sealed record ServiceRegisterOptions(
 0107        string ServiceName,
 0108        string ServiceHostExecutablePath,
 0109        string RunnerExecutablePath,
 0110        string ScriptPath,
 0111        string ModuleManifestPath,
 0112        string[] ScriptArguments,
 0113        string? ServiceLogPath,
 0114        string? ServiceUser,
 0115        string? ServicePassword);
 116
 5117    private sealed record GlobalOptions(
 4118        string[] CommandArgs,
 7119        bool SkipGalleryCheck);
 120
 4121    private sealed record InstalledModuleRecord(
 3122        string Version,
 4123        string ManifestPath);
 124
 5125    private sealed record ServiceBundleLayout(
 4126        string RootPath,
 4127        string RuntimeExecutablePath,
 3128        string ServiceHostExecutablePath,
 5129        string ScriptPath,
 9130        string ModuleManifestPath);
 131
 13132    private sealed record ResolvedServiceRuntimePackage(
 2133        string Rid,
 2134        string PackageId,
 5135        string PackageVersion,
 7136        string PackagePath,
 5137        string ExtractionRoot,
 9138        string ServiceHostExecutablePath,
 19139        string ModulesPath);
 140
 62141    private sealed record ResolvedServiceScriptSource(
 31142        string FullScriptPath,
 30143        string? FullContentRoot,
 31144        string RelativeScriptPath,
 18145        string? TemporaryContentRootPath,
 10146        string? DescriptorServiceName,
 0147        string? DescriptorServiceDescription,
 6148        string? DescriptorServiceVersion,
 4149        string? DescriptorServiceLogPath,
 1150        IReadOnlyList<string> DescriptorPreservePaths,
 63151        IReadOnlyList<string> DescriptorApplicationDataFolders);
 152
 41153    private sealed record ServiceInstallDescriptor(
 5154        string FormatVersion,
 17155        string Name,
 16156        string EntryPoint,
 15157        string Description,
 18158        string? Version,
 16159        string? ServiceLogPath,
 18160        IReadOnlyList<string> PreservePaths,
 59161        IReadOnlyList<string> ApplicationDataFolders);
 162
 163    [GeneratedRegex("--service-log-path\\s+(\\\"(?<quoted>[^\\\"]+)\\\"|(?<plain>\\S+))", RegexOptions.IgnoreCase | Rege
 164    private static partial Regex ServiceLogPathRegex();
 165    [GeneratedRegex("^\\s*ModuleVersion\\s*=\\s*['\\\"](?<value>[^'\\\"]+)['\\\"]", RegexOptions.IgnoreCase | RegexOptio
 166    private static partial Regex ModuleVersionRegex();
 167    [GeneratedRegex("^\\s*Prerelease\\s*=\\s*['\\\"](?<value>[^'\\\"]+)['\\\"]", RegexOptions.IgnoreCase | RegexOptions.
 168    private static partial Regex ModulePrereleaseRegex();
 169}

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/Program.RunCommands.cs

#LineLine coverage
 1using System.Diagnostics;
 2
 3namespace Kestrun.Tool;
 4
 5internal static partial class Program
 6{
 7    /// <summary>
 8    /// Executes the target script by delegating to the dedicated service-host executable.
 9    /// </summary>
 10    /// <param name="scriptPath">Absolute path to the script to execute.</param>
 11    /// <param name="scriptArguments">Command-line arguments passed to the target script.</param>
 12    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param>
 13    /// <returns>Process exit code.</returns>
 14    private static int ExecuteScriptViaServiceHost(string scriptPath, IReadOnlyList<string> scriptArguments, string modu
 15    {
 016        if (!TryResolveServiceRuntimePackage(
 017                runtimeSource: null,
 018                runtimePackage: null,
 019                runtimeVersion: null,
 020                runtimePackageId: null,
 021                runtimeCache: null,
 022            bearerToken: null,
 023            customHeaders: [],
 024            ignoreCertificate: false,
 025                requireModules: false,
 026                allowToolDistributionFallback: true,
 027                out var runtimePackageLayout,
 028                out var runtimeError))
 29        {
 030            Console.Error.WriteLine(runtimeError);
 031            return 1;
 32        }
 33
 034        var serviceHostExecutablePath = runtimePackageLayout.ServiceHostExecutablePath;
 35
 036        if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
 37        {
 038            TryEnsureServiceRuntimeExecutablePermissions(serviceHostExecutablePath);
 39        }
 40
 041        var runnerExecutablePath = ResolveCurrentProcessPathOrFallback(serviceHostExecutablePath);
 042        var hostArguments = BuildDedicatedServiceHostRunArguments(
 043            runnerExecutablePath,
 044            scriptPath,
 045            moduleManifestPath,
 046            scriptArguments,
 047            ShouldDiscoverPowerShellHomeForManifest(moduleManifestPath));
 48
 049        return RunForegroundProcess(serviceHostExecutablePath, hostArguments);
 50    }
 51
 52    /// <summary>
 53    /// Runs a child process in foreground mode, inheriting the current console handles.
 54    /// </summary>
 55    /// <param name="fileName">Executable to run.</param>
 56    /// <param name="arguments">Argument tokens.</param>
 57    /// <returns>Process exit code.</returns>
 58    private static int RunForegroundProcess(string fileName, IReadOnlyList<string> arguments)
 59    {
 60        try
 61        {
 162            var startInfo = new ProcessStartInfo
 163            {
 164                FileName = fileName,
 165                UseShellExecute = false,
 166                RedirectStandardOutput = false,
 167                RedirectStandardError = false,
 168                CreateNoWindow = false,
 169            };
 70
 671            foreach (var argument in arguments)
 72            {
 273                startInfo.ArgumentList.Add(argument);
 74            }
 75
 176            using var process = Process.Start(startInfo);
 177            if (process is null)
 78            {
 079                Console.Error.WriteLine($"Failed to start process: {fileName}");
 080                return 1;
 81            }
 82
 183            process.WaitForExit();
 184            return process.ExitCode;
 85        }
 086        catch (Exception ex)
 87        {
 088            Console.Error.WriteLine($"Failed to start process '{fileName}': {ex.Message}");
 089            return 1;
 90        }
 191    }
 92
 93    /// <summary>
 94    /// Parses arguments for the run command.
 95    /// </summary>
 96    /// <param name="args">Raw command-line arguments.</param>
 97    /// <param name="startIndex">Index after command token.</param>
 98    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 99    /// <param name="parsedCommand">Parsed command payload.</param>
 100    /// <param name="error">Error message when parsing fails.</param>
 101    /// <returns>True when parsing succeeds.</returns>
 102    private static bool TryParseRunArguments(string[] args, int startIndex, string? kestrunFolder, string? kestrunManife
 103    {
 8104        parsedCommand = new ParsedCommand(CommandMode.Run, string.Empty, false, [], kestrunFolder, kestrunManifestPath, 
 8105        error = string.Empty;
 106
 8107        var state = new RunParseState(startIndex, kestrunFolder, kestrunManifestPath);
 13108        while (state.Index < args.Length)
 109        {
 12110            var current = args[state.Index];
 12111            if (TryCaptureRunScriptArguments(args, current, ref state))
 112            {
 113                break;
 114            }
 115
 11116            if (TryConsumeRunOption(args, current, ref state, out error))
 117            {
 8118                if (!string.IsNullOrEmpty(error))
 119                {
 4120                    return false;
 121                }
 122
 123                continue;
 124            }
 125
 3126            if (current.StartsWith("--", StringComparison.Ordinal))
 127            {
 1128                error = $"Unknown option: {current}";
 1129                return false;
 130            }
 131
 2132            if (state.ScriptPathSet)
 133            {
 1134                error = "Script arguments must be preceded by --arguments (or --).";
 1135                return false;
 136            }
 137
 1138            state.ScriptPath = current;
 1139            state.ScriptPathSet = true;
 1140            state.Index += 1;
 141        }
 142
 2143        if (!state.ScriptPathSet)
 144        {
 145            // Default to ./Service.ps1 when a script path is not explicitly provided.
 1146            state.ScriptPath = RunDefaultScriptFileName;
 147        }
 148
 2149        parsedCommand = new ParsedCommand(CommandMode.Run, state.ScriptPath, state.ScriptPathSet, state.ScriptArguments,
 150
 2151        return true;
 152    }
 153
 154    /// <summary>
 155    /// Captures script arguments when the explicit script argument separator is encountered.
 156    /// </summary>
 157    /// <param name="args">Raw command-line arguments.</param>
 158    /// <param name="current">Current token being processed.</param>
 159    /// <param name="state">Mutable run command parse state.</param>
 160    /// <returns>True when the parser should stop consuming additional tokens.</returns>
 161    private static bool TryCaptureRunScriptArguments(string[] args, string current, ref RunParseState state)
 162    {
 12163        if (current is not "--arguments" and not "--")
 164        {
 11165            return false;
 166        }
 167
 1168        state.ScriptArguments = [.. args.Skip(state.Index + 1)];
 1169        return true;
 170    }
 171
 172    /// <summary>
 173    /// Consumes a supported run command option and updates the parse state.
 174    /// </summary>
 175    /// <param name="args">Raw command-line arguments.</param>
 176    /// <param name="current">Current token being processed.</param>
 177    /// <param name="state">Mutable run command parse state.</param>
 178    /// <param name="error">Error message when option parsing fails.</param>
 179    /// <returns>True when the current token was handled as a supported option.</returns>
 180    private static bool TryConsumeRunOption(string[] args, string current, ref RunParseState state, out string error)
 181    {
 11182        error = string.Empty;
 11183        if (current is "--script")
 184        {
 4185            return TryConsumeRunScriptOption(args, ref state, out error);
 186        }
 187
 7188        if (current is "--kestrun-folder" or "-k")
 189        {
 2190            return TryConsumeRunKestrunFolderOption(args, ref state, out error);
 191        }
 192
 5193        if (current is "--kestrun-manifest" or "-m")
 194        {
 2195            return TryConsumeRunKestrunManifestOption(args, ref state, out error);
 196        }
 197        // Add additional options here as else-if branches.
 3198        return false;
 199    }
 200
 201    /// <summary>
 202    /// Consumes the run command script path option.
 203    /// </summary>
 204    /// <param name="args">Raw command-line arguments.</param>
 205    /// <param name="state">Mutable run command parse state.</param>
 206    /// <param name="error">Error message when parsing fails.</param>
 207    /// <returns>True when the option was consumed.</returns>
 208    private static bool TryConsumeRunScriptOption(string[] args, ref RunParseState state, out string error)
 209    {
 4210        error = string.Empty;
 4211        if (state.ScriptPathSet)
 212        {
 1213            error = "Script path was provided multiple times. Use either positional script path or --script once.";
 1214            return true;
 215        }
 216
 3217        if (state.Index + 1 >= args.Length)
 218        {
 1219            error = "Missing value for --script.";
 1220            return true;
 221        }
 222
 2223        state.ScriptPath = args[state.Index + 1];
 2224        state.ScriptPathSet = true;
 2225        state.Index += 2;
 2226        return true;
 227    }
 228
 229    /// <summary>
 230    /// Consumes the run command Kestrun folder option.
 231    /// </summary>
 232    /// <param name="args">Raw command-line arguments.</param>
 233    /// <param name="state">Mutable run command parse state.</param>
 234    /// <param name="error">Error message when parsing fails.</param>
 235    /// <returns>True when the option was consumed.</returns>
 236    private static bool TryConsumeRunKestrunFolderOption(string[] args, ref RunParseState state, out string error)
 237    {
 2238        if (state.Index + 1 >= args.Length)
 239        {
 1240            error = "Missing value for --kestrun-folder.";
 1241            return true;
 242        }
 243
 1244        state.KestrunFolder = args[state.Index + 1];
 1245        state.Index += 2;
 1246        error = string.Empty;
 1247        return true;
 248    }
 249
 250    /// <summary>
 251    /// Consumes the run command Kestrun manifest option.
 252    /// </summary>
 253    /// <param name="args">Raw command-line arguments.</param>
 254    /// <param name="state">Mutable run command parse state.</param>
 255    /// <param name="error">Error message when parsing fails.</param>
 256    /// <returns>True when the option was consumed.</returns>
 257    private static bool TryConsumeRunKestrunManifestOption(string[] args, ref RunParseState state, out string error)
 258    {
 2259        if (state.Index + 1 >= args.Length)
 260        {
 1261            error = "Missing value for --kestrun-manifest.";
 1262            return true;
 263        }
 264
 1265        state.KestrunManifestPath = args[state.Index + 1];
 1266        state.Index += 2;
 1267        error = string.Empty;
 1268        return true;
 269    }
 270
 271    /// <summary>
 272    /// Holds mutable state while parsing run command arguments.
 273    /// </summary>
 274    /// <remarks>
 275    /// Initializes a new parse-state instance.
 276    /// </remarks>
 277    /// <param name="index">Current parser index.</param>
 278    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 279    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 8280    private sealed class RunParseState(int index, string? kestrunFolder, string? kestrunManifestPath)
 281    {
 282        /// <summary>
 283        /// Gets or sets the current parser index.
 284        /// </summary>
 55285        public int Index { get; set; } = index;
 286
 287        /// <summary>
 288        /// Gets or sets the optional folder containing Kestrun.psd1.
 289        /// </summary>
 11290        public string? KestrunFolder { get; set; } = kestrunFolder;
 291
 292        /// <summary>
 293        /// Gets or sets the optional explicit path to Kestrun.psd1.
 294        /// </summary>
 11295        public string? KestrunManifestPath { get; set; } = kestrunManifestPath;
 296
 297        /// <summary>
 298        /// Gets or sets the resolved script path token.
 299        /// </summary>
 14300        public string ScriptPath { get; set; } = string.Empty;
 301
 302        /// <summary>
 303        /// Gets or sets a value indicating whether a script path was provided explicitly.
 304        /// </summary>
 13305        public bool ScriptPathSet { get; set; }
 306
 307        /// <summary>
 308        /// Gets or sets script arguments while ensuring a non-null array.
 309        /// </summary>
 11310        public string[] ScriptArguments { get; set; } = [];
 311    }
 312}

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/Program.ServiceCommands.cs

#LineLine coverage
 1
 2using System.Globalization;
 3
 4namespace Kestrun.Tool;
 5
 6internal static partial class Program
 7{
 8    /// <summary>
 9    /// Installs a service/daemon entry that runs the target script through the KestrunTool executable.
 10    /// </summary>
 11    /// <param name="command">Parsed command information.</param>
 12    /// <returns>Process exit code.</returns>
 13    private static int InstallService(ParsedCommand command, bool skipGalleryCheck)
 14    {
 315        if (IsRuntimeOnlyServiceInstall(command))
 16        {
 317            return CacheServiceRuntimePackage(command);
 18        }
 19
 020        if (!TryResolveInstallServiceInputs(
 021                command,
 022                out var serviceName,
 023                out var serviceVersion,
 024                out var effectiveServiceLogPath,
 025                out var scriptSource,
 026                out var moduleManifestPath,
 027                out var inputExitCode))
 28        {
 029            return inputExitCode;
 30        }
 31
 32        try
 33        {
 034            if (!TryRunInstallServicePreflight(command, serviceName, moduleManifestPath, effectiveServiceLogPath, skipGa
 35            {
 036                return preflightExitCode;
 37            }
 38
 039            if (!TryPrepareInstallServiceBundle(command, serviceName, serviceVersion, scriptSource, moduleManifestPath, 
 40            {
 041                return bundleExitCode;
 42            }
 43            // Service bundle preparation should not fail silently, but check for null just in case.
 044            return InstallPreparedServiceForCurrentPlatform(command, serviceName, effectiveServiceLogPath, serviceBundle
 45        }
 46        finally
 47        {
 048            TryCleanupTemporaryServiceContentRoot(scriptSource.TemporaryContentRootPath);
 049        }
 050    }
 51
 52    /// <summary>
 53    /// Determines whether the service install request is runtime-only and should only populate the runtime cache.
 54    /// </summary>
 55    /// <param name="command">Parsed command information.</param>
 56    /// <returns>True when no service content root or script was supplied and runtime acquisition options were requested
 57    private static bool IsRuntimeOnlyServiceInstall(ParsedCommand command)
 358        => string.IsNullOrWhiteSpace(command.ServiceContentRoot)
 359            && !command.ScriptPathProvided
 360            && (!string.IsNullOrWhiteSpace(command.ServiceRuntimeSource)
 361                || !string.IsNullOrWhiteSpace(command.ServiceRuntimePackage)
 362                || !string.IsNullOrWhiteSpace(command.ServiceRuntimeVersion)
 363                || !string.IsNullOrWhiteSpace(command.ServiceRuntimePackageId));
 64
 65    /// <summary>
 66    /// Resolves and stores the requested service runtime package in the local cache without installing a service.
 67    /// </summary>
 68    /// <param name="command">Parsed command information.</param>
 69    /// <returns>Process exit code.</returns>
 70    private static int CacheServiceRuntimePackage(ParsedCommand command)
 71    {
 372        if (!TryResolveServiceRuntimePackage(
 373                command.ServiceRuntimeSource,
 374                command.ServiceRuntimePackage,
 375                command.ServiceRuntimeVersion,
 376                command.ServiceRuntimePackageId,
 377                command.ServiceRuntimeCache,
 378                command.ServiceContentRootBearerToken,
 379                command.ServiceContentRootHeaders,
 380                command.ServiceContentRootIgnoreCertificate,
 381                requireModules: true,
 382                allowToolDistributionFallback: false,
 383                out var runtimePackageLayout,
 384                out var runtimeError))
 85        {
 186            Console.Error.WriteLine(runtimeError);
 187            return 1;
 88        }
 89
 290        Console.WriteLine($"Cached service runtime package '{runtimePackageLayout.PackageId}' version '{runtimePackageLa
 291        if (!string.IsNullOrWhiteSpace(runtimePackageLayout.PackagePath))
 92        {
 293            Console.WriteLine($"Package: {runtimePackageLayout.PackagePath}");
 94        }
 95
 296        Console.WriteLine($"Extracted: {runtimePackageLayout.ExtractionRoot}");
 297        return 0;
 98    }
 99
 100    /// <summary>
 101    /// Resolves and validates install-service inputs that are independent of operating-system install mechanics.
 102    /// </summary>
 103    /// <param name="command">Parsed command information.</param>
 104    /// <param name="serviceName">Resolved service name.</param>
 105    /// <param name="serviceVersion">Resolved service descriptor version when provided.</param>
 106    /// <param name="effectiveServiceLogPath">Effective service log path (CLI override or descriptor value).</param>
 107    /// <param name="scriptSource">Resolved service script source.</param>
 108    /// <param name="moduleManifestPath">Resolved module manifest path.</param>
 109    /// <param name="exitCode">Exit code when validation fails.</param>
 110    /// <returns>True when inputs are valid and resolved.</returns>
 111    private static bool TryResolveInstallServiceInputs(
 112        ParsedCommand command,
 113        out string serviceName,
 114        out string? serviceVersion,
 115        out string? effectiveServiceLogPath,
 116        out ResolvedServiceScriptSource scriptSource,
 117        out string moduleManifestPath,
 118        out int exitCode)
 119    {
 5120        serviceName = string.Empty;
 5121        serviceVersion = null;
 5122        effectiveServiceLogPath = null;
 5123        scriptSource = CreateEmptyResolvedServiceScriptSource();
 5124        moduleManifestPath = string.Empty;
 5125        exitCode = 0;
 126
 5127        if (!TryResolveServiceScriptSource(command, out scriptSource, out var scriptError))
 128        {
 0129            Console.Error.WriteLine(scriptError);
 0130            exitCode = 2;
 0131            return false;
 132        }
 133
 5134        var resolvedServiceName = string.IsNullOrWhiteSpace(scriptSource.DescriptorServiceName)
 5135            ? command.ServiceName
 5136            : scriptSource.DescriptorServiceName;
 137
 5138        if (string.IsNullOrWhiteSpace(resolvedServiceName))
 139        {
 0140            Console.Error.WriteLine("Service name is required in Service.psd1 (Name) when using --package.");
 0141            exitCode = 2;
 0142            return false;
 143        }
 144
 5145        serviceName = resolvedServiceName;
 5146        serviceVersion = scriptSource.DescriptorServiceVersion;
 5147        effectiveServiceLogPath = !string.IsNullOrWhiteSpace(command.ServiceLogPath)
 5148            ? command.ServiceLogPath
 5149            : scriptSource.DescriptorServiceLogPath;
 150
 5151        var cleanupScriptSourceOnFailure = true;
 152        try
 153        {
 5154            var locatedModuleManifestPath = LocateModuleManifest(command.KestrunManifestPath, command.KestrunFolder);
 5155            if (locatedModuleManifestPath is null)
 156            {
 4157                WriteModuleNotFoundMessage(command.KestrunManifestPath, command.KestrunFolder, Console.Error.WriteLine);
 4158                exitCode = 3;
 4159                return false;
 160            }
 161
 1162            moduleManifestPath = locatedModuleManifestPath;
 1163            cleanupScriptSourceOnFailure = false;
 1164            return true;
 165        }
 166        finally
 167        {
 5168            if (cleanupScriptSourceOnFailure)
 169            {
 4170                TryCleanupTemporaryServiceContentRoot(scriptSource.TemporaryContentRootPath);
 171            }
 5172        }
 5173    }
 174
 175    /// <summary>
 176    /// Removes a temporary service content root directory when archive extraction mode was used.
 177    /// </summary>
 178    /// <param name="temporaryContentRootPath">Temporary extraction path.</param>
 179    private static void TryCleanupTemporaryServiceContentRoot(string? temporaryContentRootPath)
 180    {
 4181        if (string.IsNullOrWhiteSpace(temporaryContentRootPath) || !Directory.Exists(temporaryContentRootPath))
 182        {
 2183            return;
 184        }
 185
 186        try
 187        {
 2188            TryDeleteDirectoryWithRetry(temporaryContentRootPath, maxAttempts: 5, initialDelayMs: 50);
 2189        }
 0190        catch
 191        {
 192            // Best-effort cleanup; do not fail install/remove flow on temp directory cleanup errors.
 0193        }
 2194    }
 195
 196    /// <summary>
 197    /// Performs install-service preflight checks such as Windows elevation checks, gallery warnings, and privileged-use
 198    /// </summary>
 199    /// <param name="command">Parsed command information.</param>
 200    /// <param name="serviceName">Resolved service name.</param>
 201    /// <param name="moduleManifestPath">Resolved module manifest path.</param>
 202    /// <param name="serviceLogPath">Effective service log path.</param>
 203    /// <param name="skipGalleryCheck">True when gallery checks should be skipped.</param>
 204    /// <param name="exitCode">Exit code when a preflight check fails.</param>
 205    /// <returns>True when preflight checks pass.</returns>
 206    private static bool TryRunInstallServicePreflight(
 207        ParsedCommand command,
 208        string serviceName,
 209        string moduleManifestPath,
 210        string? serviceLogPath,
 211        bool skipGalleryCheck,
 212        out int exitCode)
 213    {
 0214        exitCode = 0;
 215
 0216        if (OperatingSystem.IsWindows() && !TryPreflightWindowsServiceInstall(command, serviceName, out var preflightExi
 217        {
 0218            exitCode = preflightExitCode;
 0219            return false;
 220        }
 221
 0222        if (!skipGalleryCheck)
 223        {
 0224            WarnIfNewerGalleryVersionExists(moduleManifestPath, serviceLogPath);
 225        }
 226
 0227        if (OperatingSystem.IsLinux() && !string.IsNullOrWhiteSpace(command.ServiceUser) && !IsLikelyRunningAsRootOnLinu
 228        {
 0229            Console.Error.WriteLine("Linux system service install with --service-user requires root privileges.");
 0230            exitCode = 1;
 0231            return false;
 232        }
 233
 0234        if (OperatingSystem.IsMacOS() && !string.IsNullOrWhiteSpace(command.ServiceUser) && !IsLikelyRunningAsRootOnUnix
 235        {
 0236            Console.Error.WriteLine("macOS system daemon install with --service-user requires root privileges.");
 0237            exitCode = 1;
 0238            return false;
 239        }
 240
 0241        return true;
 242    }
 243
 244    /// <summary>
 245    /// Creates the service deployment bundle required by platform-specific service registration.
 246    /// </summary>
 247    /// <param name="command">Parsed command information.</param>
 248    /// <param name="serviceName">Resolved service name.</param>
 249    /// <param name="serviceVersion">Optional resolved service version.</param>
 250    /// <param name="scriptSource">Resolved service script source.</param>
 251    /// <param name="moduleManifestPath">Resolved module manifest path.</param>
 252    /// <param name="serviceBundle">Prepared service bundle.</param>
 253    /// <param name="exitCode">Exit code when bundle preparation fails.</param>
 254    /// <returns>True when bundle preparation succeeds.</returns>
 255    private static bool TryPrepareInstallServiceBundle(
 256        ParsedCommand command,
 257        string serviceName,
 258        string? serviceVersion,
 259        ResolvedServiceScriptSource scriptSource,
 260        string moduleManifestPath,
 261        out ServiceBundleLayout serviceBundle,
 262        out int exitCode)
 263    {
 1264        serviceBundle = default!;
 1265        exitCode = 0;
 266
 1267        if (!TryResolveServiceRuntimePackage(
 1268            command.ServiceRuntimeSource,
 1269            command.ServiceRuntimePackage,
 1270            command.ServiceRuntimeVersion,
 1271            command.ServiceRuntimePackageId,
 1272            command.ServiceRuntimeCache,
 1273            command.ServiceContentRootBearerToken,
 1274            command.ServiceContentRootHeaders,
 1275            command.ServiceContentRootIgnoreCertificate,
 1276            requireModules: true,
 1277            allowToolDistributionFallback: false,
 1278            out var runtimePackageLayout,
 1279            out var runtimeError))
 280        {
 0281            Console.Error.WriteLine(runtimeError);
 0282            exitCode = 1;
 0283            return false;
 284        }
 285
 1286        if (!TryPrepareServiceBundle(
 1287                serviceName,
 1288                scriptSource.FullScriptPath,
 1289                moduleManifestPath,
 1290                scriptSource.FullContentRoot,
 1291                scriptSource.RelativeScriptPath,
 1292            runtimePackageLayout,
 1293                out var preparedServiceBundle,
 1294                out var bundleError,
 1295                command.ServiceDeploymentRoot,
 1296                serviceVersion))
 297        {
 0298            Console.Error.WriteLine(bundleError);
 0299            exitCode = 1;
 0300            return false;
 301        }
 302
 1303        if (preparedServiceBundle is null)
 304        {
 0305            Console.Error.WriteLine("Service bundle preparation failed.");
 0306            exitCode = 1;
 0307            return false;
 308        }
 309
 1310        serviceBundle = preparedServiceBundle;
 1311        return true;
 312    }
 313
 314    /// <summary>
 315    /// Installs a prepared service bundle using the platform-specific daemon/service mechanism.
 316    /// </summary>
 317    /// <param name="command">Parsed command information.</param>
 318    /// <param name="serviceName">Resolved service name.</param>
 319    /// <param name="serviceLogPath">Effective service log path.</param>
 320    /// <param name="serviceBundle">Prepared service bundle.</param>
 321    /// <returns>Process exit code.</returns>
 322    private static int InstallPreparedServiceForCurrentPlatform(ParsedCommand command, string serviceName, string? servi
 323    {
 1324        var daemonArgs = BuildDaemonHostArgumentsForService(
 1325            serviceName,
 1326            serviceBundle.ServiceHostExecutablePath,
 1327            serviceBundle.RuntimeExecutablePath,
 1328            serviceBundle.ScriptPath,
 1329            serviceBundle.ModuleManifestPath,
 1330            command.ScriptArguments,
 1331            serviceLogPath);
 1332        var workingDirectory = Path.GetDirectoryName(serviceBundle.ScriptPath) ?? Environment.CurrentDirectory;
 333
 1334        if (OperatingSystem.IsWindows())
 335        {
 0336            return InstallWindowsService(
 0337                command,
 0338                serviceName,
 0339                serviceLogPath,
 0340                serviceBundle.ServiceHostExecutablePath,
 0341                serviceBundle.RuntimeExecutablePath,
 0342                serviceBundle.ScriptPath,
 0343                serviceBundle.ModuleManifestPath);
 344        }
 345
 1346        if (OperatingSystem.IsLinux())
 347        {
 1348            var result = InstallLinuxUserDaemon(serviceName, serviceBundle.ServiceHostExecutablePath, daemonArgs, workin
 1349            WriteServiceOperationResult("install", "linux", serviceName, result, serviceLogPath);
 1350            return result;
 351        }
 352
 0353        if (OperatingSystem.IsMacOS())
 354        {
 0355            var result = InstallMacLaunchAgent(serviceName, serviceBundle.ServiceHostExecutablePath, daemonArgs, working
 0356            WriteServiceOperationResult("install", "macos", serviceName, result, serviceLogPath);
 0357            return result;
 358        }
 359
 0360        Console.Error.WriteLine("Service installation is not supported on this OS.");
 0361        return 1;
 362    }
 363
 364    /// <summary>
 365    /// Removes a previously installed service/daemon entry.
 366    /// </summary>
 367    /// <param name="command">Parsed command information.</param>
 368    /// <returns>Process exit code.</returns>
 369    private static int RemoveService(ParsedCommand command)
 370    {
 2371        if (string.IsNullOrWhiteSpace(command.ServiceName))
 372        {
 2373            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 2374            return 2;
 375        }
 376
 0377        var serviceName = command.ServiceName;
 378
 379        int result;
 0380        if (OperatingSystem.IsWindows())
 381        {
 0382            result = RemoveWindowsService(command);
 0383            if (result == 0)
 384            {
 0385                TryRemoveServiceBundle(serviceName, command.ServiceDeploymentRoot);
 386            }
 387
 0388            return result;
 389        }
 390
 0391        if (OperatingSystem.IsLinux())
 392        {
 0393            result = RemoveLinuxUserDaemon(serviceName);
 0394            if (result == 0)
 395            {
 0396                TryRemoveServiceBundle(serviceName, command.ServiceDeploymentRoot);
 397            }
 398
 0399            WriteServiceOperationResult("remove", "linux", serviceName, result, command.ServiceLogPath);
 400
 0401            return result;
 402        }
 403
 0404        if (OperatingSystem.IsMacOS())
 405        {
 0406            result = RemoveMacLaunchAgent(serviceName);
 0407            if (result == 0)
 408            {
 0409                TryRemoveServiceBundle(serviceName, command.ServiceDeploymentRoot);
 410            }
 411
 0412            WriteServiceOperationResult("remove", "macos", serviceName, result, command.ServiceLogPath);
 413
 0414            return result;
 415        }
 416
 0417        Console.Error.WriteLine("Service removal is not supported on this OS.");
 0418        return 1;
 419    }
 420
 421    /// <summary>
 422    /// Starts a previously installed service/daemon entry.
 423    /// </summary>
 424    /// <param name="command">Parsed command information.</param>
 425    /// <returns>Process exit code.</returns>
 426    private static int StartService(ParsedCommand command)
 427    {
 3428        if (string.IsNullOrWhiteSpace(command.ServiceName))
 429        {
 2430            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 2431            return 2;
 432        }
 433
 1434        var serviceName = command.ServiceName;
 435        ServiceControlResult result;
 436
 1437        if (OperatingSystem.IsWindows())
 438        {
 0439            result = StartWindowsService(serviceName, command.ServiceLogPath, command.RawOutput);
 0440            return WriteServiceControlResult(command, result);
 441        }
 442
 1443        if (OperatingSystem.IsLinux())
 444        {
 1445            result = StartLinuxUserDaemon(serviceName, command.ServiceLogPath, command.RawOutput);
 1446            return WriteServiceControlResult(command, result);
 447        }
 448
 0449        if (OperatingSystem.IsMacOS())
 450        {
 0451            result = StartMacLaunchAgent(serviceName, command.ServiceLogPath, command.RawOutput);
 0452            return WriteServiceControlResult(command, result);
 453        }
 454
 0455        Console.Error.WriteLine("Service start is not supported on this OS.");
 0456        return 1;
 457    }
 458
 459    /// <summary>
 460    /// Stops a previously installed service/daemon entry.
 461    /// </summary>
 462    /// <param name="command">Parsed command information.</param>
 463    /// <returns>Process exit code.</returns>
 464    private static int StopService(ParsedCommand command)
 465    {
 3466        if (string.IsNullOrWhiteSpace(command.ServiceName))
 467        {
 2468            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 2469            return 2;
 470        }
 471
 1472        var serviceName = command.ServiceName;
 473        ServiceControlResult result;
 474
 1475        if (OperatingSystem.IsWindows())
 476        {
 0477            result = StopWindowsService(serviceName, command.ServiceLogPath, command.RawOutput);
 0478            return WriteServiceControlResult(command, result);
 479        }
 480
 1481        if (OperatingSystem.IsLinux())
 482        {
 1483            result = StopLinuxUserDaemon(serviceName, command.ServiceLogPath, command.RawOutput);
 1484            return WriteServiceControlResult(command, result);
 485        }
 486
 0487        if (OperatingSystem.IsMacOS())
 488        {
 0489            result = StopMacLaunchAgent(serviceName, command.ServiceLogPath, command.RawOutput);
 0490            return WriteServiceControlResult(command, result);
 491        }
 492
 0493        Console.Error.WriteLine("Service stop is not supported on this OS.");
 0494        return 1;
 495    }
 496
 497    /// <summary>
 498    /// Queries a previously installed service/daemon entry.
 499    /// </summary>
 500    /// <param name="command">Parsed command information.</param>
 501    /// <returns>Process exit code.</returns>
 502    private static int QueryService(ParsedCommand command)
 503    {
 2504        if (string.IsNullOrWhiteSpace(command.ServiceName))
 505        {
 1506            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 1507            return 2;
 508        }
 509
 1510        var serviceName = command.ServiceName;
 511        ServiceControlResult result;
 512
 1513        if (OperatingSystem.IsWindows())
 514        {
 0515            result = QueryWindowsService(serviceName, command.ServiceLogPath, command.RawOutput);
 0516            return WriteServiceControlResult(command, result);
 517        }
 518
 1519        if (OperatingSystem.IsLinux())
 520        {
 1521            result = QueryLinuxUserDaemon(serviceName, command.ServiceLogPath, command.RawOutput);
 1522            return WriteServiceControlResult(command, result);
 523        }
 524
 0525        if (OperatingSystem.IsMacOS())
 526        {
 0527            result = QueryMacLaunchAgent(serviceName, command.ServiceLogPath, command.RawOutput);
 0528            return WriteServiceControlResult(command, result);
 529        }
 530
 0531        Console.Error.WriteLine("Service query is not supported on this OS.");
 0532        return 1;
 533    }
 534
 535    /// <summary>
 536    /// Represents normalized start/stop/query operation output.
 537    /// </summary>
 538    /// <param name="Operation">Operation token (start/stop/query).</param>
 539    /// <param name="ServiceName">Service identifier.</param>
 540    /// <param name="Platform">Platform token (windows/linux/macos).</param>
 541    /// <param name="State">Normalized service state.</param>
 542    /// <param name="Pid">Service process id when available.</param>
 543    /// <param name="ExitCode">Command exit code.</param>
 544    /// <param name="Message">Human-readable status message.</param>
 545    /// <param name="RawOutput">Raw standard output from the OS command when available.</param>
 546    /// <param name="RawError">Raw standard error from the OS command when available.</param>
 9547    private sealed record ServiceControlResult(
 7548        string Operation,
 4549        string ServiceName,
 9550        string Platform,
 4551        string State,
 4552        int? Pid,
 19553        int ExitCode,
 4554        string Message,
 4555        string RawOutput,
 12556        string RawError)
 557    {
 558        /// <summary>
 559        /// Returns true when the operation succeeded.
 560        /// </summary>
 4561        public bool Success => ExitCode == 0;
 562    }
 563
 564    /// <summary>
 565    /// Writes a service control result using table/json/raw output selection.
 566    /// </summary>
 567    /// <param name="command">Parsed command containing output switches.</param>
 568    /// <param name="result">Normalized service operation result.</param>
 569    /// <returns>Operation exit code.</returns>
 570    private static int WriteServiceControlResult(ParsedCommand command, ServiceControlResult result)
 571    {
 6572        if (command.RawOutput)
 573        {
 2574            if (!string.IsNullOrWhiteSpace(result.RawOutput))
 575            {
 2576                Console.WriteLine(result.RawOutput.TrimEnd());
 577            }
 578
 2579            if (!string.IsNullOrWhiteSpace(result.RawError))
 580            {
 1581                Console.Error.WriteLine(result.RawError.TrimEnd());
 582            }
 583
 2584            return result.ExitCode;
 585        }
 586
 4587        if (command.JsonOutput)
 588        {
 1589            var payload = new
 1590            {
 1591                result.Operation,
 1592                result.ServiceName,
 1593                result.Platform,
 1594                Status = result.Success ? "success" : "failed",
 1595                result.State,
 1596                PID = result.Pid,
 1597                result.ExitCode,
 1598                result.Message,
 1599            };
 600
 1601            Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOpti
 1602            {
 1603                WriteIndented = true,
 1604            }));
 1605            return result.ExitCode;
 606        }
 607
 3608        var columns = new[] { "Operation", "Service", "Platform", "Status", "State", "PID", "ExitCode", "Message" };
 3609        var values = new[]
 3610        {
 3611            result.Operation,
 3612            result.ServiceName,
 3613            result.Platform,
 3614            result.Success ? "success" : "failed",
 3615            result.State,
 3616            result.Pid?.ToString(CultureInfo.InvariantCulture) ?? "-",
 3617            result.ExitCode.ToString(CultureInfo.InvariantCulture),
 3618            result.Message,
 3619        };
 620
 3621        var widths = columns
 24622            .Select((header, index) => Math.Max(header.Length, values[index].Length))
 3623            .ToArray();
 624
 27625        Console.WriteLine(string.Join(" | ", columns.Select((header, index) => header.PadRight(widths[index]))));
 27626        Console.WriteLine(string.Join("-+-", widths.Select(static width => new string('-', width))));
 27627        Console.WriteLine(string.Join(" | ", values.Select((value, index) => value.PadRight(widths[index]))));
 3628        return result.ExitCode;
 629    }
 630
 631    /// <summary>
 632    /// Returns installed service descriptor metadata plus the resolved service bundle path.
 633    /// </summary>
 634    /// <param name="command">Parsed command information.</param>
 635    /// <returns>Process exit code.</returns>
 636    private static int InfoService(ParsedCommand command)
 637    {
 4638        if (!string.IsNullOrWhiteSpace(command.ServiceName))
 639        {
 2640            if (!TryResolveInstalledServiceBundleRoot(command.ServiceName, command.ServiceDeploymentRoot, out var servic
 641            {
 1642                Console.Error.WriteLine(resolutionError);
 1643                return 1;
 644            }
 645
 1646            var scriptRoot = Path.Combine(serviceRootPath, ServiceBundleScriptDirectoryName);
 1647            if (!TryResolveServiceInstallDescriptor(scriptRoot, out var descriptor, out var descriptorError))
 648            {
 0649                Console.Error.WriteLine(descriptorError);
 0650                return 1;
 651            }
 652
 1653            var descriptorPath = Path.Combine(scriptRoot, ServiceDescriptorFileName);
 1654            var backups = GetServiceBackupSnapshots(serviceRootPath);
 1655            var payload = new
 1656            {
 1657                command.ServiceName,
 1658                ServicePath = serviceRootPath,
 1659                DescriptorPath = descriptorPath,
 1660                Descriptor = new
 1661                {
 1662                    descriptor.FormatVersion,
 1663                    descriptor.Name,
 1664                    descriptor.EntryPoint,
 1665                    descriptor.Description,
 1666                    descriptor.Version,
 1667                    descriptor.ServiceLogPath,
 1668                    descriptor.PreservePaths,
 1669                    descriptor.ApplicationDataFolders,
 1670                },
 1671                Backups = backups.Select(static backup => new
 1672                {
 1673                    backup.Version,
 1674                    UpdatedAtUtc = backup.UpdatedAtUtc?.ToString("o"),
 1675                    backup.Path,
 1676                }),
 1677            };
 678
 1679            if (command.JsonOutput)
 680            {
 1681                Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializer
 1682                {
 1683                    WriteIndented = true,
 1684                }));
 685            }
 686            else
 687            {
 0688                WriteServiceInfoHumanReadable(payload.ServiceName, payload.ServicePath, payload.DescriptorPath, descript
 689            }
 690
 1691            return 0;
 692        }
 693
 2694        if (!TryEnumerateInstalledServiceBundleRoots(command.ServiceDeploymentRoot, out var bundleRoots, out var enumera
 695        {
 0696            Console.Error.WriteLine(enumerateError);
 0697            return 1;
 698        }
 699
 2700        var services = new List<(string ServiceName, string ServicePath, string DescriptorPath, ServiceInstallDescriptor
 10701        foreach (var bundleRoot in bundleRoots)
 702        {
 3703            var scriptRoot = Path.Combine(bundleRoot, ServiceBundleScriptDirectoryName);
 3704            if (!TryResolveServiceInstallDescriptor(scriptRoot, out var descriptor, out _))
 705            {
 706                continue;
 707            }
 708
 3709            var descriptorPath = Path.Combine(scriptRoot, ServiceDescriptorFileName);
 3710            var backups = GetServiceBackupSnapshots(bundleRoot);
 3711            services.Add((descriptor.Name, bundleRoot, descriptorPath, descriptor, backups));
 712        }
 713
 2714        if (services.Count == 0)
 715        {
 0716            Console.Error.WriteLine("No installed Kestrun services were found.");
 0717            return 1;
 718        }
 719
 2720        if (command.JsonOutput)
 721        {
 2722            Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(new
 2723            {
 3724                Services = services.Select(static service => new
 3725                {
 3726                    service.ServiceName,
 3727                    service.ServicePath,
 3728                    service.DescriptorPath,
 3729                    Descriptor = new
 3730                    {
 3731                        service.Descriptor.FormatVersion,
 3732                        service.Descriptor.Name,
 3733                        service.Descriptor.EntryPoint,
 3734                        service.Descriptor.Description,
 3735                        service.Descriptor.Version,
 3736                        service.Descriptor.ServiceLogPath,
 3737                        service.Descriptor.PreservePaths,
 3738                        service.Descriptor.ApplicationDataFolders,
 3739                    },
 2740                    Backups = service.Backups.Select(static backup => new
 2741                    {
 2742                        backup.Version,
 2743                        UpdatedAtUtc = backup.UpdatedAtUtc?.ToString("o"),
 2744                        backup.Path,
 2745                    }),
 3746                }),
 2747            }, new System.Text.Json.JsonSerializerOptions
 2748            {
 2749                WriteIndented = true,
 2750            }));
 751
 2752            return 0;
 753        }
 754
 0755        foreach (var (ServiceName, ServicePath, DescriptorPath, Descriptor, Backups) in services)
 756        {
 0757            WriteServiceInfoHumanReadable(ServiceName, ServicePath, DescriptorPath, Descriptor, Backups);
 0758            Console.WriteLine();
 759        }
 760
 0761        return 0;
 762    }
 763
 764    /// <summary>
 765    /// Writes service information using a human-readable text format.
 766    /// </summary>
 767    /// <param name="serviceName">Service name.</param>
 768    /// <param name="servicePath">Service bundle path.</param>
 769    /// <param name="descriptorPath">Service descriptor file path.</param>
 770    /// <param name="descriptor">Parsed descriptor payload.</param>
 771    /// <param name="backups">Available backup snapshots for the service.</param>
 772    private static void WriteServiceInfoHumanReadable(
 773        string serviceName,
 774        string servicePath,
 775        string descriptorPath,
 776        ServiceInstallDescriptor descriptor,
 777        IReadOnlyList<ServiceBackupSnapshot> backups)
 778    {
 1779        Console.WriteLine($"Name: {serviceName}");
 1780        Console.WriteLine($"Path: {servicePath}");
 1781        Console.WriteLine($"Descriptor: {descriptorPath}");
 1782        Console.WriteLine($"FormatVersion: {descriptor.FormatVersion}");
 1783        Console.WriteLine($"EntryPoint: {descriptor.EntryPoint}");
 1784        Console.WriteLine($"Description: {descriptor.Description}");
 1785        Console.WriteLine($"Version: {(string.IsNullOrWhiteSpace(descriptor.Version) ? "(not set)" : descriptor.Version)
 1786        Console.WriteLine($"ServiceLogPath: {(string.IsNullOrWhiteSpace(descriptor.ServiceLogPath) ? "(not set)" : descr
 1787        Console.WriteLine($"PreservePaths: {(descriptor.PreservePaths.Count == 0 ? "(none)" : string.Join(", ", descript
 1788        Console.WriteLine($"ApplicationDataFolders: {(descriptor.ApplicationDataFolders.Count == 0 ? "(none)" : string.J
 789
 1790        if (backups.Count == 0)
 791        {
 0792            Console.WriteLine("Backups: (none)");
 0793            return;
 794        }
 795
 1796        Console.WriteLine($"Backups: {backups.Count}");
 4797        foreach (var backup in backups)
 798        {
 1799            var updatedAt = backup.UpdatedAtUtc?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'") ?? "(unknown)";
 1800            Console.WriteLine($"  {backup.Version} | {updatedAt} | {backup.Path}");
 801        }
 1802    }
 803
 804    /// <summary>
 805    /// Represents one service backup snapshot.
 806    /// </summary>
 807    /// <param name="Version">Backup snapshot version token (folder name).</param>
 808    /// <param name="UpdatedAtUtc">Parsed update timestamp in UTC when available.</param>
 809    /// <param name="Path">Backup directory path.</param>
 28810    private sealed record ServiceBackupSnapshot(string Version, DateTimeOffset? UpdatedAtUtc, string Path);
 811
 812    /// <summary>
 813    /// Enumerates service backup snapshots from the backup root ordered from newest to oldest.
 814    /// </summary>
 815    /// <param name="serviceRootPath">Service root path.</param>
 816    /// <returns>Ordered backup snapshot list.</returns>
 817    private static List<ServiceBackupSnapshot> GetServiceBackupSnapshots(string serviceRootPath)
 818    {
 6819        var backupRoot = Path.Combine(serviceRootPath, "backup");
 6820        return !Directory.Exists(backupRoot)
 6821            ? []
 6822            : [.. Directory
 6823            .GetDirectories(backupRoot)
 6824            .Select(static directoryPath =>
 6825            {
 5826                var versionToken = Path.GetFileName(directoryPath);
 5827                DateTimeOffset? updatedAtUtc = null;
 5828                if (DateTime.TryParseExact(
 5829                        versionToken,
 5830                        "yyyyMMddHHmmss",
 5831                        CultureInfo.InvariantCulture,
 5832                        DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
 5833                        out var parsedUtc))
 6834                {
 5835                    updatedAtUtc = new DateTimeOffset(parsedUtc, TimeSpan.Zero);
 6836                }
 6837
 5838                return new ServiceBackupSnapshot(versionToken, updatedAtUtc, Path.GetFullPath(directoryPath));
 6839            })
 2840            .OrderByDescending(static backup => backup.UpdatedAtUtc)
 2841            .ThenByDescending(static backup => backup.Version, StringComparer.OrdinalIgnoreCase)
 6842            .ToList()];
 843    }
 844
 845    /// <summary>
 846    /// Enumerates installed service bundle roots across deployment roots.
 847    /// </summary>
 848    /// <param name="deploymentRootOverride">Optional deployment root override.</param>
 849    /// <param name="bundleRoots">Resolved service bundle roots.</param>
 850    /// <param name="error">Enumeration error details.</param>
 851    /// <returns>True when at least one service bundle root is found.</returns>
 852    private static bool TryEnumerateInstalledServiceBundleRoots(string? deploymentRootOverride, out List<string> bundleR
 853    {
 4854        bundleRoots = [];
 4855        error = string.Empty;
 856
 4857        var candidateRoots = new List<string>();
 4858        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 859        {
 4860            candidateRoots.Add(deploymentRootOverride);
 861        }
 862
 4863        candidateRoots.AddRange(GetServiceDeploymentRootCandidates());
 864
 40865        foreach (var root in candidateRoots.Distinct(StringComparer.OrdinalIgnoreCase))
 866        {
 16867            if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
 868            {
 869                continue;
 870            }
 871
 20872            foreach (var serviceBaseRoot in Directory.GetDirectories(root))
 873            {
 6874                var directDescriptorPath = Path.Combine(serviceBaseRoot, ServiceBundleScriptDirectoryName, ServiceDescri
 6875                if (File.Exists(directDescriptorPath))
 876                {
 6877                    bundleRoots.Add(Path.GetFullPath(serviceBaseRoot));
 878                }
 879            }
 880        }
 881
 4882        bundleRoots = [.. bundleRoots
 4883            .Distinct(StringComparer.OrdinalIgnoreCase)
 9884            .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)];
 885
 4886        if (bundleRoots.Count == 0)
 887        {
 1888            error = "No installed Kestrun services were found.";
 1889            return false;
 890        }
 891
 3892        return true;
 893    }
 894
 895    /// <summary>
 896    /// Updates an installed service bundle from a package and/or module manifest source.
 897    /// </summary>
 898    /// <param name="command">Parsed command information.</param>
 899    /// <returns>Process exit code.</returns>
 900    private static int UpdateService(ParsedCommand command)
 901    {
 0902        if (!TryValidateUpdateServiceCommand(command, out var hasPackageUpdate, out var hasModuleUpdate, out var validat
 903        {
 0904            return validationExitCode;
 905        }
 906
 0907        if (!TryResolveUpdateServiceIdentity(command, hasPackageUpdate, out var serviceName, out var scriptSource, out v
 908        {
 0909            return identityExitCode;
 910        }
 911
 912        try
 913        {
 0914            return ExecuteServiceUpdateFlow(
 0915                command,
 0916                serviceName,
 0917                hasPackageUpdate,
 0918                hasModuleUpdate,
 0919                ref scriptSource,
 0920                ref packageSourceResolved);
 921        }
 922        finally
 923        {
 0924            TryCleanupTemporaryServiceContentRoot(scriptSource.TemporaryContentRootPath);
 0925        }
 0926    }
 927
 928    /// <summary>
 929    /// Executes the resolved service update workflow including failback and update operations.
 930    /// </summary>
 931    /// <param name="command">Parsed command information.</param>
 932    /// <param name="serviceName">Resolved service name.</param>
 933    /// <param name="hasPackageUpdate">True when package/content-root update was requested.</param>
 934    /// <param name="hasModuleUpdate">True when module update was requested.</param>
 935    /// <param name="scriptSource">Resolved package script source; may be populated lazily.</param>
 936    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> is already resolved.</param>
 937    /// <returns>Process exit code.</returns>
 938    private static int ExecuteServiceUpdateFlow(
 939        ParsedCommand command,
 940        string serviceName,
 941        bool hasPackageUpdate,
 942        bool hasModuleUpdate,
 943        ref ResolvedServiceScriptSource scriptSource,
 944        ref bool packageSourceResolved)
 945    {
 0946        if (!TryPrepareServiceUpdateExecution(serviceName, command.ServiceDeploymentRoot, out var paths, out var prepare
 947        {
 0948            return prepareExitCode;
 949        }
 950
 0951        if (command.ServiceFailback)
 952        {
 0953            return TryExecuteServiceFailback(paths, out var failbackExitCode)
 0954                ? 0
 0955                : failbackExitCode;
 956        }
 957
 0958        if (!TryRunServiceUpdateOperations(
 0959                command,
 0960                hasPackageUpdate,
 0961                hasModuleUpdate,
 0962                paths,
 0963                ref scriptSource,
 0964                ref packageSourceResolved,
 0965                out var applicationUpdated,
 0966                out var moduleUpdated,
 0967                out var serviceHostUpdated,
 0968                out var updateExitCode))
 969        {
 0970            return updateExitCode;
 971        }
 972
 0973        WriteServiceUpdateSummary(serviceName, paths, applicationUpdated, moduleUpdated, serviceHostUpdated);
 0974        return 0;
 975    }
 976
 977    /// <summary>
 978    /// Validates service run state and resolves installed bundle paths for update execution.
 979    /// </summary>
 980    /// <param name="serviceName">Resolved service name.</param>
 981    /// <param name="deploymentRootOverride">Optional deployment root override.</param>
 982    /// <param name="paths">Resolved service update path set.</param>
 983    /// <param name="exitCode">Exit code when validation or path resolution fails.</param>
 984    /// <returns>True when update execution prerequisites are satisfied.</returns>
 985    private static bool TryPrepareServiceUpdateExecution(
 986        string serviceName,
 987        string? deploymentRootOverride,
 988        out ServiceUpdatePaths paths,
 989        out int exitCode)
 990    {
 1991        paths = default;
 1992        exitCode = 0;
 993
 1994        if (!TryEnsureServiceIsStopped(serviceName, out var runningError))
 995        {
 0996            Console.Error.WriteLine(runningError);
 0997            exitCode = 1;
 0998            return false;
 999        }
 1000
 11001        if (!TryResolveServiceUpdatePaths(serviceName, deploymentRootOverride, out paths, out var pathExitCode))
 1002        {
 11003            exitCode = pathExitCode;
 11004            return false;
 1005        }
 1006
 01007        return true;
 1008    }
 1009
 1010    /// <summary>
 1011    /// Applies package, module, and service-host updates for a resolved service bundle.
 1012    /// </summary>
 1013    /// <param name="command">Parsed command information.</param>
 1014    /// <param name="hasPackageUpdate">True when package/content-root update was requested.</param>
 1015    /// <param name="hasModuleUpdate">True when module update was requested.</param>
 1016    /// <param name="paths">Resolved service update path set.</param>
 1017    /// <param name="scriptSource">Resolved package script source; may be populated when required.</param>
 1018    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> is already resolved.</param>
 1019    /// <param name="applicationUpdated">True when application files were updated.</param>
 1020    /// <param name="moduleUpdated">True when module files were updated.</param>
 1021    /// <param name="serviceHostUpdated">True when service host binaries were updated.</param>
 1022    /// <param name="exitCode">Exit code when any update stage fails.</param>
 1023    /// <returns>True when all requested update stages succeed.</returns>
 1024    private static bool TryRunServiceUpdateOperations(
 1025        ParsedCommand command,
 1026        bool hasPackageUpdate,
 1027        bool hasModuleUpdate,
 1028        ServiceUpdatePaths paths,
 1029        ref ResolvedServiceScriptSource scriptSource,
 1030        ref bool packageSourceResolved,
 1031        out bool applicationUpdated,
 1032        out bool moduleUpdated,
 1033        out bool serviceHostUpdated,
 1034        out int exitCode)
 1035    {
 11036        moduleUpdated = false;
 11037        serviceHostUpdated = false;
 11038        exitCode = 0;
 1039
 11040        if (!TryApplyServicePackageUpdate(command, hasPackageUpdate, paths, ref scriptSource, ref packageSourceResolved,
 1041        {
 01042            exitCode = packageExitCode;
 01043            return false;
 1044        }
 1045
 11046        if (!TryApplyServiceModuleUpdate(command, hasModuleUpdate, paths, out moduleUpdated, out var moduleExitCode))
 1047        {
 01048            exitCode = moduleExitCode;
 01049            return false;
 1050        }
 1051
 11052        if (!TryApplyServiceHostUpdate(paths, out serviceHostUpdated, out var hostExitCode))
 1053        {
 11054            exitCode = hostExitCode;
 11055            return false;
 1056        }
 1057
 01058        return true;
 1059    }
 1060
 1061    /// <summary>
 1062    /// Validates the supported option combinations for service update.
 1063    /// </summary>
 1064    /// <param name="command">Parsed command information.</param>
 1065    /// <param name="hasPackageUpdate">True when package/content-root update was requested.</param>
 1066    /// <param name="hasModuleUpdate">True when module update was requested.</param>
 1067    /// <param name="exitCode">Validation exit code when invalid options are supplied.</param>
 1068    /// <returns>True when option combinations are valid.</returns>
 1069    private static bool TryValidateUpdateServiceCommand(
 1070        ParsedCommand command,
 1071        out bool hasPackageUpdate,
 1072        out bool hasModuleUpdate,
 1073        out int exitCode)
 1074    {
 41075        hasPackageUpdate = !string.IsNullOrWhiteSpace(command.ServiceContentRoot);
 41076        hasModuleUpdate = !string.IsNullOrWhiteSpace(command.KestrunManifestPath) || command.ServiceUseRepositoryKestrun
 41077        exitCode = 0;
 1078
 41079        if (command.ServiceFailback && (!string.IsNullOrWhiteSpace(command.KestrunManifestPath) || command.ServiceUseRep
 1080        {
 11081            Console.Error.WriteLine("--failback cannot be combined with --kestrun, --kestrun-module, or --kestrun-manife
 11082            exitCode = 2;
 11083            return false;
 1084        }
 1085
 31086        if (command.ServiceUseRepositoryKestrun && !string.IsNullOrWhiteSpace(command.KestrunManifestPath))
 1087        {
 11088            Console.Error.WriteLine("--kestrun cannot be combined with --kestrun-module or --kestrun-manifest.");
 11089            exitCode = 2;
 11090            return false;
 1091        }
 1092
 21093        if (!command.ServiceFailback && !hasPackageUpdate && !hasModuleUpdate)
 1094        {
 11095            Console.Error.WriteLine("Service update requires --package and/or --kestrun-module/--kestrun-manifest, or us
 11096            exitCode = 2;
 11097            return false;
 1098        }
 1099
 11100        return true;
 1101    }
 1102
 1103    /// <summary>
 1104    /// Resolves service identity and initial package metadata required by update and failback flows.
 1105    /// </summary>
 1106    /// <param name="command">Parsed command information.</param>
 1107    /// <param name="hasPackageUpdate">True when package/content-root update was requested.</param>
 1108    /// <param name="serviceName">Resolved service name.</param>
 1109    /// <param name="scriptSource">Resolved package script source when required.</param>
 1110    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> was resolved.</param>
 1111    /// <param name="exitCode">Exit code when identity resolution fails.</param>
 1112    /// <returns>True when identity resolution succeeds.</returns>
 1113    private static bool TryResolveUpdateServiceIdentity(
 1114        ParsedCommand command,
 1115        bool hasPackageUpdate,
 1116        out string serviceName,
 1117        out ResolvedServiceScriptSource scriptSource,
 1118        out bool packageSourceResolved,
 1119        out int exitCode)
 1120    {
 01121        serviceName = command.ServiceName ?? string.Empty;
 01122        scriptSource = CreateEmptyResolvedServiceScriptSource();
 01123        packageSourceResolved = false;
 01124        exitCode = 0;
 1125
 01126        if (!string.IsNullOrWhiteSpace(serviceName))
 1127        {
 01128            return true;
 1129        }
 1130
 01131        if (!hasPackageUpdate)
 1132        {
 01133            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 01134            exitCode = 2;
 01135            return false;
 1136        }
 1137
 01138        if (!TryResolveServiceScriptSource(command, out scriptSource, out var scriptError))
 1139        {
 01140            Console.Error.WriteLine(scriptError);
 01141            exitCode = 2;
 01142            return false;
 1143        }
 1144
 01145        packageSourceResolved = true;
 01146        if (string.IsNullOrWhiteSpace(scriptSource.DescriptorServiceName))
 1147        {
 01148            Console.Error.WriteLine("Service name is required in Service.psd1 (Name) when using --package.");
 01149            exitCode = 2;
 01150            return false;
 1151        }
 1152
 01153        serviceName = scriptSource.DescriptorServiceName;
 01154        return true;
 1155    }
 1156
 1157    /// <summary>
 1158    /// Resolves the installed service bundle paths required by update operations.
 1159    /// </summary>
 1160    /// <param name="serviceName">Target service name.</param>
 1161    /// <param name="deploymentRootOverride">Optional deployment root override.</param>
 1162    /// <param name="paths">Resolved service update path set.</param>
 1163    /// <param name="exitCode">Exit code when path resolution fails.</param>
 1164    /// <returns>True when service bundle paths are resolved.</returns>
 1165    private static bool TryResolveServiceUpdatePaths(
 1166        string serviceName,
 1167        string? deploymentRootOverride,
 1168        out ServiceUpdatePaths paths,
 1169        out int exitCode)
 1170    {
 21171        paths = default;
 21172        exitCode = 0;
 1173
 21174        if (!TryResolveInstalledServiceBundleRoot(serviceName, deploymentRootOverride, out var serviceRootPath, out var 
 1175        {
 11176            Console.Error.WriteLine(resolutionError);
 11177            exitCode = 1;
 11178            return false;
 1179        }
 1180
 11181        paths = new ServiceUpdatePaths(
 11182            serviceRootPath,
 11183            Path.Combine(serviceRootPath, ServiceBundleScriptDirectoryName),
 11184            Path.Combine(serviceRootPath, ServiceBundleModulesDirectoryName, ModuleName),
 11185            Path.Combine(serviceRootPath, "backup", DateTime.UtcNow.ToString("yyyyMMddHHmmss")));
 11186        return true;
 1187    }
 1188
 1189    /// <summary>
 1190    /// Executes service failback and writes a JSON summary payload.
 1191    /// </summary>
 1192    /// <param name="paths">Resolved service update path set.</param>
 1193    /// <param name="exitCode">Exit code when failback fails.</param>
 1194    /// <returns>True when failback succeeds.</returns>
 1195    private static bool TryExecuteServiceFailback(ServiceUpdatePaths paths, out int exitCode)
 1196    {
 11197        exitCode = 0;
 1198
 11199        if (!TryFailbackServiceFromBackup(paths.ServiceRootPath, paths.ScriptRoot, paths.ModuleRoot, out var failbackSum
 1200        {
 01201            Console.Error.WriteLine(failbackError);
 01202            exitCode = 1;
 01203            return false;
 1204        }
 1205
 11206        Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(failbackSummary, new System.Text.Json.JsonSerializer
 11207        {
 11208            WriteIndented = true,
 11209        }));
 1210
 11211        return true;
 1212    }
 1213
 1214    /// <summary>
 1215    /// Applies package/application update to the installed service bundle.
 1216    /// </summary>
 1217    /// <param name="command">Parsed command information.</param>
 1218    /// <param name="hasPackageUpdate">True when package update was requested.</param>
 1219    /// <param name="paths">Resolved service update path set.</param>
 1220    /// <param name="scriptSource">Current resolved script source; may be populated when needed.</param>
 1221    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> is already resolved.</param>
 1222    /// <param name="applicationUpdated">True when application files were updated.</param>
 1223    /// <param name="exitCode">Exit code when update fails.</param>
 1224    /// <returns>True when package update succeeds or is not requested.</returns>
 1225    private static bool TryApplyServicePackageUpdate(
 1226        ParsedCommand command,
 1227        bool hasPackageUpdate,
 1228        ServiceUpdatePaths paths,
 1229        ref ResolvedServiceScriptSource scriptSource,
 1230        ref bool packageSourceResolved,
 1231        out bool applicationUpdated,
 1232        out int exitCode)
 1233    {
 11234        applicationUpdated = false;
 11235        exitCode = 0;
 1236
 11237        if (!hasPackageUpdate)
 1238        {
 01239            return true;
 1240        }
 1241
 11242        if (!TryEnsureServicePackageSourceResolved(command, ref scriptSource, ref packageSourceResolved, out exitCode))
 1243        {
 01244            return false;
 1245        }
 1246
 11247        if (!TryValidateServicePackageUpdateContext(paths.ScriptRoot, scriptSource, out var contentRoot, out exitCode))
 1248        {
 01249            return false;
 1250        }
 1251
 11252        var preserveRelativePaths = scriptSource.DescriptorPreservePaths
 11253            .Concat(scriptSource.DescriptorApplicationDataFolders)
 11254            .ToArray();
 1255
 11256        if (!TryApplyServiceApplicationReplacement(paths, contentRoot, preserveRelativePaths, out exitCode))
 1257        {
 01258            return false;
 1259        }
 1260
 11261        applicationUpdated = true;
 11262        return true;
 1263    }
 1264
 1265    /// <summary>
 1266    /// Ensures package script metadata is resolved for service package update operations.
 1267    /// </summary>
 1268    /// <param name="command">Parsed command information.</param>
 1269    /// <param name="scriptSource">Current resolved script source; populated when resolution is required.</param>
 1270    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> is already resolved.</param>
 1271    /// <param name="exitCode">Exit code when source resolution fails.</param>
 1272    /// <returns>True when package script source is available.</returns>
 1273    private static bool TryEnsureServicePackageSourceResolved(
 1274        ParsedCommand command,
 1275        ref ResolvedServiceScriptSource scriptSource,
 1276        ref bool packageSourceResolved,
 1277        out int exitCode)
 1278    {
 11279        exitCode = 0;
 11280        if (packageSourceResolved)
 1281        {
 11282            return true;
 1283        }
 1284
 01285        if (!TryResolveServiceScriptSource(command, out scriptSource, out var scriptError))
 1286        {
 01287            Console.Error.WriteLine(scriptError);
 01288            exitCode = 2;
 01289            return false;
 1290        }
 1291
 01292        packageSourceResolved = true;
 01293        return true;
 1294    }
 1295
 1296    /// <summary>
 1297    /// Validates package update preconditions against the installed service descriptor and content root.
 1298    /// </summary>
 1299    /// <param name="scriptRoot">Installed service script root.</param>
 1300    /// <param name="scriptSource">Resolved incoming package script source.</param>
 1301    /// <param name="contentRoot">Validated package content root path.</param>
 1302    /// <param name="exitCode">Exit code when validation fails.</param>
 1303    /// <returns>True when package update preconditions are satisfied.</returns>
 1304    private static bool TryValidateServicePackageUpdateContext(
 1305        string scriptRoot,
 1306        ResolvedServiceScriptSource scriptSource,
 1307        out string contentRoot,
 1308        out int exitCode)
 1309    {
 11310        contentRoot = string.Empty;
 11311        exitCode = 0;
 1312
 11313        if (!TryResolveServiceInstallDescriptor(scriptRoot, out var runningDescriptor, out var currentDescriptorError))
 1314        {
 01315            Console.Error.WriteLine(currentDescriptorError);
 01316            exitCode = 1;
 01317            return false;
 1318        }
 1319
 11320        if (!TryValidateServicePackageVersionUpdate(
 11321                runningDescriptor.Version,
 11322                scriptSource.DescriptorServiceVersion,
 11323                out _,
 11324                out var versionWarning,
 11325                out var versionValidationError))
 1326        {
 01327            Console.Error.WriteLine(versionValidationError);
 01328            exitCode = 1;
 01329            return false;
 1330        }
 1331
 11332        if (!string.IsNullOrWhiteSpace(versionWarning))
 1333        {
 01334            Console.WriteLine(versionWarning);
 1335        }
 1336
 11337        if (string.IsNullOrWhiteSpace(scriptSource.FullContentRoot) || !Directory.Exists(scriptSource.FullContentRoot))
 1338        {
 01339            Console.Error.WriteLine("Resolved package content root is not available for update.");
 01340            exitCode = 1;
 01341            return false;
 1342        }
 1343
 11344        contentRoot = scriptSource.FullContentRoot;
 1345
 11346        return true;
 1347    }
 1348
 1349    /// <summary>
 1350    /// Backs up and replaces the installed service application directory from package content.
 1351    /// </summary>
 1352    /// <param name="paths">Resolved service update path set.</param>
 1353    /// <param name="contentRoot">Validated source content root path.</param>
 1354    /// <param name="preserveRelativePaths">Optional preserve-path entries from the service descriptor.</param>
 1355    /// <param name="exitCode">Exit code when replacement fails.</param>
 1356    /// <returns>True when backup and replacement succeed.</returns>
 1357    private static bool TryApplyServiceApplicationReplacement(
 1358        ServiceUpdatePaths paths,
 1359        string contentRoot,
 1360        IReadOnlyList<string>? preserveRelativePaths,
 1361        out int exitCode)
 1362    {
 21363        exitCode = 0;
 1364
 21365        if (!TryBackupDirectory(paths.ScriptRoot, Path.Combine(paths.BackupRoot, "application"), out var backupAppError)
 1366        {
 01367            Console.Error.WriteLine(backupAppError);
 01368            exitCode = 1;
 01369            return false;
 1370        }
 1371
 21372        if (!TryReplaceDirectoryFromSource(
 21373            contentRoot,
 21374                paths.ScriptRoot,
 21375                "Updating service application",
 21376                out var appReplaceError,
 21377                exclusionPatterns: null,
 21378            preserveRelativePaths: preserveRelativePaths))
 1379        {
 01380            Console.Error.WriteLine(appReplaceError);
 01381            exitCode = 1;
 01382            return false;
 1383        }
 1384
 21385        return true;
 1386    }
 1387
 1388    /// <summary>
 1389    /// Applies module update logic for either repository module replacement or explicit manifest replacement.
 1390    /// </summary>
 1391    /// <param name="command">Parsed command information.</param>
 1392    /// <param name="hasModuleUpdate">True when module update was requested.</param>
 1393    /// <param name="paths">Resolved service update path set.</param>
 1394    /// <param name="moduleUpdated">True when bundled module files were replaced.</param>
 1395    /// <param name="exitCode">Exit code when module update fails.</param>
 1396    /// <returns>True when module update succeeds or is not requested.</returns>
 1397    private static bool TryApplyServiceModuleUpdate(
 1398        ParsedCommand command,
 1399        bool hasModuleUpdate,
 1400        ServiceUpdatePaths paths,
 1401        out bool moduleUpdated,
 1402        out int exitCode)
 1403    {
 11404        moduleUpdated = false;
 11405        exitCode = 0;
 1406
 11407        if (!hasModuleUpdate)
 1408        {
 01409            return true;
 1410        }
 1411
 11412        if (!TryResolveUpdateManifestPath(command, out var manifestPath, out exitCode))
 1413        {
 01414            return false;
 1415        }
 1416
 11417        if (!TryResolveSourceModuleRoot(manifestPath, out var sourceModuleRoot, out var sourceModuleRootError))
 1418        {
 01419            Console.Error.WriteLine(sourceModuleRootError);
 01420            exitCode = 1;
 01421            return false;
 1422        }
 1423
 11424        if (!command.ServiceUseRepositoryKestrun)
 1425        {
 11426            return TryApplyDirectModuleReplacement(sourceModuleRoot, paths, out moduleUpdated, out exitCode);
 1427        }
 1428
 01429        var bundledManifestPath = Path.Combine(paths.ModuleRoot, ModuleManifestFileName);
 01430        if (!TryEvaluateRepositoryModuleUpdateNeeded(manifestPath, bundledManifestPath, out var shouldUpdateBundledModul
 1431        {
 01432            Console.Error.WriteLine(moduleDecisionError);
 01433            exitCode = 1;
 01434            return false;
 1435        }
 1436
 01437        if (!shouldUpdateBundledModule)
 1438        {
 01439            Console.WriteLine(moduleDecisionMessage);
 01440            return true;
 1441        }
 1442
 01443        return TryApplyDirectModuleReplacement(sourceModuleRoot, paths, out moduleUpdated, out exitCode);
 1444    }
 1445
 1446    /// <summary>
 1447    /// Replaces the bundled module directory from the provided source module root with backup creation.
 1448    /// </summary>
 1449    /// <param name="sourceModuleRoot">Source module root directory.</param>
 1450    /// <param name="paths">Resolved service update path set.</param>
 1451    /// <param name="moduleUpdated">True when module replacement succeeds.</param>
 1452    /// <param name="exitCode">Exit code when replacement fails.</param>
 1453    /// <returns>True when module replacement succeeds.</returns>
 1454    private static bool TryApplyDirectModuleReplacement(
 1455        string sourceModuleRoot,
 1456        ServiceUpdatePaths paths,
 1457        out bool moduleUpdated,
 1458        out int exitCode)
 1459    {
 21460        moduleUpdated = false;
 21461        exitCode = 0;
 1462
 21463        if (!TryBackupDirectory(paths.ModuleRoot, Path.Combine(paths.BackupRoot, "module"), out var backupModuleError))
 1464        {
 01465            Console.Error.WriteLine(backupModuleError);
 01466            exitCode = 1;
 01467            return false;
 1468        }
 1469
 21470        if (!TryReplaceDirectoryFromSource(sourceModuleRoot, paths.ModuleRoot, "Updating bundled Kestrun module", out va
 1471        {
 01472            Console.Error.WriteLine(moduleReplaceError);
 01473            exitCode = 1;
 01474            return false;
 1475        }
 1476
 21477        moduleUpdated = true;
 21478        return true;
 1479    }
 1480
 1481    /// <summary>
 1482    /// Resolves the manifest path to use for service module update.
 1483    /// </summary>
 1484    /// <param name="command">Parsed command information.</param>
 1485    /// <param name="manifestPath">Resolved manifest path.</param>
 1486    /// <param name="exitCode">Exit code when resolution fails.</param>
 1487    /// <returns>True when manifest resolution succeeds.</returns>
 21488    private static bool TryResolveUpdateManifestPath(ParsedCommand command, out string manifestPath, out int exitCode) =
 1489
 1490    /// <summary>
 1491    /// Resolves the manifest path to use for service module update, using an explicit repository search root when reque
 1492    /// </summary>
 1493    /// <param name="command">Parsed command information.</param>
 1494    /// <param name="repositorySearchRoot">Directory used to discover repository-local module manifests.</param>
 1495    /// <param name="manifestPath">Resolved manifest path.</param>
 1496    /// <param name="exitCode">Exit code when resolution fails.</param>
 1497    /// <returns>True when manifest resolution succeeds.</returns>
 1498    private static bool TryResolveUpdateManifestPath(ParsedCommand command, string repositorySearchRoot, out string mani
 1499    {
 31500        manifestPath = string.Empty;
 31501        exitCode = 0;
 1502
 31503        var resolvedManifestPath = command.ServiceUseRepositoryKestrun
 31504            ? ResolveRepositoryModuleManifestPath(repositorySearchRoot)
 31505            : LocateModuleManifest(command.KestrunManifestPath, command.KestrunFolder);
 1506
 31507        if (resolvedManifestPath is null)
 1508        {
 11509            if (command.ServiceUseRepositoryKestrun)
 1510            {
 01511                Console.Error.WriteLine("Unable to locate repository module manifest at 'src/PowerShell/Kestrun/Kestrun.
 01512                exitCode = 1;
 01513                return false;
 1514            }
 1515
 11516            WriteModuleNotFoundMessage(command.KestrunManifestPath, command.KestrunFolder, Console.Error.WriteLine);
 11517            exitCode = 3;
 11518            return false;
 1519        }
 1520
 21521        manifestPath = resolvedManifestPath;
 21522        return true;
 1523    }
 1524
 1525    /// <summary>
 1526    /// Resolves and validates the source module root directory from a manifest path.
 1527    /// </summary>
 1528    /// <param name="manifestPath">Module manifest path.</param>
 1529    /// <param name="sourceModuleRoot">Resolved module root directory.</param>
 1530    /// <param name="error">Validation error details.</param>
 1531    /// <returns>True when the source module root is valid.</returns>
 1532    private static bool TryResolveSourceModuleRoot(string manifestPath, out string sourceModuleRoot, out string error)
 1533    {
 31534        sourceModuleRoot = Path.GetDirectoryName(Path.GetFullPath(manifestPath)) ?? string.Empty;
 31535        error = string.Empty;
 1536
 31537        if (!string.IsNullOrWhiteSpace(sourceModuleRoot) && Directory.Exists(sourceModuleRoot))
 1538        {
 21539            return true;
 1540        }
 1541
 11542        error = $"Unable to resolve module root from manifest path: {manifestPath}";
 11543        return false;
 1544    }
 1545
 1546    /// <summary>
 1547    /// Applies service-host runtime update when a newer host binary is available.
 1548    /// </summary>
 1549    /// <param name="paths">Resolved service update path set.</param>
 1550    /// <param name="serviceHostUpdated">True when service host binaries were updated.</param>
 1551    /// <param name="exitCode">Exit code when update fails.</param>
 1552    /// <returns>True when service host update succeeds.</returns>
 1553    private static bool TryApplyServiceHostUpdate(ServiceUpdatePaths paths, out bool serviceHostUpdated, out int exitCod
 1554    {
 11555        exitCode = 0;
 1556
 11557        var runtimeDirectory = Path.Combine(paths.ServiceRootPath, ServiceBundleRuntimeDirectoryName);
 11558        if (TryUpdateBundledServiceHostIfNewer(runtimeDirectory, Path.Combine(paths.BackupRoot, "servicehost"), out var 
 1559        {
 01560            return true;
 1561        }
 1562
 11563        Console.Error.WriteLine(hostUpdateError);
 11564        exitCode = 1;
 11565        return false;
 1566    }
 1567
 1568    /// <summary>
 1569    /// Writes service update results as indented JSON.
 1570    /// </summary>
 1571    /// <param name="serviceName">Resolved service name.</param>
 1572    /// <param name="paths">Resolved service update path set.</param>
 1573    /// <param name="applicationUpdated">True when application files were updated.</param>
 1574    /// <param name="moduleUpdated">True when module files were updated.</param>
 1575    /// <param name="serviceHostUpdated">True when service host was updated.</param>
 1576    private static void WriteServiceUpdateSummary(
 1577        string serviceName,
 1578        ServiceUpdatePaths paths,
 1579        bool applicationUpdated,
 1580        bool moduleUpdated,
 1581        bool serviceHostUpdated)
 1582    {
 11583        var summary = new
 11584        {
 11585            ServiceName = serviceName,
 11586            ServicePath = paths.ServiceRootPath,
 11587            ApplicationUpdated = applicationUpdated,
 11588            ModuleUpdated = moduleUpdated,
 11589            ServiceHostUpdated = serviceHostUpdated,
 11590            BackupPath = Directory.Exists(paths.BackupRoot) ? paths.BackupRoot : null,
 11591        };
 1592
 11593        Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(summary, new System.Text.Json.JsonSerializerOptions
 11594        {
 11595            WriteIndented = true,
 11596        }));
 11597    }
 1598
 1599    /// <summary>
 1600    /// Contains resolved directory paths used by service update and failback operations.
 1601    /// </summary>
 1602    /// <param name="ServiceRootPath">Installed service bundle root path.</param>
 1603    /// <param name="ScriptRoot">Installed service script directory path.</param>
 1604    /// <param name="ModuleRoot">Installed bundled module root path.</param>
 1605    /// <param name="BackupRoot">Backup snapshot root path for the current update operation.</param>
 1606    private readonly record struct ServiceUpdatePaths(
 41607        string ServiceRootPath,
 71608        string ScriptRoot,
 51609        string ModuleRoot,
 81610        string BackupRoot);
 1611
 1612    /// <summary>
 1613    /// Resolves the repository-local Kestrun manifest path by scanning current directory ancestors.
 1614    /// </summary>
 1615    /// <returns>Absolute manifest path when found; otherwise null.</returns>
 01616    private static string? ResolveRepositoryModuleManifestPath() => ResolveRepositoryModuleManifestPath(Environment.Curr
 1617
 1618    /// <summary>
 1619    /// Resolves the repository-local Kestrun manifest path by scanning ancestors starting from the specified directory.
 1620    /// </summary>
 1621    /// <param name="startDirectory">Directory to begin repository ancestor scanning from.</param>
 1622    /// <returns>Absolute manifest path when found; otherwise null.</returns>
 1623    private static string? ResolveRepositoryModuleManifestPath(string startDirectory)
 1624    {
 61625        foreach (var parent in EnumerateDirectoryAndParents(startDirectory))
 1626        {
 21627            var candidate = Path.Combine(parent, "src", "PowerShell", ModuleName, ModuleManifestFileName);
 21628            if (File.Exists(candidate))
 1629            {
 21630                return Path.GetFullPath(candidate);
 1631            }
 1632        }
 1633
 01634        return null;
 21635    }
 1636
 1637    /// <summary>
 1638    /// Determines whether repository module content should replace the bundled module based on semantic version compari
 1639    /// </summary>
 1640    /// <param name="repositoryManifestPath">Repository Kestrun manifest path.</param>
 1641    /// <param name="bundledManifestPath">Bundled service module manifest path.</param>
 1642    /// <param name="shouldUpdate">True when the bundled module should be replaced.</param>
 1643    /// <param name="message">Decision summary message when no update is required.</param>
 1644    /// <param name="error">Validation error details.</param>
 1645    /// <returns>True when comparison succeeds.</returns>
 1646    private static bool TryEvaluateRepositoryModuleUpdateNeeded(
 1647        string repositoryManifestPath,
 1648        string bundledManifestPath,
 1649        out bool shouldUpdate,
 1650        out string message,
 1651        out string error)
 1652    {
 21653        shouldUpdate = false;
 21654        message = string.Empty;
 21655        error = string.Empty;
 1656
 21657        if (!TryReadModuleSemanticVersionFromManifest(repositoryManifestPath, out var repositoryVersion))
 1658        {
 01659            error = $"Unable to read ModuleVersion from repository manifest '{repositoryManifestPath}'.";
 01660            return false;
 1661        }
 1662
 21663        if (!File.Exists(bundledManifestPath))
 1664        {
 11665            shouldUpdate = true;
 11666            return true;
 1667        }
 1668
 11669        if (!TryReadModuleSemanticVersionFromManifest(bundledManifestPath, out var bundledVersion))
 1670        {
 01671            error = $"Unable to read ModuleVersion from bundled manifest '{bundledManifestPath}'.";
 01672            return false;
 1673        }
 1674
 11675        var comparison = CompareModuleVersionValues(repositoryVersion, bundledVersion);
 11676        if (comparison > 0)
 1677        {
 01678            shouldUpdate = true;
 01679            return true;
 1680        }
 1681
 11682        message = $"Bundled Kestrun module version '{bundledVersion}' is current or newer than repository version '{repo
 11683        return true;
 1684    }
 1685
 1686    /// <summary>
 1687    /// Restores service application/module directories from the latest backup folder and removes the consumed backup.
 1688    /// </summary>
 1689    /// <param name="serviceRootPath">Resolved service bundle root.</param>
 1690    /// <param name="scriptRoot">Target script root directory.</param>
 1691    /// <param name="moduleRoot">Target bundled module root directory.</param>
 1692    /// <param name="summary">Serialized summary payload.</param>
 1693    /// <param name="error">Failback error details.</param>
 1694    /// <returns>True when failback succeeds.</returns>
 1695    private static bool TryFailbackServiceFromBackup(
 1696        string serviceRootPath,
 1697        string scriptRoot,
 1698        string moduleRoot,
 1699        out object summary,
 1700        out string error)
 1701    {
 21702        summary = new { };
 1703
 21704        if (!TryResolveLatestServiceBackupDirectory(serviceRootPath, out var latestBackupPath, out error))
 1705        {
 01706            return false;
 1707        }
 1708
 21709        var backupApplicationPath = Path.Combine(latestBackupPath, "application");
 21710        var backupModulePath = Path.Combine(latestBackupPath, "module");
 21711        var hasApplicationBackup = Directory.Exists(backupApplicationPath);
 21712        var hasModuleBackup = Directory.Exists(backupModulePath);
 1713
 21714        if (!hasApplicationBackup && !hasModuleBackup)
 1715        {
 01716            error = $"Backup '{latestBackupPath}' does not contain application or module content.";
 01717            return false;
 1718        }
 1719
 21720        if (hasApplicationBackup
 21721            && !TryReplaceDirectoryFromSource(backupApplicationPath, scriptRoot, "Failback service application", out var
 1722        {
 01723            error = applicationRestoreError;
 01724            return false;
 1725        }
 1726
 21727        if (hasModuleBackup
 21728            && !TryReplaceDirectoryFromSource(backupModulePath, moduleRoot, "Failback bundled Kestrun module", out var m
 1729        {
 01730            error = moduleRestoreError;
 01731            return false;
 1732        }
 1733
 1734        try
 1735        {
 21736            TryDeleteDirectoryWithRetry(latestBackupPath, maxAttempts: 5, initialDelayMs: 50);
 21737        }
 01738        catch (Exception ex)
 1739        {
 01740            error = $"Failback succeeded but backup folder '{latestBackupPath}' could not be removed: {ex.Message}";
 01741            return false;
 1742        }
 1743
 21744        summary = new
 21745        {
 21746            ServicePath = serviceRootPath,
 21747            ApplicationReverted = hasApplicationBackup,
 21748            ModuleReverted = hasModuleBackup,
 21749            ConsumedBackupPath = latestBackupPath,
 21750            BackupRemoved = true,
 21751        };
 1752
 21753        return true;
 01754    }
 1755
 1756    /// <summary>
 1757    /// Resolves the latest service backup directory from the service backup root.
 1758    /// </summary>
 1759    /// <param name="serviceRootPath">Resolved service bundle root.</param>
 1760    /// <param name="backupDirectoryPath">Resolved latest backup path.</param>
 1761    /// <param name="error">Resolution error details.</param>
 1762    /// <returns>True when a backup directory exists.</returns>
 1763    private static bool TryResolveLatestServiceBackupDirectory(string serviceRootPath, out string backupDirectoryPath, o
 1764    {
 41765        backupDirectoryPath = string.Empty;
 41766        error = string.Empty;
 1767
 41768        var backupRoot = Path.Combine(serviceRootPath, "backup");
 41769        if (!Directory.Exists(backupRoot))
 1770        {
 11771            error = $"No backup folder found under '{backupRoot}'.";
 11772            return false;
 1773        }
 1774
 31775        var candidates = Directory
 31776            .GetDirectories(backupRoot)
 41777            .Select(static path => new
 41778            {
 41779                Path = path,
 41780                Name = Path.GetFileName(path),
 41781                LastWriteUtc = Directory.GetLastWriteTimeUtc(path),
 41782            })
 21783            .OrderByDescending(static candidate => candidate.Name)
 21784            .ThenByDescending(static candidate => candidate.LastWriteUtc)
 31785            .ToList();
 1786
 31787        if (candidates.Count == 0)
 1788        {
 01789            error = $"No backup folder found under '{backupRoot}'.";
 01790            return false;
 1791        }
 1792
 31793        backupDirectoryPath = candidates[0].Path;
 31794        return true;
 1795    }
 1796
 1797    /// <summary>
 1798    /// Validates that a descriptor version string is present and compatible with System.Version.
 1799    /// </summary>
 1800    /// <param name="descriptorVersion">Descriptor version string.</param>
 1801    /// <param name="version">Parsed version.</param>
 1802    /// <param name="error">Validation error details.</param>
 1803    /// <returns>True when version parsing succeeds.</returns>
 1804    private static bool TryParseServiceDescriptorVersion(string? descriptorVersion, out Version version, out string erro
 1805    {
 51806        version = new Version(0, 0);
 51807        error = string.Empty;
 1808
 51809        if (string.IsNullOrWhiteSpace(descriptorVersion))
 1810        {
 01811            error = "Service descriptor Version is required for update comparison.";
 01812            return false;
 1813        }
 1814
 51815        if (!Version.TryParse(descriptorVersion.Trim(), out var parsedVersion) || parsedVersion is null)
 1816        {
 01817            error = $"Service descriptor Version '{descriptorVersion}' is not compatible with System.Version.";
 01818            return false;
 1819        }
 1820
 51821        version = parsedVersion;
 51822        return true;
 1823    }
 1824
 1825    /// <summary>
 1826    /// Validates package-version progression for service updates.
 1827    /// </summary>
 1828    /// <param name="installedDescriptorVersion">Installed descriptor version.</param>
 1829    /// <param name="packageDescriptorVersion">Incoming package descriptor version.</param>
 1830    /// <param name="packageVersion">Parsed incoming package version.</param>
 1831    /// <param name="warning">Optional warning when installed version metadata is missing.</param>
 1832    /// <param name="error">Validation error details.</param>
 1833    /// <returns>True when package update version checks pass.</returns>
 1834    private static bool TryValidateServicePackageVersionUpdate(
 1835        string? installedDescriptorVersion,
 1836        string? packageDescriptorVersion,
 1837        out Version packageVersion,
 1838        out string? warning,
 1839        out string error)
 1840    {
 31841        packageVersion = new Version(0, 0);
 31842        warning = null;
 31843        error = string.Empty;
 1844
 31845        if (!TryParseServiceDescriptorVersion(packageDescriptorVersion, out var parsedPackageVersion, out var packageVer
 1846        {
 01847            error = $"Unable to compare package version: {packageVersionError}";
 01848            return false;
 1849        }
 1850
 31851        packageVersion = parsedPackageVersion;
 1852
 31853        if (string.IsNullOrWhiteSpace(installedDescriptorVersion))
 1854        {
 11855            warning = "Installed service descriptor Version is missing. Skipping installed-version comparison for this u
 11856            return true;
 1857        }
 1858
 21859        if (!TryParseServiceDescriptorVersion(installedDescriptorVersion, out var installedVersion, out var installedVer
 1860        {
 01861            error = $"Unable to compare installed version: {installedVersionError}";
 01862            return false;
 1863        }
 1864
 21865        if (packageVersion <= installedVersion)
 1866        {
 11867            error = $"Package version '{packageVersion}' must be greater than installed version '{installedVersion}'.";
 11868            return false;
 1869        }
 1870
 11871        return true;
 1872    }
 1873
 1874    /// <summary>
 1875    /// Returns false when the service is currently running.
 1876    /// </summary>
 1877    /// <param name="serviceName">Service name.</param>
 1878    /// <param name="error">State validation details.</param>
 1879    /// <returns>True when service is stopped or inactive.</returns>
 1880    private static bool TryEnsureServiceIsStopped(string serviceName, out string error)
 1881    {
 11882        if (OperatingSystem.IsWindows())
 1883        {
 01884            return TryEnsureWindowsServiceIsStopped(serviceName, out error);
 1885        }
 1886
 11887        if (OperatingSystem.IsLinux())
 1888        {
 11889            return TryEnsureLinuxServiceIsStopped(serviceName, out error);
 1890        }
 1891
 01892        if (OperatingSystem.IsMacOS())
 1893        {
 01894            return TryEnsureMacServiceIsStopped(serviceName, out error);
 1895        }
 1896
 01897        error = "Service update is not supported on this OS.";
 01898        return false;
 1899    }
 1900
 1901    /// <summary>
 1902    /// Returns false when a Windows service is currently running.
 1903    /// </summary>
 1904    /// <param name="serviceName">Service name.</param>
 1905    /// <param name="error">State validation details.</param>
 1906    /// <returns>True when service is stopped or inactive.</returns>
 1907    private static bool TryEnsureWindowsServiceIsStopped(string serviceName, out string error)
 1908    {
 01909        var queryResult = RunProcess("sc.exe", ["query", serviceName], writeStandardOutput: false);
 01910        if (queryResult.ExitCode != 0)
 1911        {
 01912            error = string.IsNullOrWhiteSpace(queryResult.Error)
 01913                ? $"Unable to query service '{serviceName}'."
 01914                : queryResult.Error.Trim();
 01915            return false;
 1916        }
 1917
 01918        var stateLine = queryResult.Output
 01919            .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 01920            .FirstOrDefault(static line => line.Contains("STATE", StringComparison.OrdinalIgnoreCase));
 1921
 01922        if (!string.IsNullOrWhiteSpace(stateLine) && stateLine.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
 1923        {
 01924            error = $"Service '{serviceName}' is running. Stop it before update.";
 01925            return false;
 1926        }
 1927
 01928        error = string.Empty;
 01929        return true;
 1930    }
 1931
 1932    /// <summary>
 1933    /// Returns false when a Linux service unit is currently active.
 1934    /// </summary>
 1935    /// <param name="serviceName">Service name.</param>
 1936    /// <param name="error">State validation details.</param>
 1937    /// <returns>True when service is stopped or inactive.</returns>
 1938    private static bool TryEnsureLinuxServiceIsStopped(string serviceName, out string error)
 1939    {
 11940        var useSystemScope = IsLinuxSystemUnitInstalled(serviceName);
 11941        var unitName = GetLinuxUnitName(serviceName);
 11942        var activeResult = RunLinuxSystemctl(useSystemScope, ["is-active", unitName]);
 11943        if (activeResult.ExitCode == 0)
 1944        {
 01945            error = $"Service '{serviceName}' is running. Stop it before update.";
 01946            return false;
 1947        }
 1948
 11949        error = string.Empty;
 11950        return true;
 1951    }
 1952
 1953    /// <summary>
 1954    /// Returns false when a macOS launchd service is currently running.
 1955    /// </summary>
 1956    /// <param name="serviceName">Service name.</param>
 1957    /// <param name="error">State validation details.</param>
 1958    /// <returns>True when service is stopped or inactive.</returns>
 1959    private static bool TryEnsureMacServiceIsStopped(string serviceName, out string error)
 1960    {
 01961        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 01962        var result = useSystemScope
 01963            ? RunProcess("launchctl", ["print", $"system/{serviceName}"])
 01964            : RunProcess("launchctl", ["list", serviceName]);
 1965
 01966        if (result.ExitCode != 0)
 1967        {
 01968            error = string.Empty;
 01969            return true;
 1970        }
 1971
 01972        if (useSystemScope)
 1973        {
 01974            var running = result.Output
 01975                .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 01976                .Any(static line => line.Contains("state = running", StringComparison.OrdinalIgnoreCase));
 1977
 01978            if (running)
 1979            {
 01980                error = $"Service '{serviceName}' is running. Stop it before update.";
 01981                return false;
 1982            }
 1983
 01984            error = string.Empty;
 01985            return true;
 1986        }
 1987
 01988        var columns = result.Output.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEn
 01989        if (columns.Length > 0 && int.TryParse(columns[0], out var pid) && pid > 0)
 1990        {
 01991            error = $"Service '{serviceName}' is running. Stop it before update.";
 01992            return false;
 1993        }
 1994
 01995        error = string.Empty;
 01996        return true;
 1997    }
 1998
 1999    /// <summary>
 2000    /// Creates a backup copy of a directory when it exists.
 2001    /// </summary>
 2002    /// <param name="sourceDirectory">Directory to back up.</param>
 2003    /// <param name="backupDirectory">Backup destination directory.</param>
 2004    /// <param name="error">Backup error details.</param>
 2005    /// <returns>True when backup succeeds or source does not exist.</returns>
 2006    private static bool TryBackupDirectory(string sourceDirectory, string backupDirectory, out string error)
 2007    {
 42008        error = string.Empty;
 42009        if (!Directory.Exists(sourceDirectory))
 2010        {
 02011            return true;
 2012        }
 2013
 2014        try
 2015        {
 42016            _ = Directory.CreateDirectory(backupDirectory);
 42017            CopyDirectoryContents(sourceDirectory, backupDirectory, showProgress: false, "Creating backup", exclusionPat
 42018            return true;
 2019        }
 02020        catch (Exception ex)
 2021        {
 02022            error = $"Failed to back up '{sourceDirectory}' to '{backupDirectory}': {ex.Message}";
 02023            return false;
 2024        }
 42025    }
 2026
 2027    /// <summary>
 2028    /// Replaces a target directory from a source directory.
 2029    /// </summary>
 2030    /// <param name="sourceDirectory">Source directory.</param>
 2031    /// <param name="targetDirectory">Target directory.</param>
 2032    /// <param name="operationName">Operation label for progress output.</param>
 2033    /// <param name="error">Replacement error details.</param>
 2034    /// <param name="exclusionPatterns">Optional exclusion patterns.</param>
 2035    /// <returns>True when replacement succeeds.</returns>
 2036    private static bool TryReplaceDirectoryFromSource(
 2037        string sourceDirectory,
 2038        string targetDirectory,
 2039        string operationName,
 2040        out string error,
 2041        IReadOnlyList<string>? exclusionPatterns = null,
 2042        IReadOnlyList<string>? preserveRelativePaths = null)
 2043    {
 92044        error = string.Empty;
 92045        string? preserveStagingRoot = null;
 2046        try
 2047        {
 92048            if (preserveRelativePaths is not null
 92049                && preserveRelativePaths.Count > 0
 92050                && Directory.Exists(targetDirectory)
 92051                && !TryStagePreservedPaths(targetDirectory, preserveRelativePaths, out preserveStagingRoot, out error))
 2052            {
 02053                return false;
 2054            }
 2055
 92056            if (Directory.Exists(targetDirectory))
 2057            {
 92058                Directory.Delete(targetDirectory, recursive: true);
 2059            }
 2060
 92061            _ = Directory.CreateDirectory(targetDirectory);
 92062            CopyDirectoryContents(sourceDirectory, targetDirectory, showProgress: !Console.IsOutputRedirected, operation
 2063
 92064            return string.IsNullOrWhiteSpace(preserveStagingRoot)
 92065                || TryRestorePreservedPaths(preserveStagingRoot, targetDirectory, out error);
 2066        }
 02067        catch (Exception ex)
 2068        {
 02069            error = $"Failed to replace '{targetDirectory}' from '{sourceDirectory}': {ex.Message}";
 02070            return false;
 2071        }
 2072        finally
 2073        {
 92074            if (!string.IsNullOrWhiteSpace(preserveStagingRoot) && Directory.Exists(preserveStagingRoot))
 2075            {
 2076                try
 2077                {
 42078                    TryDeleteDirectoryWithRetry(preserveStagingRoot, maxAttempts: 5, initialDelayMs: 50);
 42079                }
 02080                catch
 2081                {
 2082                    // Best-effort cleanup for preserve staging directory.
 02083                }
 2084            }
 92085        }
 92086    }
 2087
 2088    /// <summary>
 2089    /// Stages preserved files/directories from an existing target directory into a temporary folder.
 2090    /// </summary>
 2091    /// <param name="targetDirectory">Existing target directory whose content is being replaced.</param>
 2092    /// <param name="preserveRelativePaths">Relative preserve paths declared in the package descriptor.</param>
 2093    /// <param name="preserveStagingRoot">Temporary preserve staging root path.</param>
 2094    /// <param name="error">Staging error details.</param>
 2095    /// <returns>True when staging succeeds.</returns>
 2096    private static bool TryStagePreservedPaths(
 2097        string targetDirectory,
 2098        IReadOnlyList<string> preserveRelativePaths,
 2099        out string preserveStagingRoot,
 2100        out string error)
 2101    {
 52102        preserveStagingRoot = Path.Combine(Path.GetTempPath(), $"kestrun-preserve-{Guid.NewGuid():N}");
 52103        error = string.Empty;
 2104
 52105        var targetRootFullPath = Path.GetFullPath(targetDirectory);
 52106        var preservePathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordin
 52107        var normalizedPreservePaths = new HashSet<string>(preservePathComparer);
 222108        foreach (var preservePath in preserveRelativePaths)
 2109        {
 62110            if (!TryNormalizePreservePath(preservePath, out var normalizedPath, out error))
 2111            {
 02112                return false;
 2113            }
 2114
 62115            _ = normalizedPreservePaths.Add(normalizedPath);
 2116        }
 2117
 52118        _ = Directory.CreateDirectory(preserveStagingRoot);
 212119        foreach (var normalizedPath in normalizedPreservePaths)
 2120        {
 62121            var sourcePath = Path.GetFullPath(Path.Combine(targetRootFullPath, normalizedPath));
 62122            if (!IsPathWithinDirectory(sourcePath, targetRootFullPath))
 2123            {
 12124                error = $"Preserved path entry '{normalizedPath}' escapes the service application root.";
 12125                return false;
 2126            }
 2127
 52128            var stagedPath = Path.Combine(preserveStagingRoot, normalizedPath);
 52129            var stagedDirectory = Path.GetDirectoryName(stagedPath);
 52130            if (!string.IsNullOrWhiteSpace(stagedDirectory))
 2131            {
 52132                _ = Directory.CreateDirectory(stagedDirectory);
 2133            }
 2134
 52135            if (File.Exists(sourcePath))
 2136            {
 32137                File.Copy(sourcePath, stagedPath, overwrite: true);
 32138                continue;
 2139            }
 2140
 22141            if (Directory.Exists(sourcePath))
 2142            {
 22143                _ = Directory.CreateDirectory(stagedPath);
 22144                CopyDirectoryContents(sourcePath, stagedPath, showProgress: false, "Staging preserved paths", exclusionP
 2145            }
 2146        }
 2147
 42148        return true;
 12149    }
 2150
 2151    /// <summary>
 2152    /// Restores staged preserved files/directories into the replaced target directory.
 2153    /// </summary>
 2154    /// <param name="preserveStagingRoot">Preserve staging root path.</param>
 2155    /// <param name="targetDirectory">Replacement target directory.</param>
 2156    /// <param name="error">Restore error details.</param>
 2157    /// <returns>True when restore succeeds.</returns>
 2158    private static bool TryRestorePreservedPaths(string preserveStagingRoot, string targetDirectory, out string error)
 2159    {
 42160        error = string.Empty;
 2161        try
 2162        {
 162163            foreach (var directoryPath in Directory.GetDirectories(preserveStagingRoot, "*", SearchOption.AllDirectories
 2164            {
 42165                var relativePath = Path.GetRelativePath(preserveStagingRoot, directoryPath);
 42166                var destinationDirectory = Path.Combine(targetDirectory, relativePath);
 42167                _ = Directory.CreateDirectory(destinationDirectory);
 2168            }
 2169
 182170            foreach (var filePath in Directory.GetFiles(preserveStagingRoot, "*", SearchOption.AllDirectories))
 2171            {
 52172                var relativePath = Path.GetRelativePath(preserveStagingRoot, filePath);
 52173                var destinationPath = Path.Combine(targetDirectory, relativePath);
 52174                var destinationDirectory = Path.GetDirectoryName(destinationPath);
 52175                if (!string.IsNullOrWhiteSpace(destinationDirectory))
 2176                {
 52177                    _ = Directory.CreateDirectory(destinationDirectory);
 2178                }
 2179
 52180                File.Copy(filePath, destinationPath, overwrite: true);
 2181            }
 2182
 42183            return true;
 2184        }
 02185        catch (Exception ex)
 2186        {
 02187            error = $"Failed to restore preserved paths into '{targetDirectory}': {ex.Message}";
 02188            return false;
 2189        }
 42190    }
 2191
 2192    /// <summary>
 2193    /// Validates and normalizes one preserved relative path entry.
 2194    /// </summary>
 2195    /// <param name="rawPath">Raw path value from the descriptor.</param>
 2196    /// <param name="normalizedPath">Normalized relative path.</param>
 2197    /// <param name="error">Validation error details.</param>
 2198    /// <returns>True when the preserved path is valid.</returns>
 2199    private static bool TryNormalizePreservePath(string rawPath, out string normalizedPath, out string error)
 2200    {
 72201        normalizedPath = string.Empty;
 72202        if (string.IsNullOrWhiteSpace(rawPath))
 2203        {
 02204            error = $"Service descriptor '{ServiceDescriptorFileName}' contains an empty preserved path entry.";
 02205            return false;
 2206        }
 2207
 72208        var candidate = rawPath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar).Tri
 72209        if (Path.IsPathRooted(candidate))
 2210        {
 12211            error = $"Service descriptor '{ServiceDescriptorFileName}' preserved path entry '{rawPath}' must be relative
 12212            return false;
 2213        }
 2214
 62215        var candidatePath = candidate.TrimEnd(Path.DirectorySeparatorChar);
 62216        if (string.IsNullOrWhiteSpace(candidatePath)
 62217            || string.Equals(candidatePath, ".", StringComparison.Ordinal))
 2218        {
 02219            error = $"Service descriptor '{ServiceDescriptorFileName}' preserved path entry '{rawPath}' is invalid.";
 02220            return false;
 2221        }
 2222
 62223        normalizedPath = candidatePath;
 62224        error = string.Empty;
 62225        return true;
 2226    }
 2227
 2228    /// <summary>
 2229    /// Updates bundled service-host binary when the tool-shipped host is newer.
 2230    /// </summary>
 2231    /// <param name="runtimeDirectory">Service runtime directory.</param>
 2232    /// <param name="backupDirectory">Backup directory for replaced host binary.</param>
 2233    /// <param name="error">Update error details.</param>
 2234    /// <param name="updated">True when host binary was replaced.</param>
 2235    /// <returns>True when host check/update succeeds.</returns>
 2236    private static bool TryUpdateBundledServiceHostIfNewer(string runtimeDirectory, string backupDirectory, out string e
 2237    {
 12238        updated = false;
 12239        if (!TryResolveServiceHostUpdatePaths(runtimeDirectory, out var sourceHostPath, out var targetHostPath, out erro
 2240        {
 02241            return false;
 2242        }
 2243
 12244        if (!File.Exists(targetHostPath))
 2245        {
 12246            return TryCopyServiceHostBinary(sourceHostPath, targetHostPath, out error, out updated);
 2247        }
 2248
 02249        if (!ShouldReplaceBundledServiceHostBinary(sourceHostPath, targetHostPath))
 2250        {
 02251            updated = false;
 02252            return true;
 2253        }
 2254
 02255        return TryBackupAndReplaceServiceHostBinary(sourceHostPath, targetHostPath, backupDirectory, out error, out upda
 2256    }
 2257
 2258    /// <summary>
 2259    /// Resolves source and target service-host paths used by runtime host update operations.
 2260    /// </summary>
 2261    /// <param name="runtimeDirectory">Service runtime directory.</param>
 2262    /// <param name="sourceHostPath">Tool-distributed host executable path.</param>
 2263    /// <param name="targetHostPath">Installed runtime host executable path.</param>
 2264    /// <param name="error">Resolution error details.</param>
 2265    /// <returns>True when path resolution succeeds.</returns>
 2266    private static bool TryResolveServiceHostUpdatePaths(
 2267        string runtimeDirectory,
 2268        out string sourceHostPath,
 2269        out string targetHostPath,
 2270        out string error)
 2271    {
 12272        targetHostPath = string.Empty;
 12273        error = string.Empty;
 2274
 12275        if (!TryResolveDedicatedServiceHostExecutableFromToolDistribution(out sourceHostPath))
 2276        {
 02277            error = "Unable to resolve bundled service-host from Kestrun.Tool distribution.";
 02278            return false;
 2279        }
 2280
 12281        targetHostPath = Path.Combine(runtimeDirectory, Path.GetFileName(sourceHostPath));
 12282        return true;
 2283    }
 2284
 2285    /// <summary>
 2286    /// Copies a service-host executable to the target runtime path and applies Unix execute permissions when required.
 2287    /// </summary>
 2288    /// <param name="sourceHostPath">Source host executable path.</param>
 2289    /// <param name="targetHostPath">Target runtime host executable path.</param>
 2290    /// <param name="error">Copy error details.</param>
 2291    /// <param name="updated">True when copy succeeds.</param>
 2292    /// <returns>True when copy succeeds.</returns>
 2293    private static bool TryCopyServiceHostBinary(string sourceHostPath, string targetHostPath, out string error, out boo
 2294    {
 12295        error = string.Empty;
 12296        updated = false;
 2297
 2298        try
 2299        {
 12300            File.Copy(sourceHostPath, targetHostPath, overwrite: true);
 02301            if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
 2302            {
 02303                TryEnsureServiceRuntimeExecutablePermissions(targetHostPath);
 2304            }
 2305
 02306            updated = true;
 02307            return true;
 2308        }
 12309        catch (Exception ex)
 2310        {
 12311            error = $"Failed to update bundled service-host: {ex.Message}";
 12312            return false;
 2313        }
 12314    }
 2315
 2316    /// <summary>
 2317    /// Determines whether the bundled host binary should replace the installed runtime host binary.
 2318    /// </summary>
 2319    /// <param name="sourceHostPath">Tool-distributed host executable path.</param>
 2320    /// <param name="targetHostPath">Installed runtime host executable path.</param>
 2321    /// <returns>True when replacement should occur.</returns>
 2322    private static bool ShouldReplaceBundledServiceHostBinary(string sourceHostPath, string targetHostPath)
 2323    {
 02324        var hasSourceVersion = TryReadFileVersion(sourceHostPath, out var sourceVersion) && sourceVersion is not null;
 02325        var hasTargetVersion = TryReadFileVersion(targetHostPath, out var targetVersion) && targetVersion is not null;
 2326
 02327        return !hasSourceVersion || !hasTargetVersion || sourceVersion > targetVersion;
 2328    }
 2329
 2330    /// <summary>
 2331    /// Backs up the installed runtime host binary and replaces it with the bundled host binary.
 2332    /// </summary>
 2333    /// <param name="sourceHostPath">Tool-distributed host executable path.</param>
 2334    /// <param name="targetHostPath">Installed runtime host executable path.</param>
 2335    /// <param name="backupDirectory">Backup directory for the previous runtime host binary.</param>
 2336    /// <param name="error">Replacement error details.</param>
 2337    /// <param name="updated">True when replacement succeeds.</param>
 2338    /// <returns>True when backup and replacement succeed.</returns>
 2339    private static bool TryBackupAndReplaceServiceHostBinary(
 2340        string sourceHostPath,
 2341        string targetHostPath,
 2342        string backupDirectory,
 2343        out string error,
 2344        out bool updated)
 2345    {
 02346        updated = false;
 2347
 2348        try
 2349        {
 02350            _ = Directory.CreateDirectory(backupDirectory);
 02351            File.Copy(targetHostPath, Path.Combine(backupDirectory, Path.GetFileName(targetHostPath)), overwrite: true);
 02352        }
 02353        catch (Exception ex)
 2354        {
 02355            error = $"Failed to update bundled service-host: {ex.Message}";
 02356            return false;
 2357        }
 2358
 02359        return TryCopyServiceHostBinary(sourceHostPath, targetHostPath, out error, out updated);
 02360    }
 2361
 2362    /// <summary>
 2363    /// Reads file version metadata from a binary file.
 2364    /// </summary>
 2365    /// <param name="filePath">Binary file path.</param>
 2366    /// <param name="version">Parsed version when available.</param>
 2367    /// <returns>True when version parsing succeeds.</returns>
 2368    private static bool TryReadFileVersion(string filePath, out Version? version)
 2369    {
 02370        version = null;
 2371        try
 2372        {
 02373            var fileVersionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(filePath);
 02374            if (!string.IsNullOrWhiteSpace(fileVersionInfo.FileVersion)
 02375                && Version.TryParse(fileVersionInfo.FileVersion, out var parsedFileVersion)
 02376                && parsedFileVersion is not null)
 2377            {
 02378                version = parsedFileVersion;
 02379                return true;
 2380            }
 2381
 02382            if (!string.IsNullOrWhiteSpace(fileVersionInfo.ProductVersion)
 02383                && Version.TryParse(fileVersionInfo.ProductVersion, out var parsedProductVersion)
 02384                && parsedProductVersion is not null)
 2385            {
 02386                version = parsedProductVersion;
 02387                return true;
 2388            }
 2389
 02390            return false;
 2391        }
 02392        catch
 2393        {
 02394            return false;
 2395        }
 02396    }
 2397
 2398    /// <summary>
 2399    /// Resolves an installed service bundle path by service name across deployment roots.
 2400    /// </summary>
 2401    /// <param name="serviceName">Service name.</param>
 2402    /// <param name="deploymentRootOverride">Optional deployment root override.</param>
 2403    /// <param name="serviceRootPath">Resolved service bundle root path.</param>
 2404    /// <param name="error">Resolution error details.</param>
 2405    /// <returns>True when a matching installed service bundle is found.</returns>
 2406    private static bool TryResolveInstalledServiceBundleRoot(string serviceName, string? deploymentRootOverride, out str
 2407    {
 52408        serviceRootPath = string.Empty;
 52409        error = string.Empty;
 2410
 52411        var serviceDirectoryName = GetServiceDeploymentDirectoryName(serviceName);
 52412        var candidateRoots = new List<string>();
 52413        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 2414        {
 42415            candidateRoots.Add(deploymentRootOverride);
 2416        }
 2417
 52418        candidateRoots.AddRange(GetServiceDeploymentRootCandidates());
 2419
 272420        foreach (var root in candidateRoots.Distinct(StringComparer.OrdinalIgnoreCase))
 2421        {
 102422            if (string.IsNullOrWhiteSpace(root))
 2423            {
 2424                continue;
 2425            }
 2426
 102427            var serviceBaseRoot = Path.Combine(root, serviceDirectoryName);
 102428            if (!Directory.Exists(serviceBaseRoot))
 2429            {
 2430                continue;
 2431            }
 2432
 32433            var directDescriptorPath = Path.Combine(serviceBaseRoot, ServiceBundleScriptDirectoryName, ServiceDescriptor
 32434            if (File.Exists(directDescriptorPath))
 2435            {
 32436                serviceRootPath = Path.GetFullPath(serviceBaseRoot);
 32437                return true;
 2438            }
 2439        }
 2440
 22441        error = $"Installed service bundle not found for '{serviceName}'.";
 22442        return false;
 32443    }
 2444
 2445    /// <summary>
 2446    /// Starts a Windows service using sc.exe.
 2447    /// </summary>
 2448    /// <param name="serviceName">Service name.</param>
 2449    /// <returns>Process exit code.</returns>
 2450    private static ServiceControlResult StartWindowsService(string serviceName, string? configuredLogPath, bool rawOutpu
 2451    {
 02452        var result = RunProcess("sc.exe", ["start", serviceName], writeStandardOutput: false);
 02453        if (result.ExitCode != 0)
 2454        {
 02455            WriteServiceOperationLog(
 02456                $"operation='start' service='{serviceName}' platform='windows' result='failed' exitCode={result.ExitCode
 02457                configuredLogPath,
 02458                serviceName);
 02459            return new ServiceControlResult("start", serviceName, "windows", "unknown", null, result.ExitCode, "Failed t
 2460        }
 2461
 02462        WriteServiceOperationLog(
 02463            $"operation='start' service='{serviceName}' platform='windows' result='success' exitCode=0",
 02464            configuredLogPath,
 02465            serviceName);
 02466        return new ServiceControlResult("start", serviceName, "windows", "running", null, 0, "Service started.", result.
 2467    }
 2468
 2469    /// <summary>
 2470    /// Stops a Windows service using sc.exe.
 2471    /// </summary>
 2472    /// <param name="serviceName">Service name.</param>
 2473    /// <returns>Process exit code.</returns>
 2474    private static ServiceControlResult StopWindowsService(string serviceName, string? configuredLogPath, bool rawOutput
 2475    {
 02476        var result = RunProcess("sc.exe", ["stop", serviceName], writeStandardOutput: false);
 02477        if (result.ExitCode != 0)
 2478        {
 02479            if (IsWindowsServiceAlreadyStopped(result))
 2480            {
 02481                WriteServiceOperationLog(
 02482                    $"operation='stop' service='{serviceName}' platform='windows' result='success' exitCode=0 note='alre
 02483                    configuredLogPath,
 02484                    serviceName);
 02485                return new ServiceControlResult("stop", serviceName, "windows", "stopped", null, 0, "Service is already 
 2486            }
 2487
 02488            WriteServiceOperationLog(
 02489                $"operation='stop' service='{serviceName}' platform='windows' result='failed' exitCode={result.ExitCode}
 02490                configuredLogPath,
 02491                serviceName);
 02492            return new ServiceControlResult("stop", serviceName, "windows", "unknown", null, result.ExitCode, "Failed to
 2493        }
 2494
 02495        WriteServiceOperationLog(
 02496            $"operation='stop' service='{serviceName}' platform='windows' result='success' exitCode=0",
 02497            configuredLogPath,
 02498            serviceName);
 02499        return new ServiceControlResult("stop", serviceName, "windows", "stopped", null, 0, "Service stopped.", result.O
 2500    }
 2501
 2502    /// <summary>
 2503    /// Returns true when SCM stop command indicates the service was not running.
 2504    /// </summary>
 2505    /// <param name="result">SCM command result.</param>
 2506    /// <returns>True when SCM returned service-not-started semantics.</returns>
 2507    private static bool IsWindowsServiceAlreadyStopped(ProcessResult result)
 2508    {
 02509        var text = $"{result.Output}\n{result.Error}";
 02510        return text.Contains("1062", StringComparison.OrdinalIgnoreCase)
 02511            || text.Contains("has not been started", StringComparison.OrdinalIgnoreCase)
 02512            || text.Contains("not started", StringComparison.OrdinalIgnoreCase);
 2513    }
 2514
 2515    /// <summary>
 2516    /// Queries a Windows service using sc.exe.
 2517    /// </summary>
 2518    /// <param name="serviceName">Service name.</param>
 2519    /// <returns>Process exit code.</returns>
 2520    private static ServiceControlResult QueryWindowsService(string serviceName, string? configuredLogPath, bool rawOutpu
 2521    {
 02522        var result = RunProcess("sc.exe", ["queryex", serviceName], writeStandardOutput: false);
 02523        if (result.ExitCode != 0)
 2524        {
 02525            WriteServiceOperationLog(
 02526                $"operation='query' service='{serviceName}' platform='windows' result='failed' exitCode={result.ExitCode
 02527                configuredLogPath,
 02528                serviceName);
 02529            return new ServiceControlResult("query", serviceName, "windows", "unknown", null, result.ExitCode, "Failed t
 2530        }
 2531
 02532        var stateLine = result.Output
 02533            .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 02534            .FirstOrDefault(static line => line.Contains("STATE", StringComparison.OrdinalIgnoreCase)) ?? "STATE: unknow
 02535        var state = stateLine.Contains("RUNNING", StringComparison.OrdinalIgnoreCase)
 02536            ? "running"
 02537            : stateLine.Contains("STOPPED", StringComparison.OrdinalIgnoreCase)
 02538                ? "stopped"
 02539                : "unknown";
 02540        var pid = TryExtractWindowsServicePid(result.Output);
 2541
 02542        WriteServiceOperationLog(
 02543            $"operation='query' service='{serviceName}' platform='windows' result='success' exitCode=0 state='{stateLine
 02544            configuredLogPath,
 02545            serviceName);
 2546
 02547        return new ServiceControlResult("query", serviceName, "windows", state, pid, 0, stateLine, result.Output, result
 2548    }
 2549
 2550    /// <summary>
 2551    /// Installs a systemd unit (user scope by default; system scope when serviceUser is provided).
 2552    /// </summary>
 2553    /// <param name="serviceName">Unit base name.</param>
 2554    /// <param name="exePath">Executable path.</param>
 2555    /// <param name="runnerArgs">Runner arguments.</param>
 2556    /// <param name="workingDirectory">Working directory for the unit.</param>
 2557    /// <param name="serviceUser">Optional service account for system scope.</param>
 2558    /// <returns>Process exit code.</returns>
 2559    private static int InstallLinuxUserDaemon(string serviceName, string exePath, IReadOnlyList<string> runnerArgs, stri
 2560    {
 12561        var useSystemScope = !string.IsNullOrWhiteSpace(serviceUser);
 2562
 12563        if (useSystemScope && !IsLikelyRunningAsRootOnLinux())
 2564        {
 02565            Console.Error.WriteLine("Linux system service install with --service-user requires root privileges.");
 02566            return 1;
 2567        }
 2568
 12569        if (!useSystemScope && IsLikelyRunningAsRootOnLinux())
 2570        {
 02571            Console.Error.WriteLine("Warning: Running as root installs a root user-level unit via systemctl --user.");
 02572            Console.Error.WriteLine("That unit is managed from root's user session and is separate from your regular use
 2573        }
 2574
 12575        var unitDirectory = useSystemScope
 12576            ? "/etc/systemd/system"
 12577            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "systemd", "user
 12578        _ = Directory.CreateDirectory(unitDirectory);
 2579
 12580        var unitName = GetLinuxUnitName(serviceName);
 12581        var unitPath = Path.Combine(unitDirectory, unitName);
 12582        var unitContent = BuildLinuxSystemdUnitContent(serviceName, exePath, runnerArgs, workingDirectory, serviceUser);
 2583
 12584        File.WriteAllText(unitPath, unitContent);
 2585
 12586        var reloadResult = RunLinuxSystemctl(useSystemScope, ["daemon-reload"]);
 12587        if (reloadResult.ExitCode != 0)
 2588        {
 02589            Console.Error.WriteLine(reloadResult.Error);
 02590            if (!useSystemScope)
 2591            {
 02592                WriteLinuxUserSystemdFailureHint(reloadResult);
 2593            }
 2594
 02595            return reloadResult.ExitCode;
 2596        }
 2597
 12598        var enableResult = RunLinuxSystemctl(useSystemScope, ["enable", unitName]);
 12599        if (enableResult.ExitCode != 0)
 2600        {
 02601            Console.Error.WriteLine(enableResult.Error);
 02602            if (!useSystemScope)
 2603            {
 02604                WriteLinuxUserSystemdFailureHint(enableResult);
 2605            }
 2606
 02607            return enableResult.ExitCode;
 2608        }
 2609
 12610        Console.WriteLine(useSystemScope
 12611            ? $"Installed Linux system daemon '{unitName}' for user '{serviceUser}' (not started)."
 12612            : $"Installed Linux user daemon '{unitName}' (not started).");
 12613        return 0;
 2614    }
 2615
 2616    /// <summary>
 2617    /// Builds Linux systemd unit file content for a service install.
 2618    /// </summary>
 2619    /// <param name="serviceName">Service name used for Description.</param>
 2620    /// <param name="exePath">Executable path for the runner.</param>
 2621    /// <param name="runnerArgs">Arguments passed to the runner executable.</param>
 2622    /// <param name="workingDirectory">Working directory for the systemd unit.</param>
 2623    /// <param name="serviceUser">Optional Linux user account for system-scoped units.</param>
 2624    /// <returns>Rendered unit file content.</returns>
 2625    private static string BuildLinuxSystemdUnitContent(string serviceName, string exePath, IReadOnlyList<string> runnerA
 2626    {
 32627        var useSystemScope = !string.IsNullOrWhiteSpace(serviceUser);
 32628        var execStart = string.Join(" ", new[] { EscapeSystemdToken(exePath) }.Concat(runnerArgs.Select(EscapeSystemdTok
 2629
 32630        return string.Join('\n',
 32631            "[Unit]",
 32632            $"Description={serviceName}",
 32633            "After=network.target",
 32634            "",
 32635            "[Service]",
 32636            "Type=simple",
 32637            useSystemScope ? $"User={serviceUser}" : string.Empty,
 32638            $"WorkingDirectory={workingDirectory}",
 32639            $"ExecStart={execStart}",
 32640            "Restart=always",
 32641            "RestartSec=2",
 32642            "",
 32643            "[Install]",
 32644            useSystemScope ? "WantedBy=multi-user.target" : "WantedBy=default.target",
 32645            "");
 2646    }
 2647
 2648    /// <summary>
 2649    /// Removes a user-level systemd unit.
 2650    /// </summary>
 2651    /// <param name="serviceName">Unit base name.</param>
 2652    /// <returns>Process exit code.</returns>
 2653    private static int RemoveLinuxUserDaemon(string serviceName)
 2654    {
 02655        var useSystemScope = IsLinuxSystemUnitInstalled(serviceName);
 02656        var unitDirectory = useSystemScope
 02657            ? "/etc/systemd/system"
 02658            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "systemd", "user
 02659        var unitName = GetLinuxUnitName(serviceName);
 02660        var unitPath = Path.Combine(unitDirectory, unitName);
 2661
 02662        _ = RunLinuxSystemctl(useSystemScope, ["disable", "--now", unitName]);
 02663        if (File.Exists(unitPath))
 2664        {
 02665            File.Delete(unitPath);
 2666        }
 2667
 02668        var reloadResult = RunLinuxSystemctl(useSystemScope, ["daemon-reload"]);
 02669        if (reloadResult.ExitCode != 0)
 2670        {
 02671            Console.Error.WriteLine(reloadResult.Error);
 02672            if (!useSystemScope)
 2673            {
 02674                WriteLinuxUserSystemdFailureHint(reloadResult);
 2675            }
 2676
 02677            return reloadResult.ExitCode;
 2678        }
 2679
 02680        Console.WriteLine(useSystemScope
 02681            ? $"Removed Linux system daemon '{unitName}'."
 02682            : $"Removed Linux user daemon '{unitName}'.");
 02683        return 0;
 2684    }
 2685
 2686    /// <summary>
 2687    /// Starts a Linux user-level systemd unit.
 2688    /// </summary>
 2689    /// <param name="serviceName">Unit base name.</param>
 2690    /// <returns>Process exit code.</returns>
 2691    private static ServiceControlResult StartLinuxUserDaemon(string serviceName, string? configuredLogPath, bool rawOutp
 2692    {
 22693        var useSystemScope = IsLinuxSystemUnitInstalled(serviceName);
 22694        var unitName = GetLinuxUnitName(serviceName);
 22695        var result = RunLinuxSystemctl(useSystemScope, ["start", unitName], writeStandardOutput: false);
 22696        if (result.ExitCode != 0)
 2697        {
 22698            WriteServiceOperationLog(
 22699                $"operation='start' service='{serviceName}' platform='linux' result='failed' exitCode={result.ExitCode} 
 22700                configuredLogPath,
 22701                serviceName);
 22702            return new ServiceControlResult("start", serviceName, "linux", "unknown", null, result.ExitCode, "Failed to 
 2703        }
 2704
 02705        WriteServiceOperationResult("start", "linux", serviceName, 0, configuredLogPath);
 02706        return new ServiceControlResult("start", serviceName, "linux", "running", null, 0, "Service started.", result.Ou
 2707    }
 2708
 2709    /// <summary>
 2710    /// Stops a Linux user-level systemd unit.
 2711    /// </summary>
 2712    /// <param name="serviceName">Unit base name.</param>
 2713    /// <returns>Process exit code.</returns>
 2714    private static ServiceControlResult StopLinuxUserDaemon(string serviceName, string? configuredLogPath, bool rawOutpu
 2715    {
 22716        var unitName = GetLinuxUnitName(serviceName);
 22717        if (!TryGetInstalledLinuxUnitScope(serviceName, out var useSystemScope))
 2718        {
 2719            const int missingServiceExitCode = 2;
 12720            var message = $"Service unit '{unitName}' was not found.";
 12721            WriteServiceOperationLog(
 12722                $"operation='stop' service='{serviceName}' platform='linux' result='failed' exitCode={missingServiceExit
 12723                configuredLogPath,
 12724                serviceName);
 12725            return new ServiceControlResult("stop", serviceName, "linux", "unknown", null, missingServiceExitCode, messa
 2726        }
 2727
 12728        var result = RunLinuxSystemctl(useSystemScope, ["stop", unitName], writeStandardOutput: false);
 12729        if (result.ExitCode != 0)
 2730        {
 12731            if (IsLinuxServiceAlreadyStopped(result))
 2732            {
 12733                WriteServiceOperationLog(
 12734                    $"operation='stop' service='{serviceName}' platform='linux' result='success' exitCode=0 note='alread
 12735                    configuredLogPath,
 12736                    serviceName);
 12737                return new ServiceControlResult("stop", serviceName, "linux", "stopped", null, 0, "Service is already st
 2738            }
 2739
 02740            WriteServiceOperationLog(
 02741                $"operation='stop' service='{serviceName}' platform='linux' result='failed' exitCode={result.ExitCode} e
 02742                configuredLogPath,
 02743                serviceName);
 02744            return new ServiceControlResult("stop", serviceName, "linux", "unknown", null, result.ExitCode, "Failed to s
 2745        }
 2746
 02747        WriteServiceOperationResult("stop", "linux", serviceName, 0, configuredLogPath);
 02748        return new ServiceControlResult("stop", serviceName, "linux", "stopped", null, 0, "Service stopped.", result.Out
 2749    }
 2750
 2751    /// <summary>
 2752    /// Returns true when systemctl stop indicates the unit is already inactive or absent.
 2753    /// </summary>
 2754    /// <param name="result">Systemctl command result.</param>
 2755    /// <returns>True when stop semantics indicate no-op success.</returns>
 2756    private static bool IsLinuxServiceAlreadyStopped(ProcessResult result)
 2757    {
 32758        var text = $"{result.Output}\n{result.Error}";
 32759        return text.Contains("not loaded", StringComparison.OrdinalIgnoreCase)
 32760            || text.Contains("inactive", StringComparison.OrdinalIgnoreCase)
 32761            || text.Contains("not running", StringComparison.OrdinalIgnoreCase)
 32762            || text.Contains("could not be found", StringComparison.OrdinalIgnoreCase);
 2763    }
 2764
 2765    /// <summary>
 2766    /// Queries a Linux user-level systemd unit.
 2767    /// </summary>
 2768    /// <param name="serviceName">Unit base name.</param>
 2769    /// <returns>Process exit code.</returns>
 2770    private static ServiceControlResult QueryLinuxUserDaemon(string serviceName, string? configuredLogPath, bool rawOutp
 2771    {
 22772        var useSystemScope = IsLinuxSystemUnitInstalled(serviceName);
 22773        var unitName = GetLinuxUnitName(serviceName);
 22774        var queryArgs = rawOutput ? (IReadOnlyList<string>)["status", unitName] : ["is-active", unitName];
 22775        var result = RunLinuxSystemctl(useSystemScope, queryArgs, writeStandardOutput: false);
 22776        if (result.ExitCode != 0)
 2777        {
 22778            WriteServiceOperationLog(
 22779                $"operation='query' service='{serviceName}' platform='linux' result='failed' exitCode={result.ExitCode} 
 22780                configuredLogPath,
 22781                serviceName);
 22782            return new ServiceControlResult("query", serviceName, "linux", "unknown", null, result.ExitCode, "Failed to 
 2783        }
 2784
 02785        var normalizedOutput = result.Output.Trim();
 02786        var state = normalizedOutput.StartsWith("active", StringComparison.OrdinalIgnoreCase) ? "running" : "unknown";
 02787        var pid = TryQueryLinuxServicePid(useSystemScope, unitName);
 02788        WriteServiceOperationResult("query", "linux", serviceName, 0, configuredLogPath);
 02789        return new ServiceControlResult("query", serviceName, "linux", state, pid, 0, string.IsNullOrWhiteSpace(normaliz
 2790    }
 2791
 2792    /// <summary>
 2793    /// Runs systemctl in user or system scope.
 2794    /// </summary>
 2795    /// <param name="useSystemScope">True for system scope; false for user scope.</param>
 2796    /// <param name="arguments">Arguments after optional scope switch.</param>
 2797    /// <returns>Process execution result.</returns>
 2798    private static ProcessResult RunLinuxSystemctl(bool useSystemScope, IReadOnlyList<string> arguments, bool writeStand
 2799    {
 82800        return useSystemScope
 82801            ? RunProcess("systemctl", arguments, writeStandardOutput)
 82802            : RunProcess("systemctl", ["--user", .. arguments], writeStandardOutput);
 2803    }
 2804
 2805    /// <summary>
 2806    /// Returns true when a system-scoped unit file exists for the service.
 2807    /// </summary>
 2808    /// <param name="serviceName">Service name.</param>
 2809    /// <returns>True when a system unit exists under /etc/systemd/system.</returns>
 2810    private static bool IsLinuxSystemUnitInstalled(string serviceName)
 2811    {
 92812        var unitName = GetLinuxUnitName(serviceName);
 92813        var systemUnitPath = Path.Combine("/etc/systemd/system", unitName);
 92814        return File.Exists(systemUnitPath);
 2815    }
 2816
 2817    /// <summary>
 2818    /// Resolves whether a Linux service is installed as a system or user unit.
 2819    /// </summary>
 2820    /// <param name="serviceName">Service name.</param>
 2821    /// <param name="useSystemScope">True when installed as a system unit; false for user unit.</param>
 2822    /// <returns>True when either system or user unit file exists.</returns>
 2823    private static bool TryGetInstalledLinuxUnitScope(string serviceName, out bool useSystemScope)
 2824    {
 42825        if (IsLinuxSystemUnitInstalled(serviceName))
 2826        {
 02827            useSystemScope = true;
 02828            return true;
 2829        }
 2830
 42831        var unitName = GetLinuxUnitName(serviceName);
 42832        var userUnitPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "sy
 42833        useSystemScope = false;
 42834        return File.Exists(userUnitPath);
 2835    }
 2836
 2837    /// <summary>
 2838    /// Installs a macOS launch agent plist (or launch daemon when a service user is specified).
 2839    /// </summary>
 2840    /// <param name="serviceName">Agent label.</param>
 2841    /// <param name="exePath">Executable path.</param>
 2842    /// <param name="runnerArgs">Runner arguments.</param>
 2843    /// <param name="workingDirectory">Working directory for launchd.</param>
 2844    /// <param name="serviceUser">Optional service account for system daemon scope.</param>
 2845    /// <returns>Process exit code.</returns>
 2846    private static int InstallMacLaunchAgent(string serviceName, string exePath, IReadOnlyList<string> runnerArgs, strin
 2847    {
 02848        var useSystemScope = !string.IsNullOrWhiteSpace(serviceUser);
 02849        if (useSystemScope && !IsLikelyRunningAsRootOnUnix())
 2850        {
 02851            Console.Error.WriteLine("macOS system daemon install with --service-user requires root privileges.");
 02852            return 1;
 2853        }
 2854
 02855        var agentDirectory = useSystemScope
 02856            ? "/Library/LaunchDaemons"
 02857            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents");
 02858        _ = Directory.CreateDirectory(agentDirectory);
 2859
 02860        var plistName = $"{serviceName}.plist";
 02861        var plistPath = Path.Combine(agentDirectory, plistName);
 02862        var programArgs = new[] { exePath }.Concat(runnerArgs).ToArray();
 02863        var plistContent = BuildLaunchdPlist(serviceName, workingDirectory, programArgs, serviceUser);
 02864        File.WriteAllText(plistPath, plistContent);
 2865
 02866        Console.WriteLine(useSystemScope
 02867            ? $"Installed macOS launch daemon '{serviceName}' for user '{serviceUser}' (not started)."
 02868            : $"Installed macOS launch agent '{serviceName}' (not started).");
 02869        return 0;
 2870    }
 2871
 2872    /// <summary>
 2873    /// Removes a macOS launch agent plist.
 2874    /// </summary>
 2875    /// <param name="serviceName">Agent label.</param>
 2876    /// <returns>Process exit code.</returns>
 2877    private static int RemoveMacLaunchAgent(string serviceName)
 2878    {
 12879        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 12880        var agentDirectory = useSystemScope
 12881            ? "/Library/LaunchDaemons"
 12882            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents");
 12883        var plistPath = Path.Combine(agentDirectory, $"{serviceName}.plist");
 2884
 2885        // Unload the agent before deleting the plist to ensure launchd doesn't keep a stale reference to the file.
 12886        _ = useSystemScope
 12887            ? RunProcess("launchctl", ["bootout", $"system/{serviceName}"])
 12888            : RunProcess("launchctl", ["unload", plistPath]);
 2889
 2890        // It's possible for the unload to fail if the agent isn't running, but we want to attempt it anyway to avoid le
 02891        if (File.Exists(plistPath))
 2892        {
 02893            File.Delete(plistPath);
 2894        }
 2895
 02896        Console.WriteLine(useSystemScope
 02897            ? $"Removed macOS launch daemon '{serviceName}'."
 02898            : $"Removed macOS launch agent '{serviceName}'.");
 02899        return 0;
 2900    }
 2901
 2902    /// <summary>
 2903    /// Starts a macOS launch agent by loading its plist.
 2904    /// </summary>
 2905    /// <param name="serviceName">Agent label.</param>
 2906    /// <returns>Process exit code.</returns>
 2907    private static ServiceControlResult StartMacLaunchAgent(string serviceName, string? configuredLogPath, bool rawOutpu
 2908    {
 12909        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 12910        var agentDirectory = useSystemScope
 12911            ? "/Library/LaunchDaemons"
 12912            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents");
 12913        var plistPath = Path.Combine(agentDirectory, $"{serviceName}.plist");
 12914        if (!File.Exists(plistPath))
 2915        {
 12916            return new ServiceControlResult("start", serviceName, "macos", "unknown", null, 2, $"Launch agent plist not 
 2917        }
 2918
 02919        var result = useSystemScope
 02920            ? RunProcess("launchctl", ["bootstrap", "system", plistPath], writeStandardOutput: false)
 02921            : RunProcess("launchctl", ["load", "-w", plistPath], writeStandardOutput: false);
 02922        if (result.ExitCode != 0)
 2923        {
 02924            WriteServiceOperationLog(
 02925                $"operation='start' service='{serviceName}' platform='macos' result='failed' exitCode={result.ExitCode} 
 02926                configuredLogPath,
 02927                serviceName);
 02928            return new ServiceControlResult("start", serviceName, "macos", "unknown", null, result.ExitCode, "Failed to 
 2929        }
 2930
 02931        WriteServiceOperationResult("start", "macos", serviceName, 0, configuredLogPath);
 02932        return new ServiceControlResult("start", serviceName, "macos", "running", null, 0, "Service started.", result.Ou
 2933    }
 2934
 2935    /// <summary>
 2936    /// Stops a macOS launch agent by unloading its plist.
 2937    /// </summary>
 2938    /// <param name="serviceName">Agent label.</param>
 2939    /// <returns>Process exit code.</returns>
 2940    private static ServiceControlResult StopMacLaunchAgent(string serviceName, string? configuredLogPath, bool rawOutput
 2941    {
 12942        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 12943        var agentDirectory = useSystemScope
 12944            ? "/Library/LaunchDaemons"
 12945            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents");
 12946        var plistPath = Path.Combine(agentDirectory, $"{serviceName}.plist");
 12947        if (!File.Exists(plistPath))
 2948        {
 12949            return new ServiceControlResult("stop", serviceName, "macos", "unknown", null, 2, $"Launch agent plist not f
 2950        }
 2951
 02952        var result = useSystemScope
 02953            ? RunProcess("launchctl", ["bootout", $"system/{serviceName}"], writeStandardOutput: false)
 02954            : RunProcess("launchctl", ["unload", plistPath], writeStandardOutput: false);
 02955        if (result.ExitCode != 0)
 2956        {
 02957            if (IsMacServiceAlreadyStopped(result))
 2958            {
 02959                WriteServiceOperationLog(
 02960                    $"operation='stop' service='{serviceName}' platform='macos' result='success' exitCode=0 note='alread
 02961                    configuredLogPath,
 02962                    serviceName);
 02963                return new ServiceControlResult("stop", serviceName, "macos", "stopped", null, 0, "Service is already st
 2964            }
 2965
 02966            WriteServiceOperationLog(
 02967                $"operation='stop' service='{serviceName}' platform='macos' result='failed' exitCode={result.ExitCode} e
 02968                configuredLogPath,
 02969                serviceName);
 02970            return new ServiceControlResult("stop", serviceName, "macos", "unknown", null, result.ExitCode, "Failed to s
 2971        }
 2972
 02973        WriteServiceOperationResult("stop", "macos", serviceName, 0, configuredLogPath);
 02974        return new ServiceControlResult("stop", serviceName, "macos", "stopped", null, 0, "Service stopped.", result.Out
 2975    }
 2976
 2977    /// <summary>
 2978    /// Returns true when launchctl stop semantics indicate service is not currently running.
 2979    /// </summary>
 2980    /// <param name="result">Launchctl command result.</param>
 2981    /// <returns>True when stop is effectively a no-op success.</returns>
 2982    private static bool IsMacServiceAlreadyStopped(ProcessResult result)
 2983    {
 02984        var text = $"{result.Output}\n{result.Error}";
 02985        return text.Contains("Could not find specified service", StringComparison.OrdinalIgnoreCase)
 02986            || text.Contains("No such process", StringComparison.OrdinalIgnoreCase)
 02987            || text.Contains("not loaded", StringComparison.OrdinalIgnoreCase)
 02988            || text.Contains("service is not loaded", StringComparison.OrdinalIgnoreCase);
 2989    }
 2990
 2991    /// <summary>
 2992    /// Queries a macOS launch agent by label.
 2993    /// </summary>
 2994    /// <param name="serviceName">Agent label.</param>
 2995    /// <returns>Process exit code.</returns>
 2996    private static ServiceControlResult QueryMacLaunchAgent(string serviceName, string? configuredLogPath, bool rawOutpu
 2997    {
 12998        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 12999        var result = useSystemScope
 13000            ? RunProcess("launchctl", ["print", $"system/{serviceName}"], writeStandardOutput: false)
 13001            : RunProcess("launchctl", ["list", serviceName], writeStandardOutput: false);
 03002        if (result.ExitCode != 0)
 3003        {
 03004            WriteServiceOperationLog(
 03005                $"operation='query' service='{serviceName}' platform='macos' result='failed' exitCode={result.ExitCode} 
 03006                configuredLogPath,
 03007                serviceName);
 03008            return new ServiceControlResult("query", serviceName, "macos", "unknown", null, result.ExitCode, "Failed to 
 3009        }
 3010
 03011        var state = result.Output.Contains("\"PID\" =", StringComparison.OrdinalIgnoreCase)
 03012            || result.Output.Contains("pid =", StringComparison.OrdinalIgnoreCase)
 03013            ? "running"
 03014            : "loaded";
 03015        var pid = TryExtractMacServicePid(result.Output);
 3016
 03017        WriteServiceOperationResult("query", "macos", serviceName, 0, configuredLogPath);
 03018        return new ServiceControlResult("query", serviceName, "macos", state, pid, 0, "Service queried.", result.Output,
 3019    }
 3020
 3021    /// <summary>
 3022    /// Extracts a Windows service PID from sc.exe queryex output.
 3023    /// </summary>
 3024    /// <param name="output">Raw command output.</param>
 3025    /// <returns>Parsed PID when available.</returns>
 3026    private static int? TryExtractWindowsServicePid(string output)
 3027    {
 13028        var pidLine = output
 13029            .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 33030            .FirstOrDefault(static line => line.Contains("PID", StringComparison.OrdinalIgnoreCase));
 3031
 13032        if (string.IsNullOrWhiteSpace(pidLine))
 3033        {
 03034            return null;
 3035        }
 3036
 13037        var parts = pidLine.Split(':', 2, StringSplitOptions.TrimEntries);
 13038        return parts.Length == 2 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var p
 13039            ? pid
 13040            : null;
 3041    }
 3042
 3043    /// <summary>
 3044    /// Queries Linux MainPID for a systemd unit.
 3045    /// </summary>
 3046    /// <param name="useSystemScope">True for system scope; false for user scope.</param>
 3047    /// <param name="unitName">Systemd unit name.</param>
 3048    /// <returns>Main PID when available.</returns>
 3049    private static int? TryQueryLinuxServicePid(bool useSystemScope, string unitName)
 3050    {
 03051        var pidResult = RunLinuxSystemctl(useSystemScope, ["show", "-p", "MainPID", "--value", unitName], writeStandardO
 03052        if (pidResult.ExitCode != 0)
 3053        {
 03054            return null;
 3055        }
 3056
 03057        var text = pidResult.Output.Trim();
 03058        return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) && pid > 0
 03059            ? pid
 03060            : null;
 3061    }
 3062
 3063    /// <summary>
 3064    /// Extracts a macOS launchd PID from launchctl output.
 3065    /// </summary>
 3066    /// <param name="output">Raw command output.</param>
 3067    /// <returns>Parsed PID when available.</returns>
 3068    private static int? TryExtractMacServicePid(string output)
 3069    {
 23070        var lines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 73071        foreach (var line in lines)
 3072        {
 23073            if (line.Contains("pid =", StringComparison.OrdinalIgnoreCase))
 3074            {
 03075                var value = line[(line.IndexOf('=') + 1)..].Trim().TrimEnd(';');
 03076                if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) && pid > 0)
 3077                {
 03078                    return pid;
 3079                }
 3080            }
 3081
 23082            var tokens = line.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries);
 23083            if (tokens.Length > 0 && int.TryParse(tokens[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var
 3084            {
 13085                return listPid;
 3086            }
 3087        }
 3088
 13089        return null;
 3090    }
 3091
 3092    /// <summary>
 3093    /// Returns true when a system-scoped launch daemon plist exists for the service.
 3094    /// </summary>
 3095    /// <param name="serviceName">Service label.</param>
 3096    /// <returns>True when plist exists under /Library/LaunchDaemons.</returns>
 3097    private static bool IsMacSystemLaunchDaemonInstalled(string serviceName)
 3098    {
 43099        var plistPath = Path.Combine("/Library/LaunchDaemons", $"{serviceName}.plist");
 43100        return File.Exists(plistPath);
 3101    }
 3102
 3103    /// <summary>
 3104    /// Builds a launchd plist document for a persistent launch agent/daemon.
 3105    /// </summary>
 3106    /// <param name="label">Launchd label.</param>
 3107    /// <param name="workingDirectory">Working directory.</param>
 3108    /// <param name="programArguments">Program argument list.</param>
 3109    /// <param name="serviceUser">Optional macOS account name for LaunchDaemon UserName.</param>
 3110    /// <returns>XML plist content.</returns>
 3111    private static string BuildLaunchdPlist(string label, string workingDirectory, IReadOnlyList<string> programArgument
 3112    {
 83113        var argsXml = string.Join(string.Empty, programArguments.Select(arg => $"\n    <string>{EscapeXml(arg)}</string>
 23114        var userXml = string.IsNullOrWhiteSpace(serviceUser)
 23115            ? string.Empty
 23116            : $"\n  <key>UserName</key>\n  <string>{EscapeXml(serviceUser)}</string>";
 23117        return $"""
 23118<?xml version="1.0" encoding="UTF-8"?>
 23119<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 23120<plist version="1.0">
 23121<dict>
 23122  <key>Label</key>
 23123  <string>{EscapeXml(label)}</string>
 23124  <key>ProgramArguments</key>
 23125  <array>{argsXml}
 23126  </array>
 23127  <key>WorkingDirectory</key>
 23128    <string>{EscapeXml(workingDirectory)}</string>{userXml}
 23129  <key>RunAtLoad</key>
 23130  <true/>
 23131  <key>KeepAlive</key>
 23132  <true/>
 23133</dict>
 23134</plist>
 23135""";
 3136    }
 3137
 3138    /// <summary>
 3139    /// Creates a per-service deployment bundle with runtime binary, module files, and the script entrypoint.
 3140    /// </summary>
 3141    /// <param name="serviceName">Service name.</param>
 3142    /// <param name="sourceScriptPath">Source script path.</param>
 3143    /// <param name="sourceModuleManifestPath">Source module manifest path.</param>
 3144    /// <param name="serviceVersion">Optional service version from descriptor metadata.</param>
 3145    /// <param name="serviceBundle">Created service bundle paths.</param>
 3146    /// <param name="error">Error details when bundling fails.</param>
 3147    /// <param name="deploymentRootOverride">Optional deployment root override for tests.</param>
 3148    /// <returns>True when service bundle creation succeeds.</returns>
 3149    private static bool TryPrepareServiceBundle(
 3150        string serviceName,
 3151        string sourceScriptPath,
 3152        string sourceModuleManifestPath,
 3153        string? sourceContentRoot,
 3154        string relativeScriptPath,
 3155        ResolvedServiceRuntimePackage runtimePackage,
 3156        out ServiceBundleLayout? serviceBundle,
 3157        out string error,
 3158        string? deploymentRootOverride = null,
 3159        string? serviceVersion = null)
 3160    {
 43161        serviceBundle = null;
 43162        error = string.Empty;
 3163
 43164        if (!TryResolveServiceBundleContext(
 43165                serviceName,
 43166                sourceScriptPath,
 43167                sourceModuleManifestPath,
 43168                runtimePackage,
 43169                deploymentRootOverride,
 43170                serviceVersion,
 43171                out var context,
 43172                out error))
 3173        {
 03174            return false;
 3175        }
 3176
 43177        var showProgress = !Console.IsOutputRedirected;
 43178        using var bundleProgress = showProgress
 43179            ? new ConsoleProgressBar("Preparing service bundle", 5, FormatServiceBundleStepProgressDetail)
 43180            : null;
 43181        var completedBundleSteps = 0;
 43182        bundleProgress?.Report(0);
 3183
 3184        try
 3185        {
 43186            RecreateServiceBundleDirectories(context);
 43187            completedBundleSteps++;
 43188            bundleProgress?.Report(completedBundleSteps);
 3189
 43190            var bundledRuntimePath = CopyServiceRuntimeExecutable(context);
 43191            completedBundleSteps++;
 43192            bundleProgress?.Report(completedBundleSteps);
 3193
 43194            if (!TryCopyServiceHostExecutable(context, out var bundledServiceHostPath, out error))
 3195            {
 03196                return false;
 3197            }
 3198
 43199            if (!TryCopyBundledRuntimeModules(context, showProgress, out error))
 3200            {
 03201                return false;
 3202            }
 3203
 43204            completedBundleSteps++;
 43205            bundleProgress?.Report(completedBundleSteps);
 3206
 43207            EnsureBundleExecutablesAreRunnable(bundledRuntimePath, bundledServiceHostPath);
 3208
 43209            if (!TryCopyServiceModuleFiles(context, showProgress, out var bundledManifestPath, out error))
 3210            {
 03211                return false;
 3212            }
 3213
 43214            completedBundleSteps++;
 43215            bundleProgress?.Report(completedBundleSteps);
 3216
 43217            if (!TryCopyServiceScriptFiles(context, sourceContentRoot, relativeScriptPath, showProgress, out var bundled
 3218            {
 03219                return false;
 3220            }
 3221
 43222            completedBundleSteps++;
 43223            bundleProgress?.Report(completedBundleSteps);
 43224            bundleProgress?.Complete(completedBundleSteps);
 3225
 43226            serviceBundle = new ServiceBundleLayout(
 43227                Path.GetFullPath(context.ServiceRoot),
 43228                Path.GetFullPath(bundledRuntimePath),
 43229                Path.GetFullPath(bundledServiceHostPath),
 43230                Path.GetFullPath(bundledScriptPath),
 43231                Path.GetFullPath(bundledManifestPath));
 43232            return true;
 3233        }
 03234        catch (Exception ex)
 3235        {
 03236            error = $"Failed to prepare service bundle at '{context.ServiceRoot}': {ex.Message}";
 03237            return false;
 3238        }
 43239    }
 3240
 3241    /// <summary>
 3242    /// Resolves and validates all source and destination paths required to build a service bundle.
 3243    /// </summary>
 3244    /// <param name="serviceName">Service name.</param>
 3245    /// <param name="sourceScriptPath">Source script path.</param>
 3246    /// <param name="sourceModuleManifestPath">Source module manifest path.</param>
 3247    /// <param name="deploymentRootOverride">Optional deployment root override for tests.</param>
 3248    /// <param name="serviceVersion">Optional service version from descriptor metadata.</param>
 3249    /// <param name="context">Resolved bundle path context.</param>
 3250    /// <param name="error">Error details when resolution fails.</param>
 3251    /// <returns>True when context resolution succeeds.</returns>
 3252    private static bool TryResolveServiceBundleContext(
 3253        string serviceName,
 3254        string sourceScriptPath,
 3255        string sourceModuleManifestPath,
 3256        ResolvedServiceRuntimePackage runtimePackage,
 3257        string? deploymentRootOverride,
 3258        string? serviceVersion,
 3259        out ServiceBundleContext context,
 3260        out string error)
 3261    {
 43262        context = default;
 43263        error = string.Empty;
 3264
 43265        var fullScriptPath = Path.GetFullPath(sourceScriptPath);
 43266        if (!File.Exists(fullScriptPath))
 3267        {
 03268            error = $"Script file not found: {fullScriptPath}";
 03269            return false;
 3270        }
 3271
 43272        var fullManifestPath = Path.GetFullPath(sourceModuleManifestPath);
 43273        if (!File.Exists(fullManifestPath))
 3274        {
 03275            error = $"Kestrun manifest file not found: {fullManifestPath}";
 03276            return false;
 3277        }
 3278
 43279        if (!TryResolveServiceRuntimeExecutableFromModule(fullManifestPath, out var runtimeExecutablePath, out var runti
 3280        {
 03281            error = runtimeError;
 03282            return false;
 3283        }
 3284
 43285        if (!TryResolveServiceDeploymentRoot(deploymentRootOverride, out var deploymentRoot, out var deploymentError))
 3286        {
 03287            error = deploymentError;
 03288            return false;
 3289        }
 3290
 43291        var serviceDirectoryName = GetServiceDeploymentDirectoryName(serviceName);
 43292        var serviceRoot = Path.Combine(deploymentRoot, serviceDirectoryName);
 43293        var runtimeDirectory = Path.Combine(serviceRoot, ServiceBundleRuntimeDirectoryName);
 43294        var modulesDirectory = Path.Combine(serviceRoot, ServiceBundleModulesDirectoryName);
 43295        var moduleDirectory = Path.Combine(modulesDirectory, ModuleName);
 43296        var scriptDirectory = Path.Combine(serviceRoot, ServiceBundleScriptDirectoryName);
 43297        var moduleRoot = Path.GetDirectoryName(fullManifestPath)!;
 3298
 43299        context = new ServiceBundleContext(
 43300            fullScriptPath,
 43301            fullManifestPath,
 43302            runtimeExecutablePath,
 43303            moduleRoot,
 43304            runtimePackage.ServiceHostExecutablePath,
 43305            runtimePackage.ModulesPath,
 43306            serviceRoot,
 43307            runtimeDirectory,
 43308            modulesDirectory,
 43309            moduleDirectory,
 43310            scriptDirectory);
 43311        return true;
 3312    }
 3313
 3314    /// <summary>
 3315    /// Recreates the target service bundle directory structure from scratch.
 3316    /// </summary>
 3317    /// <param name="context">Resolved service bundle context.</param>
 3318    private static void RecreateServiceBundleDirectories(ServiceBundleContext context)
 3319    {
 43320        if (Directory.Exists(context.ServiceRoot))
 3321        {
 03322            Directory.Delete(context.ServiceRoot, recursive: true);
 3323        }
 3324
 43325        _ = Directory.CreateDirectory(context.RuntimeDirectory);
 43326        _ = Directory.CreateDirectory(context.ModulesDirectory);
 43327        _ = Directory.CreateDirectory(context.ModuleDirectory);
 43328        _ = Directory.CreateDirectory(context.ScriptDirectory);
 43329    }
 3330
 3331    /// <summary>
 3332    /// Copies the resolved runtime executable into the service bundle runtime directory.
 3333    /// </summary>
 3334    /// <param name="context">Resolved service bundle context.</param>
 3335    /// <returns>Bundled runtime executable path.</returns>
 3336    private static string CopyServiceRuntimeExecutable(ServiceBundleContext context)
 3337    {
 43338        var bundledRuntimePath = Path.Combine(context.RuntimeDirectory, Path.GetFileName(context.RuntimeExecutablePath))
 43339        File.Copy(context.RuntimeExecutablePath, bundledRuntimePath, overwrite: true);
 43340        return bundledRuntimePath;
 3341    }
 3342
 3343    /// <summary>
 3344    /// Copies the dedicated service host executable into the service bundle runtime directory.
 3345    /// </summary>
 3346    /// <param name="runtimeDirectory">Runtime directory path.</param>
 3347    /// <param name="bundledServiceHostPath">Bundled service host executable path.</param>
 3348    /// <param name="error">Error details when host resolution or copy fails.</param>
 3349    /// <returns>True when the service host executable is copied successfully.</returns>
 3350    private static bool TryCopyServiceHostExecutable(ServiceBundleContext context, out string bundledServiceHostPath, ou
 3351    {
 43352        bundledServiceHostPath = string.Empty;
 43353        error = string.Empty;
 3354
 43355        if (!File.Exists(context.RuntimePackageServiceHostPath))
 3356        {
 03357            error = $"Resolved runtime payload does not contain the dedicated service host at '{context.RuntimePackageSe
 03358            return false;
 3359        }
 3360
 43361        bundledServiceHostPath = Path.Combine(context.RuntimeDirectory, Path.GetFileName(context.RuntimePackageServiceHo
 43362        File.Copy(context.RuntimePackageServiceHostPath, bundledServiceHostPath, overwrite: true);
 43363        return true;
 3364    }
 3365
 3366    /// <summary>
 3367    /// Copies bundled PowerShell modules payload from the tool distribution into the service bundle.
 3368    /// </summary>
 3369    /// <param name="modulesDirectory">Destination modules directory in the service bundle.</param>
 3370    /// <param name="showProgress">True to print copy progress details.</param>
 3371    /// <param name="error">Error details when payload resolution fails.</param>
 3372    /// <returns>True when bundled modules copy succeeds.</returns>
 3373    private static bool TryCopyBundledRuntimeModules(ServiceBundleContext context, bool showProgress, out string error)
 3374    {
 43375        error = string.Empty;
 3376
 43377        if (string.IsNullOrWhiteSpace(context.RuntimePackageModulesPath) || !Directory.Exists(context.RuntimePackageModu
 3378        {
 03379            error = $"Resolved runtime payload does not contain bundled PowerShell modules at '{context.RuntimePackageMo
 03380            return false;
 3381        }
 3382
 43383        CopyDirectoryContents(
 43384            context.RuntimePackageModulesPath,
 43385            context.ModulesDirectory,
 43386            showProgress,
 43387            "Bundling service runtime modules",
 43388            exclusionPatterns: null);
 43389        return true;
 3390    }
 3391
 3392    /// <summary>
 3393    /// Ensures copied runtime executables are executable on Unix-like platforms.
 3394    /// </summary>
 3395    /// <param name="bundledRuntimePath">Bundled runtime executable path.</param>
 3396    /// <param name="bundledServiceHostPath">Bundled service host executable path.</param>
 3397    private static void EnsureBundleExecutablesAreRunnable(string bundledRuntimePath, string bundledServiceHostPath)
 3398    {
 43399        if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
 3400        {
 03401            return;
 3402        }
 3403
 43404        TryEnsureServiceRuntimeExecutablePermissions(bundledRuntimePath);
 43405        if (!string.Equals(bundledRuntimePath, bundledServiceHostPath, StringComparison.OrdinalIgnoreCase))
 3406        {
 43407            TryEnsureServiceRuntimeExecutablePermissions(bundledServiceHostPath);
 3408        }
 43409    }
 3410
 3411    /// <summary>
 3412    /// Copies module files into the service bundle and validates that the module manifest is present.
 3413    /// </summary>
 3414    /// <param name="context">Resolved service bundle context.</param>
 3415    /// <param name="showProgress">True to print copy progress details.</param>
 3416    /// <param name="bundledManifestPath">Resulting bundled manifest path.</param>
 3417    /// <param name="error">Error details when the manifest is not present after copy.</param>
 3418    /// <returns>True when module files are copied and manifest validation succeeds.</returns>
 3419    private static bool TryCopyServiceModuleFiles(ServiceBundleContext context, bool showProgress, out string bundledMan
 3420    {
 43421        error = string.Empty;
 3422
 43423        CopyDirectoryContents(
 43424            context.ModuleRoot,
 43425            context.ModuleDirectory,
 43426            showProgress,
 43427            "Bundling module files",
 43428            ServiceBundleModuleExclusionPatterns);
 3429
 43430        bundledManifestPath = Path.Combine(context.ModuleDirectory, Path.GetFileName(context.FullManifestPath));
 43431        if (File.Exists(bundledManifestPath))
 3432        {
 43433            return true;
 3434        }
 3435
 03436        error = $"Service bundle copy did not include module manifest: {bundledManifestPath}";
 03437        return false;
 3438    }
 3439
 3440    /// <summary>
 3441    /// Copies service script files into the service bundle and validates that the entry script exists.
 3442    /// </summary>
 3443    /// <param name="context">Resolved service bundle context.</param>
 3444    /// <param name="sourceContentRoot">Optional script content root for folder copy mode.</param>
 3445    /// <param name="relativeScriptPath">Relative script path under the script folder.</param>
 3446    /// <param name="showProgress">True to print copy progress details.</param>
 3447    /// <param name="bundledScriptPath">Resulting bundled script entrypoint path.</param>
 3448    /// <param name="error">Error details when the bundled script is not present.</param>
 3449    /// <returns>True when script copy and validation succeed.</returns>
 3450    private static bool TryCopyServiceScriptFiles(
 3451        ServiceBundleContext context,
 3452        string? sourceContentRoot,
 3453        string relativeScriptPath,
 3454        bool showProgress,
 3455        out string bundledScriptPath,
 3456        out string error)
 3457    {
 43458        error = string.Empty;
 43459        bundledScriptPath = Path.Combine(context.ScriptDirectory, relativeScriptPath.Replace('/', Path.DirectorySeparato
 3460
 43461        if (string.IsNullOrWhiteSpace(sourceContentRoot))
 3462        {
 23463            var bundledScriptDirectory = Path.GetDirectoryName(bundledScriptPath);
 23464            if (!string.IsNullOrWhiteSpace(bundledScriptDirectory))
 3465            {
 23466                _ = Directory.CreateDirectory(bundledScriptDirectory);
 3467            }
 3468
 23469            File.Copy(context.FullScriptPath, bundledScriptPath, overwrite: true);
 3470        }
 3471        else
 3472        {
 23473            CopyDirectoryContents(
 23474                sourceContentRoot,
 23475                context.ScriptDirectory,
 23476                showProgress,
 23477                "Bundling service script folder",
 23478                exclusionPatterns: null);
 3479        }
 3480
 43481        if (File.Exists(bundledScriptPath))
 3482        {
 43483            return true;
 3484        }
 3485
 03486        error = $"Service bundle copy did not include script: {bundledScriptPath}";
 03487        return false;
 3488    }
 3489
 3490    /// <summary>
 3491    /// Stores resolved paths used during service bundle creation.
 3492    /// </summary>
 3493    /// <param name="FullScriptPath">Resolved source script path.</param>
 3494    /// <param name="FullManifestPath">Resolved source module manifest path.</param>
 3495    /// <param name="RuntimeExecutablePath">Resolved runtime executable source path.</param>
 3496    /// <param name="ModuleRoot">Resolved module directory root.</param>
 3497    /// <param name="RuntimePackageServiceHostPath">Resolved service host path from runtime payload.</param>
 3498    /// <param name="RuntimePackageModulesPath">Resolved modules path from runtime payload.</param>
 3499    /// <param name="ServiceRoot">Resolved service bundle root path.</param>
 3500    /// <param name="RuntimeDirectory">Resolved runtime directory inside bundle.</param>
 3501    /// <param name="ModulesDirectory">Resolved modules directory inside bundle.</param>
 3502    /// <param name="ModuleDirectory">Resolved module-specific directory inside bundle.</param>
 3503    /// <param name="ScriptDirectory">Resolved script directory inside bundle.</param>
 3504    private readonly record struct ServiceBundleContext(
 23505        string FullScriptPath,
 43506        string FullManifestPath,
 83507        string RuntimeExecutablePath,
 43508        string ModuleRoot,
 123509        string RuntimePackageServiceHostPath,
 123510        string RuntimePackageModulesPath,
 83511        string ServiceRoot,
 123512        string RuntimeDirectory,
 83513        string ModulesDirectory,
 123514        string ModuleDirectory,
 103515        string ScriptDirectory);
 3516}

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/Program.ServiceParsing.cs

#LineLine coverage
 1namespace Kestrun.Tool;
 2
 3internal static partial class Program
 4{
 5    private sealed class ServiceParseState
 6    {
 1887        public string ServiceName { get; set; } = string.Empty;
 8
 1309        public bool ServiceNameSet { get; set; }
 10
 13611        public string ScriptPath { get; set; } = string.Empty;
 12
 13313        public bool ScriptPathSet { get; set; }
 14
 12115        public string[] ScriptArguments { get; set; } = [];
 16
 4617        public string? ServiceLogPath { get; set; }
 18
 10619        public string? ServiceUser { get; set; }
 20
 10521        public string? ServicePassword { get; set; }
 22
 14523        public string? ServiceContentRoot { get; set; }
 24
 5525        public bool ServicePackageSet { get; set; }
 26
 4827        public string? ServiceDeploymentRoot { get; set; }
 28
 10029        public string? ServiceRuntimeSource { get; set; }
 30
 9531        public string? ServiceRuntimePackage { get; set; }
 32
 5633        public string? ServiceRuntimeVersion { get; set; }
 34
 5035        public string? ServiceRuntimePackageId { get; set; }
 36
 5137        public string? ServiceRuntimeCache { get; set; }
 38
 9739        public string? ServiceContentRootChecksum { get; set; }
 40
 9541        public string? ServiceContentRootChecksumAlgorithm { get; set; }
 42
 9543        public string? ServiceContentRootBearerToken { get; set; }
 44
 9145        public bool ServiceContentRootIgnoreCertificate { get; set; }
 46
 6547        public bool ServiceFailbackRequested { get; set; }
 48
 4949        public bool ServiceUseRepositoryKestrun { get; set; }
 50
 10951        public bool ServiceJsonOutputRequested { get; set; }
 52
 5053        public bool ServiceRawOutputRequested { get; set; }
 54
 17055        public List<string> ServiceContentRootHeaders { get; } = [];
 56    }
 57
 58    private sealed class ServiceRegisterParseState
 59    {
 360        public string ServiceName { get; set; } = string.Empty;
 61
 362        public string ServiceHostExecutablePath { get; set; } = string.Empty;
 63
 364        public string RunnerExecutablePath { get; set; } = string.Empty;
 65
 366        public string ScriptPath { get; set; } = string.Empty;
 67
 368        public string ModuleManifestPath { get; set; } = string.Empty;
 69
 370        public string[] ScriptArguments { get; set; } = [];
 71
 072        public string? ServiceLogPath { get; set; }
 73
 074        public string? ServiceUser { get; set; }
 75
 076        public string? ServicePassword { get; set; }
 77    }
 78
 79    /// <summary>
 80    /// Parses arguments for service install/remove/start/stop/query/info commands.
 81    /// </summary>
 82    /// <param name="args">Raw command-line arguments.</param>
 83    /// <param name="startIndex">Index after service token.</param>
 84    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 85    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 86    /// <param name="parsedCommand">Parsed command payload.</param>
 87    /// <param name="error">Error message when parsing fails.</param>
 88    /// <returns>True when parsing succeeds.</returns>
 89    private static bool TryParseServiceArguments(string[] args, int startIndex, string? kestrunFolder, string? kestrunMa
 90    {
 7691        parsedCommand = CreateDefaultServiceParsedCommand(kestrunFolder, kestrunManifestPath);
 7692        if (!TryResolveServiceMode(args, startIndex, out var mode, out error))
 93        {
 094            return false;
 95        }
 96
 7697        var state = new ServiceParseState();
 7698        if (!TryParseServiceOptionLoop(args, mode, state, startIndex + 1, ref kestrunFolder, ref kestrunManifestPath, ou
 99        {
 14100            return false;
 101        }
 102
 62103        if (!TryValidateServiceParseState(mode, state, out error))
 104        {
 17105            return false;
 106        }
 107
 45108        parsedCommand = CreateServiceParsedCommand(mode, state, kestrunFolder, kestrunManifestPath);
 45109        return true;
 110    }
 111
 112    /// <summary>
 113    /// Creates the default parsed command placeholder for service command parsing.
 114    /// </summary>
 115    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 116    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 117    /// <returns>Default parsed command for service mode.</returns>
 118    private static ParsedCommand CreateDefaultServiceParsedCommand(string? kestrunFolder, string? kestrunManifestPath)
 76119        => new(CommandMode.ServiceInstall, string.Empty, false, [], kestrunFolder, kestrunManifestPath, null, false, nul
 120
 121    /// <summary>
 122    /// Validates service token bounds and resolves command mode.
 123    /// </summary>
 124    /// <param name="args">Raw command-line arguments.</param>
 125    /// <param name="startIndex">Index after service token.</param>
 126    /// <param name="mode">Resolved service mode.</param>
 127    /// <param name="error">Error text when parsing fails.</param>
 128    /// <returns>True when a service mode is resolved.</returns>
 129    private static bool TryResolveServiceMode(string[] args, int startIndex, out CommandMode mode, out string error)
 130    {
 76131        mode = CommandMode.Run;
 76132        if (startIndex >= args.Length)
 133        {
 0134            error = "Missing service action. Use 'service install', 'service update', 'service remove', 'service start',
 0135            return false;
 136        }
 137
 76138        return TryParseServiceMode(args[startIndex], out mode, out error);
 139    }
 140
 141    /// <summary>
 142    /// Parses all option and positional tokens for service commands.
 143    /// </summary>
 144    /// <param name="args">Raw command-line arguments.</param>
 145    /// <param name="mode">Current service mode.</param>
 146    /// <param name="state">Mutable service parse state.</param>
 147    /// <param name="startIndex">First token index after service action.</param>
 148    /// <param name="kestrunFolder">Optional folder override.</param>
 149    /// <param name="kestrunManifestPath">Optional manifest override.</param>
 150    /// <param name="error">Error text when parsing fails.</param>
 151    /// <returns>True when option parsing succeeds.</returns>
 152    private static bool TryParseServiceOptionLoop(
 153        string[] args,
 154        CommandMode mode,
 155        ServiceParseState state,
 156        int startIndex,
 157        ref string? kestrunFolder,
 158        ref string? kestrunManifestPath,
 159        out string error)
 160    {
 76161        error = string.Empty;
 76162        var index = startIndex;
 239163        while (index < args.Length)
 164        {
 177165            if (TryConsumeServiceOption(args, mode, state, ref index, ref kestrunFolder, ref kestrunManifestPath, out er
 166            {
 170167                if (!string.IsNullOrEmpty(error))
 168                {
 7169                    return false;
 170                }
 171
 172                continue;
 173            }
 174
 7175            if (!string.IsNullOrEmpty(error))
 176            {
 0177                return false;
 178            }
 179
 7180            var current = args[index];
 7181            if (mode == CommandMode.ServiceInstall && (current is "--arguments" or "--"))
 182            {
 0183                state.ScriptArguments = [.. args.Skip(index + 1)];
 0184                break;
 185            }
 186
 7187            if (!TryConsumeServicePositionalScript(current, mode, state, out error))
 188            {
 7189                return false;
 190            }
 191
 0192            index += 1;
 193        }
 194
 62195        return true;
 196    }
 197
 198    /// <summary>
 199    /// Creates the final parsed command from service parse state.
 200    /// </summary>
 201    /// <param name="mode">Resolved service mode.</param>
 202    /// <param name="state">Completed parse state.</param>
 203    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 204    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 205    /// <returns>Parsed command payload.</returns>
 206    private static ParsedCommand CreateServiceParsedCommand(CommandMode mode, ServiceParseState state, string? kestrunFo
 45207        => new(
 45208            mode,
 45209            state.ScriptPath,
 45210            state.ScriptPathSet,
 45211            state.ScriptArguments,
 45212            kestrunFolder,
 45213            kestrunManifestPath,
 45214            state.ServiceName,
 45215            state.ServiceNameSet,
 45216            state.ServiceLogPath,
 45217            state.ServiceUser,
 45218            state.ServicePassword,
 45219            null,
 45220            ModuleStorageScope.Local,
 45221            false,
 45222            state.ServiceContentRoot,
 45223            state.ServiceDeploymentRoot,
 45224            state.ServiceRuntimeSource,
 45225            state.ServiceRuntimePackage,
 45226            state.ServiceRuntimeVersion,
 45227            state.ServiceRuntimePackageId,
 45228            state.ServiceRuntimeCache,
 45229            state.ServiceContentRootChecksum,
 45230            state.ServiceContentRootChecksumAlgorithm,
 45231            state.ServiceContentRootBearerToken,
 45232            state.ServiceContentRootIgnoreCertificate,
 45233            [.. state.ServiceContentRootHeaders],
 45234            state.ServiceFailbackRequested,
 45235            state.ServiceUseRepositoryKestrun,
 45236            state.ServiceJsonOutputRequested,
 45237            state.ServiceRawOutputRequested);
 238
 239    /// <summary>
 240    /// Parses the service action token into a concrete command mode.
 241    /// </summary>
 242    /// <param name="action">Service action token.</param>
 243    /// <param name="mode">Parsed service mode.</param>
 244    /// <param name="error">Error text when parsing fails.</param>
 245    /// <returns>True when the action token is valid.</returns>
 246    private static bool TryParseServiceMode(string action, out CommandMode mode, out string error)
 247    {
 76248        mode = action.ToLowerInvariant() switch
 76249        {
 45250            "install" => CommandMode.ServiceInstall,
 14251            "update" => CommandMode.ServiceUpdate,
 2252            "remove" => CommandMode.ServiceRemove,
 4253            "start" => CommandMode.ServiceStart,
 3254            "stop" => CommandMode.ServiceStop,
 3255            "query" => CommandMode.ServiceQuery,
 5256            "info" => CommandMode.ServiceInfo,
 0257            _ => CommandMode.Run,
 76258        };
 259
 76260        if (mode != CommandMode.Run)
 261        {
 76262            error = string.Empty;
 76263            return true;
 264        }
 265
 0266        error = $"Unknown service action: {action}. Use 'service install', 'service update', 'service remove', 'service 
 0267        return false;
 268    }
 269
 270    /// <summary>
 271    /// Attempts to consume one named option in service argument parsing.
 272    /// </summary>
 273    /// <param name="args">Raw command-line arguments.</param>
 274    /// <param name="mode">Current service mode.</param>
 275    /// <param name="state">Mutable service parse state.</param>
 276    /// <param name="index">Current argument index.</param>
 277    /// <param name="kestrunFolder">Optional folder override.</param>
 278    /// <param name="kestrunManifestPath">Optional manifest override.</param>
 279    /// <param name="error">Error text when parsing fails.</param>
 280    /// <returns>True when an option was consumed or handled.</returns>
 281    private static bool TryConsumeServiceOption(
 282        string[] args,
 283        CommandMode mode,
 284        ServiceParseState state,
 285        ref int index,
 286        ref string? kestrunFolder,
 287        ref string? kestrunManifestPath,
 288        out string error)
 289    {
 177290        error = string.Empty;
 177291        var current = args[index];
 292
 177293        return current switch
 177294        {
 15295            "--script" => TryConsumeServiceScriptOption(args, mode, state, ref index, out error),
 41296            "--name" or "-n" => TryConsumeServiceNameOption(args, state, ref index, out error),
 0297            "--kestrun-folder" or "-k" => TryConsumeKestrunFolderOption(args, ref kestrunFolder, ref index, out error),
 5298            "--kestrun-manifest" or "-m" => TryConsumeKestrunManifestOption(args, ref kestrunManifestPath, ref index, "-
 1299            "--kestrun-module" or "--kestrunModule" => TryConsumeKestrunManifestOption(args, ref kestrunManifestPath, re
 1300            "--service-log-path" => TryConsumeServiceLogPathOption(args, state, ref index, out error),
 1301            "--service-user" => TryConsumeServiceUserOption(args, mode, state, ref index, out error),
 1302            "--service-password" => TryConsumeServicePasswordOption(args, mode, state, ref index, out error),
 4303            "--deployment-root" => TryConsumeServiceDeploymentRootOption(args, mode, state, ref index, out error),
 9304            "--package" => TryConsumeServicePackageOption(args, mode, state, ref index, out error),
 6305            "--runtime-source" => TryConsumeServiceRuntimeSourceOption(args, mode, state, ref index, out error),
 5306            "--runtime-package" => TryConsumeServiceRuntimePackageOption(args, mode, state, ref index, out error),
 5307            "--runtime-version" => TryConsumeServiceRuntimeVersionOption(args, mode, state, ref index, out error),
 1308            "--runtime-package-id" => TryConsumeServiceRuntimePackageIdOption(args, mode, state, ref index, out error),
 6309            "--runtime-cache" => TryConsumeServiceRuntimeCacheOption(args, mode, state, ref index, out error),
 26310            "--content-root" => TryConsumeDeprecatedServiceContentRootOption(args, mode, state, ref index, out error),
 4311            "--content-root-checksum" => TryConsumeServiceContentRootChecksumOption(args, mode, state, ref index, out er
 3312            "--content-root-checksum-algorithm" => TryConsumeServiceContentRootChecksumAlgorithmOption(args, mode, state
 6313            "--content-root-bearer-token" => TryConsumeServiceContentRootBearerTokenOption(args, mode, state, ref index,
 5314            "--content-root-ignore-certificate" => TryConsumeServiceContentRootIgnoreCertificateOption(mode, state, ref 
 10315            "--content-root-header" => TryConsumeServiceContentRootHeaderOption(args, mode, state, ref index, out error)
 8316            "--failback" => TryConsumeServiceFailbackOption(mode, state, ref index, out error),
 2317            "--kestrun" => TryConsumeServiceRepositoryKestrunOption(mode, state, ref index, out error),
 3318            "--json" => TryConsumeServiceJsonOption(mode, state, ref index, out error),
 2319            RawOption => TryConsumeServiceRawOption(mode, state, ref index, out error),
 7320            _ => false,
 177321        };
 322    }
 323
 324    /// <summary>
 325    /// Consumes and validates the json output switch.
 326    /// </summary>
 327    /// <param name="mode">Current service mode.</param>
 328    /// <param name="state">Mutable service parse state.</param>
 329    /// <param name="index">Current parser index.</param>
 330    /// <param name="error">Error text when parsing fails.</param>
 331    /// <returns>True when the option token is handled.</returns>
 332    private static bool TryConsumeServiceJsonOption(CommandMode mode, ServiceParseState state, ref int index, out string
 333    {
 3334        if (mode is not (CommandMode.ServiceInfo or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.S
 335        {
 0336            error = "--json is only supported for service start/stop/query/info.";
 0337            return true;
 338        }
 339
 3340        state.ServiceJsonOutputRequested = true;
 3341        index += 1;
 3342        error = string.Empty;
 3343        return true;
 344    }
 345
 346    /// <summary>
 347    /// Consumes and validates the raw output switch.
 348    /// </summary>
 349    /// <param name="mode">Current service mode.</param>
 350    /// <param name="state">Mutable service parse state.</param>
 351    /// <param name="index">Current parser index.</param>
 352    /// <param name="error">Error text when parsing fails.</param>
 353    /// <returns>True when the option token is handled.</returns>
 354    private static bool TryConsumeServiceRawOption(CommandMode mode, ServiceParseState state, ref int index, out string 
 355    {
 2356        if (mode is not (CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.ServiceQuery))
 357        {
 0358            error = "--raw is only supported for service start/stop/query.";
 0359            return true;
 360        }
 361
 2362        state.ServiceRawOutputRequested = true;
 2363        index += 1;
 2364        error = string.Empty;
 2365        return true;
 366    }
 367
 368    /// <summary>
 369    /// Consumes and validates the repository Kestrun module switch.
 370    /// </summary>
 371    /// <param name="mode">Current service mode.</param>
 372    /// <param name="state">Mutable service parse state.</param>
 373    /// <param name="index">Current parser index.</param>
 374    /// <param name="error">Error text when parsing fails.</param>
 375    /// <returns>True when the option token is handled.</returns>
 376    private static bool TryConsumeServiceRepositoryKestrunOption(CommandMode mode, ServiceParseState state, ref int inde
 377    {
 2378        if (mode != CommandMode.ServiceUpdate)
 379        {
 0380            error = "--kestrun is only supported for service update.";
 0381            return true;
 382        }
 383
 2384        state.ServiceUseRepositoryKestrun = true;
 2385        index += 1;
 2386        error = string.Empty;
 2387        return true;
 388    }
 389
 390    /// <summary>
 391    /// Consumes and validates the failback switch.
 392    /// </summary>
 393    /// <param name="mode">Current service mode.</param>
 394    /// <param name="state">Mutable service parse state.</param>
 395    /// <param name="index">Current parser index.</param>
 396    /// <param name="error">Error text when parsing fails.</param>
 397    /// <returns>True when the option token is handled.</returns>
 398    private static bool TryConsumeServiceFailbackOption(CommandMode mode, ServiceParseState state, ref int index, out st
 399    {
 8400        if (mode != CommandMode.ServiceUpdate)
 401        {
 0402            error = "--failback is only supported for service update.";
 0403            return true;
 404        }
 405
 8406        state.ServiceFailbackRequested = true;
 8407        index += 1;
 8408        error = string.Empty;
 8409        return true;
 410    }
 411
 412    /// <summary>
 413    /// Consumes and validates the service script option.
 414    /// </summary>
 415    /// <param name="args">Raw command-line arguments.</param>
 416    /// <param name="mode">Current service mode.</param>
 417    /// <param name="state">Mutable service parse state.</param>
 418    /// <param name="index">Current parser index.</param>
 419    /// <param name="error">Error text when parsing fails.</param>
 420    /// <returns>True when the option token is handled.</returns>
 421    private static bool TryConsumeServiceScriptOption(string[] args, CommandMode mode, ServiceParseState state, ref int 
 422    {
 15423        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 424        {
 0425            error = "Service remove/start/stop/query/info/update does not accept --script.";
 0426            return true;
 427        }
 428
 15429        if (!TryConsumeOptionValue(args, ref index, "--script", out var value, out error))
 430        {
 0431            return true;
 432        }
 433
 15434        if (state.ScriptPathSet)
 435        {
 0436            error = "Script path was provided multiple times. Use either positional script path or --script once.";
 0437            return true;
 438        }
 439
 15440        state.ScriptPath = value;
 15441        state.ScriptPathSet = true;
 15442        return true;
 443    }
 444
 445    /// <summary>
 446    /// Consumes and applies the service name option.
 447    /// </summary>
 448    /// <param name="args">Raw command-line arguments.</param>
 449    /// <param name="state">Mutable service parse state.</param>
 450    /// <param name="index">Current parser index.</param>
 451    /// <param name="error">Error text when parsing fails.</param>
 452    /// <returns>True when the option token is handled.</returns>
 453    private static bool TryConsumeServiceNameOption(string[] args, ServiceParseState state, ref int index, out string er
 454    {
 41455        if (!TryConsumeOptionValue(args, ref index, "--name", out var value, out error))
 456        {
 0457            return true;
 458        }
 459
 41460        state.ServiceName = value;
 41461        state.ServiceNameSet = true;
 41462        return true;
 463    }
 464
 465    /// <summary>
 466    /// Consumes and applies the Kestrun folder option.
 467    /// </summary>
 468    /// <param name="args">Raw command-line arguments.</param>
 469    /// <param name="kestrunFolder">Optional folder override.</param>
 470    /// <param name="index">Current parser index.</param>
 471    /// <param name="error">Error text when parsing fails.</param>
 472    /// <returns>True when the option token is handled.</returns>
 473    private static bool TryConsumeKestrunFolderOption(string[] args, ref string? kestrunFolder, ref int index, out strin
 474    {
 0475        if (!TryConsumeOptionValue(args, ref index, "--kestrun-folder", out _, out error))
 476        {
 0477            return true;
 478        }
 479
 480        _ = kestrunFolder;
 0481        error = "--kestrun-folder is no longer supported. Use --kestrun-manifest when a custom manifest path is required
 0482        return true;
 483    }
 484
 485    /// <summary>
 486    /// Consumes and applies the Kestrun manifest option.
 487    /// </summary>
 488    /// <param name="args">Raw command-line arguments.</param>
 489    /// <param name="kestrunManifestPath">Optional manifest override.</param>
 490    /// <param name="index">Current parser index.</param>
 491    /// <param name="error">Error text when parsing fails.</param>
 492    /// <returns>True when the option token is handled.</returns>
 493    private static bool TryConsumeKestrunManifestOption(string[] args, ref string? kestrunManifestPath, ref int index, s
 494    {
 6495        if (!TryConsumeOptionValue(args, ref index, optionName, out var value, out error))
 496        {
 0497            return true;
 498        }
 499
 6500        kestrunManifestPath = value;
 6501        return true;
 502    }
 503
 504    /// <summary>
 505    /// Consumes and applies the service log path option.
 506    /// </summary>
 507    /// <param name="args">Raw command-line arguments.</param>
 508    /// <param name="state">Mutable service parse state.</param>
 509    /// <param name="index">Current parser index.</param>
 510    /// <param name="error">Error text when parsing fails.</param>
 511    /// <returns>True when the option token is handled.</returns>
 512    private static bool TryConsumeServiceLogPathOption(string[] args, ServiceParseState state, ref int index, out string
 513    {
 1514        if (!TryConsumeOptionValue(args, ref index, "--service-log-path", out var value, out error))
 515        {
 0516            return true;
 517        }
 518
 1519        state.ServiceLogPath = value;
 1520        return true;
 521    }
 522
 523    /// <summary>
 524    /// Consumes and validates the service-user option.
 525    /// </summary>
 526    /// <param name="args">Raw command-line arguments.</param>
 527    /// <param name="mode">Current service mode.</param>
 528    /// <param name="state">Mutable service parse state.</param>
 529    /// <param name="index">Current parser index.</param>
 530    /// <param name="error">Error text when parsing fails.</param>
 531    /// <returns>True when the option token is handled.</returns>
 532    private static bool TryConsumeServiceUserOption(string[] args, CommandMode mode, ServiceParseState state, ref int in
 533    {
 1534        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 535        {
 0536            error = "Service remove/start/stop/query/info/update does not accept --service-user.";
 0537            return true;
 538        }
 539
 1540        if (!TryConsumeOptionValue(args, ref index, "--service-user", out var value, out error))
 541        {
 0542            return true;
 543        }
 544
 1545        state.ServiceUser = value;
 1546        return true;
 547    }
 548
 549    /// <summary>
 550    /// Consumes and validates the service-password option.
 551    /// </summary>
 552    /// <param name="args">Raw command-line arguments.</param>
 553    /// <param name="mode">Current service mode.</param>
 554    /// <param name="state">Mutable service parse state.</param>
 555    /// <param name="index">Current parser index.</param>
 556    /// <param name="error">Error text when parsing fails.</param>
 557    /// <returns>True when the option token is handled.</returns>
 558    private static bool TryConsumeServicePasswordOption(string[] args, CommandMode mode, ServiceParseState state, ref in
 559    {
 1560        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 561        {
 0562            error = "Service remove/start/stop/query/info/update does not accept --service-password.";
 0563            return true;
 564        }
 565
 1566        if (!TryConsumeOptionValue(args, ref index, "--service-password", out var value, out error))
 567        {
 0568            return true;
 569        }
 570
 1571        state.ServicePassword = value;
 1572        return true;
 573    }
 574
 575    /// <summary>
 576    /// Consumes and validates the deployment-root option.
 577    /// </summary>
 578    /// <param name="args">Raw command-line arguments.</param>
 579    /// <param name="mode">Current service mode.</param>
 580    /// <param name="state">Mutable service parse state.</param>
 581    /// <param name="index">Current parser index.</param>
 582    /// <param name="error">Error text when parsing fails.</param>
 583    /// <returns>True when the option token is handled.</returns>
 584    private static bool TryConsumeServiceDeploymentRootOption(string[] args, CommandMode mode, ServiceParseState state, 
 585    {
 4586        if (mode is CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.ServiceQuery)
 587        {
 1588            error = "Service start/stop/query does not accept --deployment-root.";
 1589            return true;
 590        }
 591
 3592        if (!TryConsumeOptionValue(args, ref index, "--deployment-root", out var value, out error))
 593        {
 0594            return true;
 595        }
 596
 3597        state.ServiceDeploymentRoot = value;
 3598        return true;
 599    }
 600
 601    /// <summary>
 602    /// Consumes and validates the package option.
 603    /// </summary>
 604    /// <param name="args">Raw command-line arguments.</param>
 605    /// <param name="mode">Current service mode.</param>
 606    /// <param name="state">Mutable service parse state.</param>
 607    /// <param name="index">Current parser index.</param>
 608    /// <param name="error">Error text when parsing fails.</param>
 609    /// <returns>True when the option token is handled.</returns>
 610    private static bool TryConsumeServicePackageOption(string[] args, CommandMode mode, ServiceParseState state, ref int
 611    {
 9612        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 613        {
 5614            error = "Service remove/start/stop/query/info does not accept --package.";
 5615            return true;
 616        }
 617
 4618        if (!TryConsumeOptionValue(args, ref index, "--package", out var value, out error))
 619        {
 0620            return true;
 621        }
 622
 4623        state.ServiceContentRoot = value;
 4624        state.ServicePackageSet = true;
 4625        return true;
 626    }
 627
 628    /// <summary>
 629    /// Consumes and validates the runtime-source option.
 630    /// </summary>
 631    /// <param name="args">Raw command-line arguments.</param>
 632    /// <param name="mode">Current service mode.</param>
 633    /// <param name="state">Mutable service parse state.</param>
 634    /// <param name="index">Current parser index.</param>
 635    /// <param name="error">Error text when parsing fails.</param>
 636    /// <returns>True when the option token is handled.</returns>
 637    private static bool TryConsumeServiceRuntimeSourceOption(string[] args, CommandMode mode, ServiceParseState state, r
 638    {
 6639        if (mode != CommandMode.ServiceInstall)
 640        {
 1641            error = "--runtime-source is only supported for service install.";
 1642            return true;
 643        }
 644
 5645        if (!TryConsumeOptionValue(args, ref index, "--runtime-source", out var value, out error))
 646        {
 0647            return true;
 648        }
 649
 5650        state.ServiceRuntimeSource = value;
 5651        return true;
 652    }
 653
 654    /// <summary>
 655    /// Consumes and validates the runtime-package option.
 656    /// </summary>
 657    /// <param name="args">Raw command-line arguments.</param>
 658    /// <param name="mode">Current service mode.</param>
 659    /// <param name="state">Mutable service parse state.</param>
 660    /// <param name="index">Current parser index.</param>
 661    /// <param name="error">Error text when parsing fails.</param>
 662    /// <returns>True when the option token is handled.</returns>
 663    private static bool TryConsumeServiceRuntimePackageOption(string[] args, CommandMode mode, ServiceParseState state, 
 664    {
 5665        if (mode != CommandMode.ServiceInstall)
 666        {
 0667            error = "--runtime-package is only supported for service install.";
 0668            return true;
 669        }
 670
 5671        if (!TryConsumeOptionValue(args, ref index, "--runtime-package", out var value, out error))
 672        {
 0673            return true;
 674        }
 675
 5676        state.ServiceRuntimePackage = value;
 5677        return true;
 678    }
 679
 680    /// <summary>
 681    /// Consumes and validates the runtime-version option.
 682    /// </summary>
 683    /// <param name="args">Raw command-line arguments.</param>
 684    /// <param name="mode">Current service mode.</param>
 685    /// <param name="state">Mutable service parse state.</param>
 686    /// <param name="index">Current parser index.</param>
 687    /// <param name="error">Error text when parsing fails.</param>
 688    /// <returns>True when the option token is handled.</returns>
 689    private static bool TryConsumeServiceRuntimeVersionOption(string[] args, CommandMode mode, ServiceParseState state, 
 690    {
 5691        if (mode != CommandMode.ServiceInstall)
 692        {
 0693            error = "--runtime-version is only supported for service install.";
 0694            return true;
 695        }
 696
 5697        if (!TryConsumeOptionValue(args, ref index, "--runtime-version", out var value, out error))
 698        {
 0699            return true;
 700        }
 701
 5702        state.ServiceRuntimeVersion = value;
 5703        return true;
 704    }
 705
 706    /// <summary>
 707    /// Consumes and validates the runtime-package-id option.
 708    /// </summary>
 709    /// <param name="args">Raw command-line arguments.</param>
 710    /// <param name="mode">Current service mode.</param>
 711    /// <param name="state">Mutable service parse state.</param>
 712    /// <param name="index">Current parser index.</param>
 713    /// <param name="error">Error text when parsing fails.</param>
 714    /// <returns>True when the option token is handled.</returns>
 715    private static bool TryConsumeServiceRuntimePackageIdOption(string[] args, CommandMode mode, ServiceParseState state
 716    {
 1717        if (mode != CommandMode.ServiceInstall)
 718        {
 0719            error = "--runtime-package-id is only supported for service install.";
 0720            return true;
 721        }
 722
 1723        if (!TryConsumeOptionValue(args, ref index, "--runtime-package-id", out var value, out error))
 724        {
 0725            return true;
 726        }
 727
 1728        state.ServiceRuntimePackageId = value;
 1729        return true;
 730    }
 731
 732    /// <summary>
 733    /// Consumes and validates the runtime-cache option.
 734    /// </summary>
 735    /// <param name="args">Raw command-line arguments.</param>
 736    /// <param name="mode">Current service mode.</param>
 737    /// <param name="state">Mutable service parse state.</param>
 738    /// <param name="index">Current parser index.</param>
 739    /// <param name="error">Error text when parsing fails.</param>
 740    /// <returns>True when the option token is handled.</returns>
 741    private static bool TryConsumeServiceRuntimeCacheOption(string[] args, CommandMode mode, ServiceParseState state, re
 742    {
 6743        if (mode != CommandMode.ServiceInstall)
 744        {
 0745            error = "--runtime-cache is only supported for service install.";
 0746            return true;
 747        }
 748
 6749        if (!TryConsumeOptionValue(args, ref index, "--runtime-cache", out var value, out error))
 750        {
 0751            return true;
 752        }
 753
 6754        state.ServiceRuntimeCache = value;
 6755        return true;
 756    }
 757
 758    /// <summary>
 759    /// Handles deprecated content-root option for service install.
 760    /// </summary>
 761    /// <param name="mode">Current service mode.</param>
 762    /// <param name="index">Current parser index.</param>
 763    /// <param name="error">Error text when parsing fails.</param>
 764    /// <returns>True when the option token is handled.</returns>
 765    private static bool TryConsumeDeprecatedServiceContentRootOption(string[] args, CommandMode mode, ServiceParseState 
 766    {
 26767        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 768        {
 0769            error = "Service remove/start/stop/query/info does not accept --content-root.";
 0770            return true;
 771        }
 772
 26773        if (!TryConsumeOptionValue(args, ref index, "--content-root", out var value, out error))
 774        {
 0775            return true;
 776        }
 777
 26778        state.ServiceContentRoot = value;
 26779        state.ServicePackageSet = false;
 26780        error = string.Empty;
 26781        return true;
 782    }
 783
 784    /// <summary>
 785    /// Consumes and validates the content-root checksum option.
 786    /// </summary>
 787    /// <param name="args">Raw command-line arguments.</param>
 788    /// <param name="mode">Current service mode.</param>
 789    /// <param name="state">Mutable service parse state.</param>
 790    /// <param name="index">Current parser index.</param>
 791    /// <param name="error">Error text when parsing fails.</param>
 792    /// <returns>True when the option token is handled.</returns>
 793    private static bool TryConsumeServiceContentRootChecksumOption(string[] args, CommandMode mode, ServiceParseState st
 794    {
 4795        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 796        {
 0797            error = "Service remove/start/stop/query/info does not accept --content-root-checksum.";
 0798            return true;
 799        }
 800
 4801        if (!TryConsumeOptionValue(args, ref index, "--content-root-checksum", out var value, out error))
 802        {
 0803            return true;
 804        }
 805
 4806        state.ServiceContentRootChecksum = value;
 4807        return true;
 808    }
 809
 810    /// <summary>
 811    /// Consumes and validates the content-root checksum algorithm option.
 812    /// </summary>
 813    /// <param name="args">Raw command-line arguments.</param>
 814    /// <param name="mode">Current service mode.</param>
 815    /// <param name="state">Mutable service parse state.</param>
 816    /// <param name="index">Current parser index.</param>
 817    /// <param name="error">Error text when parsing fails.</param>
 818    /// <returns>True when the option token is handled.</returns>
 819    private static bool TryConsumeServiceContentRootChecksumAlgorithmOption(string[] args, CommandMode mode, ServicePars
 820    {
 3821        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 822        {
 0823            error = "Service remove/start/stop/query/info does not accept --content-root-checksum-algorithm.";
 0824            return true;
 825        }
 826
 3827        if (!TryConsumeOptionValue(args, ref index, "--content-root-checksum-algorithm", out var value, out error))
 828        {
 0829            return true;
 830        }
 831
 3832        state.ServiceContentRootChecksumAlgorithm = value;
 3833        return true;
 834    }
 835
 836    /// <summary>
 837    /// Consumes and validates the content-root bearer token option.
 838    /// </summary>
 839    /// <param name="args">Raw command-line arguments.</param>
 840    /// <param name="mode">Current service mode.</param>
 841    /// <param name="state">Mutable service parse state.</param>
 842    /// <param name="index">Current parser index.</param>
 843    /// <param name="error">Error text when parsing fails.</param>
 844    /// <returns>True when the option token is handled.</returns>
 845    private static bool TryConsumeServiceContentRootBearerTokenOption(string[] args, CommandMode mode, ServiceParseState
 846    {
 6847        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 848        {
 0849            error = "Service remove/start/stop/query/info does not accept --content-root-bearer-token.";
 0850            return true;
 851        }
 852
 6853        if (!TryConsumeOptionValue(args, ref index, "--content-root-bearer-token", out var value, out error))
 854        {
 0855            return true;
 856        }
 857
 6858        state.ServiceContentRootBearerToken = value;
 6859        return true;
 860    }
 861
 862    /// <summary>
 863    /// Consumes and validates the content-root certificate-ignore option.
 864    /// </summary>
 865    /// <param name="mode">Current service mode.</param>
 866    /// <param name="state">Mutable service parse state.</param>
 867    /// <param name="index">Current parser index.</param>
 868    /// <param name="error">Error text when parsing fails.</param>
 869    /// <returns>True when the option token is handled.</returns>
 870    private static bool TryConsumeServiceContentRootIgnoreCertificateOption(CommandMode mode, ServiceParseState state, r
 871    {
 5872        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 873        {
 0874            error = "Service remove/start/stop/query/info does not accept --content-root-ignore-certificate.";
 0875            return true;
 876        }
 877
 5878        state.ServiceContentRootIgnoreCertificate = true;
 5879        index += 1;
 5880        error = string.Empty;
 5881        return true;
 882    }
 883
 884    /// <summary>
 885    /// Consumes and validates the content-root custom header option.
 886    /// </summary>
 887    /// <param name="args">Raw command-line arguments.</param>
 888    /// <param name="mode">Current service mode.</param>
 889    /// <param name="state">Mutable service parse state.</param>
 890    /// <param name="index">Current parser index.</param>
 891    /// <param name="error">Error text when parsing fails.</param>
 892    /// <returns>True when the option token is handled.</returns>
 893    private static bool TryConsumeServiceContentRootHeaderOption(string[] args, CommandMode mode, ServiceParseState stat
 894    {
 10895        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 896        {
 0897            error = "Service remove/start/stop/query/info does not accept --content-root-header.";
 0898            return true;
 899        }
 900
 10901        if (!TryConsumeOptionValue(args, ref index, "--content-root-header", out var value, out error))
 902        {
 0903            return true;
 904        }
 905
 10906        state.ServiceContentRootHeaders.Add(value);
 10907        return true;
 908    }
 909
 910    /// <summary>
 911    /// Consumes a single option value and advances the argument index.
 912    /// </summary>
 913    /// <param name="args">Raw command-line arguments.</param>
 914    /// <param name="index">Current parser index.</param>
 915    /// <param name="optionName">Option name for error reporting.</param>
 916    /// <param name="value">Parsed option value.</param>
 917    /// <param name="error">Error text when parsing fails.</param>
 918    /// <returns>True when the option value was consumed.</returns>
 919    private static bool TryConsumeOptionValue(string[] args, ref int index, string optionName, out string value, out str
 920    {
 146921        value = string.Empty;
 146922        error = string.Empty;
 923
 146924        if (index + 1 >= args.Length)
 925        {
 1926            error = $"Missing value for {optionName}.";
 1927            return false;
 928        }
 929
 145930        value = args[index + 1];
 145931        index += 2;
 145932        return true;
 933    }
 934
 935    /// <summary>
 936    /// Consumes the positional script path for service install mode.
 937    /// </summary>
 938    /// <param name="current">Current argument token.</param>
 939    /// <param name="mode">Current service mode.</param>
 940    /// <param name="state">Mutable service parse state.</param>
 941    /// <param name="error">Error text when parsing fails.</param>
 942    /// <returns>True when parsing can continue.</returns>
 943    private static bool TryConsumeServicePositionalScript(string current, CommandMode mode, ServiceParseState state, out
 944    {
 7945        error = string.Empty;
 946
 7947        if (current.StartsWith("--", StringComparison.Ordinal))
 948        {
 7949            error = $"Unknown option: {current}";
 7950            return false;
 951        }
 952
 0953        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 954        {
 0955            error = "Service remove/start/stop/query/info/update does not accept a script path.";
 0956            return false;
 957        }
 958
 0959        if (state.ScriptPathSet)
 960        {
 0961            error = "Service install script arguments must be preceded by --arguments (or --).";
 0962            return false;
 963        }
 964
 0965        state.ScriptPath = current;
 0966        state.ScriptPathSet = true;
 0967        return true;
 968    }
 969
 970    /// <summary>
 971    /// Validates parsed service arguments and applies install defaults.
 972    /// </summary>
 973    /// <param name="mode">Current service mode.</param>
 974    /// <param name="state">Mutable service parse state.</param>
 975    /// <param name="error">Error text when validation fails.</param>
 976    /// <returns>True when validation succeeds.</returns>
 977    private static bool TryValidateServiceParseState(CommandMode mode, ServiceParseState state, out string error)
 978    {
 62979        if (!TryValidateServiceName(mode, state, out error))
 980        {
 1981            return false;
 982        }
 983
 61984        if (state.ServiceJsonOutputRequested && state.ServiceRawOutputRequested)
 985        {
 1986            error = "--json cannot be combined with --raw.";
 1987            return false;
 988        }
 989
 60990        ApplyDefaultServiceInstallScript(mode, state);
 991
 60992        return TryValidateServiceCredentialOptions(mode, state, out error)
 60993            && TryValidateServiceRuntimeOptions(mode, state, out error)
 60994            && TryValidateServiceContentRootDependentOptions(mode, state, out error)
 60995            && TryValidateServiceUpdateOptions(mode, state, out error);
 996    }
 997
 998    /// <summary>
 999    /// Validates service runtime package option combinations.
 1000    /// </summary>
 1001    /// <param name="mode">Current service mode.</param>
 1002    /// <param name="state">Mutable service parse state.</param>
 1003    /// <param name="error">Validation error text.</param>
 1004    /// <returns>True when runtime option usage is valid.</returns>
 1005    private static bool TryValidateServiceRuntimeOptions(CommandMode mode, ServiceParseState state, out string error)
 1006    {
 601007        if (mode != CommandMode.ServiceInstall)
 1008        {
 171009            error = string.Empty;
 171010            return true;
 1011        }
 1012
 431013        if (!string.IsNullOrWhiteSpace(state.ServiceRuntimePackage))
 1014        {
 51015            if (!string.IsNullOrWhiteSpace(state.ServiceRuntimeSource))
 1016            {
 11017                error = "--runtime-package cannot be combined with --runtime-source.";
 11018                return false;
 1019            }
 1020
 41021            if (!string.IsNullOrWhiteSpace(state.ServiceRuntimeVersion))
 1022            {
 01023                error = "--runtime-package cannot be combined with --runtime-version.";
 01024                return false;
 1025            }
 1026
 41027            if (!string.IsNullOrWhiteSpace(state.ServiceRuntimePackageId))
 1028            {
 01029                error = "--runtime-package cannot be combined with --runtime-package-id.";
 01030                return false;
 1031            }
 1032        }
 1033
 421034        error = string.Empty;
 421035        return true;
 1036    }
 1037
 1038    /// <summary>
 1039    /// Validates update-mode specific options.
 1040    /// </summary>
 1041    /// <param name="mode">Current service mode.</param>
 1042    /// <param name="state">Mutable service parse state.</param>
 1043    /// <param name="error">Error text when validation fails.</param>
 1044    /// <returns>True when update-mode options are valid.</returns>
 1045    private static bool TryValidateServiceUpdateOptions(CommandMode mode, ServiceParseState state, out string error)
 1046    {
 521047        if (mode != CommandMode.ServiceUpdate)
 1048        {
 401049            error = string.Empty;
 401050            return true;
 1051        }
 1052
 121053        var hasPackageUpdate = !string.IsNullOrWhiteSpace(state.ServiceContentRoot);
 1054
 121055        if (state.ServiceFailbackRequested)
 1056        {
 81057            if (hasPackageUpdate)
 1058            {
 11059                error = "--failback cannot be combined with --package.";
 11060                return false;
 1061            }
 1062
 71063            if (!string.IsNullOrWhiteSpace(state.ServiceContentRootChecksum)
 71064                || !string.IsNullOrWhiteSpace(state.ServiceContentRootChecksumAlgorithm)
 71065                || !string.IsNullOrWhiteSpace(state.ServiceContentRootBearerToken)
 71066                || state.ServiceContentRootIgnoreCertificate
 71067                || state.ServiceContentRootHeaders.Count > 0)
 1068            {
 51069                error = "--failback does not accept --content-root* update options.";
 51070                return false;
 1071            }
 1072
 21073            if (state.ServiceUseRepositoryKestrun)
 1074            {
 11075                error = "--failback cannot be combined with --kestrun.";
 11076                return false;
 1077            }
 1078
 11079            error = string.Empty;
 11080            return true;
 1081        }
 1082
 41083        error = string.Empty;
 41084        return true;
 1085    }
 1086
 1087    /// <summary>
 1088    /// Validates that the service name option was provided.
 1089    /// </summary>
 1090    /// <param name="mode">Current service mode.</param>
 1091    /// <param name="state">Mutable service parse state.</param>
 1092    /// <param name="error">Error text when validation fails.</param>
 1093    /// <returns>True when the service name is valid.</returns>
 1094    private static bool TryValidateServiceName(CommandMode mode, ServiceParseState state, out string error)
 1095    {
 621096        if (mode != CommandMode.ServiceInstall)
 1097        {
 181098            if (mode == CommandMode.ServiceInfo)
 1099            {
 31100                error = string.Empty;
 31101                return true;
 1102            }
 1103
 151104            if (mode == CommandMode.ServiceUpdate
 151105                && string.IsNullOrWhiteSpace(state.ServiceName)
 151106                && !string.IsNullOrWhiteSpace(state.ServiceContentRoot))
 1107            {
 11108                error = string.Empty;
 11109                return true;
 1110            }
 1111
 141112            if (string.IsNullOrWhiteSpace(state.ServiceName))
 1113            {
 01114                error = "Service name is required. Use --name <value>.";
 01115                return false;
 1116            }
 1117
 141118            error = string.Empty;
 141119            return true;
 1120        }
 1121
 441122        if (state.ServiceNameSet && !string.IsNullOrWhiteSpace(state.ServiceContentRoot))
 1123        {
 11124            error = "--name is no longer supported when installing from --package. Define Name in Service.psd1 inside th
 11125            return false;
 1126        }
 1127
 431128        error = string.Empty;
 431129        return true;
 1130    }
 1131
 1132    /// <summary>
 1133    /// Applies the default script path for service install when script is omitted.
 1134    /// </summary>
 1135    /// <param name="mode">Current service mode.</param>
 1136    /// <param name="state">Mutable service parse state.</param>
 1137    private static void ApplyDefaultServiceInstallScript(CommandMode mode, ServiceParseState state)
 1138    {
 1139        _ = mode;
 1140        _ = state;
 601141    }
 1142
 1143    /// <summary>
 1144    /// Validates credential-related service install options.
 1145    /// </summary>
 1146    /// <param name="mode">Current service mode.</param>
 1147    /// <param name="state">Mutable service parse state.</param>
 1148    /// <param name="error">Error text when validation fails.</param>
 1149    /// <returns>True when credential options are valid.</returns>
 1150    private static bool TryValidateServiceCredentialOptions(CommandMode mode, ServiceParseState state, out string error)
 1151    {
 601152        if (mode != CommandMode.ServiceInstall && (!string.IsNullOrWhiteSpace(state.ServiceUser) || !string.IsNullOrWhit
 1153        {
 01154            error = "Service user credentials are only supported for service install.";
 01155            return false;
 1156        }
 1157
 601158        if (mode == CommandMode.ServiceInstall && string.IsNullOrWhiteSpace(state.ServiceUser) && !string.IsNullOrWhiteS
 1159        {
 01160            error = "--service-password requires --service-user.";
 01161            return false;
 1162        }
 1163
 601164        error = string.Empty;
 601165        return true;
 1166    }
 1167
 1168    /// <summary>
 1169    /// Validates content-root dependent options for service install mode.
 1170    /// </summary>
 1171    /// <param name="mode">Current service mode.</param>
 1172    /// <param name="state">Mutable service parse state.</param>
 1173    /// <param name="error">Error text when validation fails.</param>
 1174    /// <returns>True when content-root dependent options are valid.</returns>
 1175    private static bool TryValidateServiceContentRootDependentOptions(CommandMode mode, ServiceParseState state, out str
 1176    {
 591177        if (mode != CommandMode.ServiceInstall)
 1178        {
 171179            error = string.Empty;
 171180            return true;
 1181        }
 1182
 421183        var hasContentRoot = !string.IsNullOrWhiteSpace(state.ServiceContentRoot);
 1184
 421185        if (!TryValidateServiceInstallScriptContentRootSelection(state, hasContentRoot, out error))
 1186        {
 11187            return false;
 1188        }
 1189
 411190        if (!TryValidateServiceInstallPackageExtension(state, hasContentRoot, out error))
 1191        {
 01192            return false;
 1193        }
 1194
 411195        if (!TryValidateServiceContentRootLinkedOptions(state, hasContentRoot, out error))
 1196        {
 61197            return false;
 1198        }
 1199
 351200        error = string.Empty;
 351201        return true;
 1202    }
 1203
 1204    /// <summary>
 1205    /// Validates service-install script/content-root selection rules.
 1206    /// </summary>
 1207    /// <param name="state">Mutable service parse state.</param>
 1208    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1209    /// <param name="error">Validation error text.</param>
 1210    /// <returns>True when script/content-root selection is valid.</returns>
 1211    private static bool TryValidateServiceInstallScriptContentRootSelection(ServiceParseState state, bool hasContentRoot
 1212    {
 421213        if (state.ScriptPathSet && hasContentRoot)
 1214        {
 11215            error = "An explicit script path is not supported when --package is used. Define EntryPoint in Service.psd1 
 11216            return false;
 1217        }
 1218
 411219        if (!hasContentRoot && !state.ScriptPathSet && !HasServiceRuntimeAcquisitionRequest(state))
 1220        {
 01221            error = "Service install requires --package or at least one runtime acquisition option (--runtime-version, -
 01222            return false;
 1223        }
 1224
 411225        error = string.Empty;
 411226        return true;
 1227    }
 1228
 1229    /// <summary>
 1230    /// Determines whether service-install parsing includes a runtime acquisition request.
 1231    /// </summary>
 1232    /// <param name="state">Mutable service parse state.</param>
 1233    /// <returns>True when runtime acquisition options were supplied.</returns>
 1234    private static bool HasServiceRuntimeAcquisitionRequest(ServiceParseState state)
 41235        => !string.IsNullOrWhiteSpace(state.ServiceRuntimeSource)
 41236            || !string.IsNullOrWhiteSpace(state.ServiceRuntimePackage)
 41237            || !string.IsNullOrWhiteSpace(state.ServiceRuntimeVersion)
 41238            || !string.IsNullOrWhiteSpace(state.ServiceRuntimePackageId);
 1239
 1240    /// <summary>
 1241    /// Validates package extension requirements when --package semantics are in effect.
 1242    /// </summary>
 1243    /// <param name="state">Mutable service parse state.</param>
 1244    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1245    /// <param name="error">Validation error text.</param>
 1246    /// <returns>True when package extension usage is valid.</returns>
 1247    private static bool TryValidateServiceInstallPackageExtension(ServiceParseState state, bool hasContentRoot, out stri
 1248    {
 411249        if (hasContentRoot && state.ServicePackageSet)
 1250        {
 11251            var contentRoot = state.ServiceContentRoot!.Trim();
 1252
 1253            // When --package points to an HTTP/HTTPS URL, rely on the downloader/content-type/signature
 1254            // pipeline to determine the archive type instead of enforcing a local file extension check.
 11255            if (Uri.TryCreate(contentRoot, UriKind.Absolute, out var uri)
 11256                && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
 1257            {
 01258                error = string.Empty;
 01259                return true;
 1260            }
 1261
 1262            // For local paths (and non-HTTP(S) URIs), enforce the extension on the path component only.
 11263            var pathToCheck = contentRoot;
 11264            if (Uri.TryCreate(contentRoot, UriKind.Absolute, out uri)
 11265                && uri.IsAbsoluteUri
 11266                && uri.Scheme == Uri.UriSchemeFile)
 1267            {
 11268                pathToCheck = uri.AbsolutePath;
 1269            }
 1270
 11271            if (!pathToCheck.EndsWith(ServicePackageExtension, StringComparison.OrdinalIgnoreCase))
 1272            {
 01273                error = $"--package must point to a '{ServicePackageExtension}' file.";
 01274                return false;
 1275            }
 1276        }
 1277
 411278        error = string.Empty;
 411279        return true;
 1280    }
 1281
 1282    /// <summary>
 1283    /// Validates options that require --content-root to be supplied.
 1284    /// </summary>
 1285    /// <param name="state">Mutable service parse state.</param>
 1286    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1287    /// <param name="error">Validation error text.</param>
 1288    /// <returns>True when content-root dependent option usage is valid.</returns>
 1289    private static bool TryValidateServiceContentRootLinkedOptions(ServiceParseState state, bool hasContentRoot, out str
 1290    {
 411291        var hasRuntimeSource = !string.IsNullOrWhiteSpace(state.ServiceRuntimeSource);
 411292        var hasLinkedDownloadSource = hasContentRoot || hasRuntimeSource;
 1293
 411294        if (!TryValidateServiceContentRootChecksumOptions(state, hasContentRoot, out error))
 1295        {
 21296            return false;
 1297        }
 1298
 391299        if (!TryValidateContentRootLinkedOption(!string.IsNullOrWhiteSpace(state.ServiceContentRootBearerToken), hasLink
 1300        {
 21301            return false;
 1302        }
 1303
 371304        if (!TryValidateContentRootLinkedOption(state.ServiceContentRootIgnoreCertificate, hasLinkedDownloadSource, "--c
 1305        {
 11306            return false;
 1307        }
 1308
 361309        if (!TryValidateContentRootLinkedOption(state.ServiceContentRootHeaders.Count > 0, hasLinkedDownloadSource, "--c
 1310        {
 11311            return false;
 1312        }
 1313
 351314        error = string.Empty;
 351315        return true;
 1316    }
 1317
 1318    /// <summary>
 1319    /// Validates checksum-specific option dependencies for service content-root installs.
 1320    /// </summary>
 1321    /// <param name="state">Mutable service parse state.</param>
 1322    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1323    /// <param name="error">Validation error text.</param>
 1324    /// <returns>True when checksum-related options are valid.</returns>
 1325    private static bool TryValidateServiceContentRootChecksumOptions(ServiceParseState state, bool hasContentRoot, out s
 1326    {
 411327        var hasChecksum = !string.IsNullOrWhiteSpace(state.ServiceContentRootChecksum);
 411328        var hasChecksumAlgorithm = !string.IsNullOrWhiteSpace(state.ServiceContentRootChecksumAlgorithm);
 411329        if (hasChecksumAlgorithm && !hasChecksum)
 1330        {
 11331            error = "--content-root-checksum-algorithm requires --content-root-checksum.";
 11332            return false;
 1333        }
 1334
 401335        if (!TryValidateContentRootLinkedOption(hasChecksum, hasContentRoot, "--content-root-checksum requires --content
 1336        {
 11337            return false;
 1338        }
 1339
 391340        error = string.Empty;
 391341        return true;
 1342    }
 1343
 1344    /// <summary>
 1345    /// Validates that an option requiring a content root is only supplied with --content-root.
 1346    /// </summary>
 1347    /// <param name="optionIsSet">True when the dependent option was supplied.</param>
 1348    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1349    /// <param name="errorMessage">Validation error to emit when dependency is missing.</param>
 1350    /// <param name="error">Validation error text.</param>
 1351    /// <returns>True when the option dependency is satisfied.</returns>
 1352    private static bool TryValidateContentRootLinkedOption(bool optionIsSet, bool hasContentRoot, string errorMessage, o
 1353    {
 1521354        if (optionIsSet && !hasContentRoot)
 1355        {
 51356            error = errorMessage;
 51357            return false;
 1358        }
 1359
 1471360        error = string.Empty;
 1471361        return true;
 1362    }
 1363
 1364    /// <summary>
 1365    /// Parses internal Windows service registration arguments when present.
 1366    /// </summary>
 1367    /// <param name="args">Raw command-line arguments.</param>
 1368    /// <param name="options">Parsed registration options when successful.</param>
 1369    /// <param name="error">Parse error when registration mode is requested but invalid.</param>
 1370    /// <returns>True when service registration mode is recognized and parsed.</returns>
 1371    private static bool TryParseServiceRegisterArguments(string[] args, out ServiceRegisterOptions? options, out string?
 1372    {
 61373        options = null;
 61374        error = null;
 1375
 61376        if (args.Length == 0 || !string.Equals(args[0], "--service-register", StringComparison.OrdinalIgnoreCase))
 1377        {
 31378            return false;
 1379        }
 1380
 31381        var state = new ServiceRegisterParseState();
 31382        if (!TryParseServiceRegisterOptionLoop(args, state, out error))
 1383        {
 31384            return false;
 1385        }
 1386        // Service registration mode is recognized. Validate required options and build immutable options.
 01387        return TryBuildServiceRegisterOptions(state, out options, out error);
 1388    }
 1389
 1390    /// <summary>
 1391    /// Parses service-register option tokens into a mutable parse state.
 1392    /// </summary>
 1393    /// <param name="args">Raw command-line arguments.</param>
 1394    /// <param name="state">Mutable parse state.</param>
 1395    /// <param name="error">Parse error when an unsupported option or missing option value is encountered.</param>
 1396    /// <returns>True when parsing succeeds.</returns>
 1397    private static bool TryParseServiceRegisterOptionLoop(string[] args, ServiceRegisterParseState state, out string? er
 1398    {
 31399        error = null;
 31400        var index = 1;
 1401
 31402        while (index < args.Length)
 1403        {
 31404            if (!TryConsumeServiceRegisterOption(args, state, ref index, out error))
 1405            {
 31406                return false;
 1407            }
 1408        }
 1409
 01410        return true;
 1411    }
 1412
 1413    /// <summary>
 1414    /// Consumes one internal service-register option from the current parser index.
 1415    /// </summary>
 1416    /// <param name="args">Raw command-line arguments.</param>
 1417    /// <param name="state">Mutable parse state.</param>
 1418    /// <param name="index">Current parser index.</param>
 1419    /// <param name="error">Parse error when an unsupported option or missing option value is encountered.</param>
 1420    /// <returns>True when parsing can continue.</returns>
 1421    private static bool TryConsumeServiceRegisterOption(string[] args, ServiceRegisterParseState state, ref int index, o
 1422    {
 31423        error = null;
 31424        var current = args[index];
 1425
 31426        if (current is "--arguments" or "--")
 1427        {
 01428            state.ScriptArguments = [.. args.Skip(index + 1)];
 01429            index = args.Length;
 01430            return true;
 1431        }
 1432
 31433        if (!TryConsumeServiceRegisterOptionValue(args, ref index, current, out var value))
 1434        {
 31435            error = IsServiceRegisterOptionWithValue(current)
 31436                ? $"Missing value for {current}."
 31437                : $"Unknown service register option: {current}";
 31438            return false;
 1439        }
 1440
 01441        return TryApplyServiceRegisterOptionValue(current, value, state, out error);
 1442    }
 1443
 1444    /// <summary>
 1445    /// Applies a consumed service-register option value to parse state.
 1446    /// </summary>
 1447    /// <param name="option">Service-register option token.</param>
 1448    /// <param name="value">Consumed option value.</param>
 1449    /// <param name="state">Mutable service-register parse state.</param>
 1450    /// <param name="error">Error text when the option token is unsupported.</param>
 1451    /// <returns>True when the option value is applied.</returns>
 1452    private static bool TryApplyServiceRegisterOptionValue(string option, string value, ServiceRegisterParseState state,
 1453    {
 01454        error = null;
 1455
 1456        switch (option)
 1457        {
 1458            case "--name":
 01459                state.ServiceName = value;
 01460                return true;
 1461            case "--service-host-exe":
 01462                state.ServiceHostExecutablePath = value;
 01463                return true;
 1464            case "--runner-exe":
 01465                state.RunnerExecutablePath = value;
 01466                return true;
 1467            case "--exe":
 1468                // Backward compatibility for older elevated registration invocations.
 01469                state.ServiceHostExecutablePath = value;
 01470                return true;
 1471            case "--script":
 01472                state.ScriptPath = value;
 01473                return true;
 1474            case "--kestrun-manifest":
 1475            case "-m":
 01476                state.ModuleManifestPath = value;
 01477                return true;
 1478            case "--service-log-path":
 01479                state.ServiceLogPath = value;
 01480                return true;
 1481            case "--service-user":
 01482                state.ServiceUser = value;
 01483                return true;
 1484            case "--service-password":
 01485                state.ServicePassword = value;
 01486                return true;
 1487            default:
 01488                error = $"Unknown service register option: {option}";
 01489                return false;
 1490        }
 1491    }
 1492
 1493    /// <summary>
 1494    /// Determines whether a token is a supported service-register option that requires a value.
 1495    /// </summary>
 1496    /// <param name="option">Option token to evaluate.</param>
 1497    /// <returns>True when the token is a recognized option that requires a value.</returns>
 1498    private static bool IsServiceRegisterOptionWithValue(string option)
 61499        => option is "--name"
 61500            or "--service-host-exe"
 61501            or "--runner-exe"
 61502            or "--exe"
 61503            or "--script"
 61504            or "--kestrun-manifest"
 61505            or "-m"
 61506            or "--service-log-path"
 61507            or "--service-user"
 61508            or "--service-password";
 1509
 1510    /// <summary>
 1511    /// Attempts to consume a single service-register option value.
 1512    /// </summary>
 1513    /// <param name="args">Raw command-line arguments.</param>
 1514    /// <param name="index">Current parser index.</param>
 1515    /// <param name="option">Current option token.</param>
 1516    /// <param name="value">Consumed option value when available.</param>
 1517    /// <returns>True when an option value pair is consumed.</returns>
 1518    private static bool TryConsumeServiceRegisterOptionValue(string[] args, ref int index, string option, out string val
 1519    {
 31520        value = string.Empty;
 1521
 31522        if (!IsServiceRegisterOptionWithValue(option))
 1523        {
 11524            return false;
 1525        }
 1526
 21527        if (index + 1 >= args.Length)
 1528        {
 21529            return false;
 1530        }
 1531
 01532        value = args[index + 1];
 01533        index += 2;
 01534        return true;
 1535    }
 1536
 1537    /// <summary>
 1538    /// Validates parsed service-register values and creates immutable registration options.
 1539    /// </summary>
 1540    /// <param name="state">Completed parse state.</param>
 1541    /// <param name="options">Parsed registration options.</param>
 1542    /// <param name="error">Validation error text.</param>
 1543    /// <returns>True when validation succeeds.</returns>
 1544    private static bool TryBuildServiceRegisterOptions(
 1545        ServiceRegisterParseState state,
 1546        out ServiceRegisterOptions? options,
 1547        out string? error)
 1548    {
 01549        options = null;
 01550        error = null;
 1551
 01552        if (string.IsNullOrWhiteSpace(state.ServiceName))
 1553        {
 01554            error = "Missing --name for internal service registration mode.";
 01555            return false;
 1556        }
 1557
 01558        if (string.IsNullOrWhiteSpace(state.ServiceHostExecutablePath))
 1559        {
 01560            error = "Missing --service-host-exe for internal service registration mode.";
 01561            return false;
 1562        }
 1563
 01564        if (string.IsNullOrWhiteSpace(state.RunnerExecutablePath))
 1565        {
 01566            state.RunnerExecutablePath = state.ServiceHostExecutablePath;
 1567        }
 1568
 01569        if (string.IsNullOrWhiteSpace(state.ScriptPath))
 1570        {
 01571            error = "Missing --script for internal service registration mode.";
 01572            return false;
 1573        }
 1574
 01575        if (string.IsNullOrWhiteSpace(state.ModuleManifestPath))
 1576        {
 01577            error = "Missing --kestrun-manifest for internal service registration mode.";
 01578            return false;
 1579        }
 1580
 01581        options = new ServiceRegisterOptions(
 01582            state.ServiceName,
 01583            state.ServiceHostExecutablePath,
 01584            state.RunnerExecutablePath,
 01585            state.ScriptPath,
 01586            state.ModuleManifestPath,
 01587            state.ScriptArguments,
 01588            state.ServiceLogPath,
 01589            state.ServiceUser,
 01590            state.ServicePassword);
 01591        return true;
 1592    }
 1593}

/home/runner/work/Kestrun/Kestrun/src/CSharp/Kestrun.Tool/Program.ServiceRuntimePackages.cs

#LineLine coverage
 1using System.IO.Compression;
 2using System.Net.Http.Headers;
 3using System.Security.Cryptography;
 4using System.Text;
 5using System.Text.Json;
 6
 7using System.Xml.Linq;
 8
 9namespace Kestrun.Tool;
 10
 11internal static partial class Program
 12{
 13    /// <summary>
 14    /// Resolves the service runtime payload required for service install or run execution.
 15    /// </summary>
 16    /// <param name="runtimeSource">Optional runtime package source override.</param>
 17    /// <param name="runtimePackage">Optional explicit runtime package path.</param>
 18    /// <param name="runtimeVersion">Optional runtime package version override.</param>
 19    /// <param name="runtimePackageId">Optional runtime package id override.</param>
 20    /// <param name="runtimeCache">Optional runtime cache directory override.</param>
 21    /// <param name="requireModules">True when the resolved payload must also contain bundled modules.</param>
 22    /// <param name="allowToolDistributionFallback">True to allow falling back to the staged runtime bundled with Kestru
 23    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 24    /// <param name="error">Resolution error details.</param>
 25    /// <returns>True when a usable runtime payload is available.</returns>
 26    private static bool TryResolveServiceRuntimePackage(
 27        string? runtimeSource,
 28        string? runtimePackage,
 29        string? runtimeVersion,
 30        string? runtimePackageId,
 31        string? runtimeCache,
 32        string? bearerToken,
 33        string[] customHeaders,
 34        bool ignoreCertificate,
 35        bool requireModules,
 36        bool allowToolDistributionFallback,
 37        out ResolvedServiceRuntimePackage runtimePackageLayout,
 38        out string error)
 39    {
 1840        runtimePackageLayout = default!;
 41
 1842        var hasExplicitRuntimeOverride = HasExplicitRuntimeOverride(
 1843            runtimeSource,
 1844            runtimePackage,
 1845            runtimeVersion,
 1846            runtimePackageId,
 1847            runtimeCache);
 48
 1849        if (!TryGetServiceRuntimeRid(out var rid, out error))
 50        {
 051            return false;
 52        }
 53
 1854        var effectivePackageId = GetEffectiveRuntimePackageId(rid, runtimePackageId);
 1855        var requestedVersion = NormalizeRuntimeVersion(runtimeVersion);
 56
 1857        if (!TryResolveRuntimeCacheRoot(runtimeCache, out var cacheRoot, out error))
 58        {
 159            return allowToolDistributionFallback && !hasExplicitRuntimeOverride && TryResolveServiceRuntimePackageFromTo
 60        }
 61
 62        // When an explicit runtime package path is provided, attempt to resolve it directly without involving source or
 63        // since the intent is clear and this avoids unexpected results from misconfigured source/cache arguments.
 64        // If this fails, do not proceed with source/cache resolution to avoid further unexpected results and instead re
 1765        if (!string.IsNullOrWhiteSpace(runtimePackage))
 66        {
 467            return TryResolveServiceRuntimePackageFromExplicitPackage(
 468                rid,
 469                effectivePackageId,
 470                requestedVersion,
 471                runtimePackage,
 472                cacheRoot,
 473                requireModules,
 474                out runtimePackageLayout,
 475                out error);
 76        }
 77
 78        // When a direct runtime source is provided, attempt to resolve it as a local package path or a direct URL befor
 79        // This allows users to specify direct overrides without needing to also configure the cache or sources, and avo
 1380        if (TryResolveServiceRuntimePackageFromDirectSource(
 1381                rid,
 1382                effectivePackageId,
 1383                requestedVersion,
 1384                runtimeSource,
 1385                cacheRoot,
 1386                bearerToken,
 1387                customHeaders,
 1388                ignoreCertificate,
 1389                requireModules,
 1390                out runtimePackageLayout,
 1391                out error,
 1392                out var runtimeSourceWasDirect))
 93        {
 594            return true;
 95        }
 96
 97        // If the runtime source was treated as a direct package source but failed to resolve, do not proceed with sourc
 98        // (e.g. a URL missing the package file name or a directory missing the expected package file).
 899        if (runtimeSourceWasDirect)
 100        {
 2101            return false;
 102        }
 103
 104        // When no direct source overrides are provided or the direct source fails to resolve, proceed with normal resol
 6105        return TryResolveServiceRuntimePackageFromCacheOrSources(
 6106            rid,
 6107            effectivePackageId,
 6108            requestedVersion,
 6109            runtimeSource,
 6110            cacheRoot,
 6111            bearerToken,
 6112            customHeaders,
 6113            ignoreCertificate,
 6114            requireModules,
 6115            allowToolDistributionFallback,
 6116            hasExplicitRuntimeOverride,
 6117            out runtimePackageLayout,
 6118            out error);
 119    }
 120
 121    /// <summary>
 122    /// Determines whether any runtime-resolution override was provided explicitly.
 123    /// </summary>
 124    /// <param name="runtimeSource">Optional runtime package source override.</param>
 125    /// <param name="runtimePackage">Optional explicit runtime package path.</param>
 126    /// <param name="runtimeVersion">Optional runtime package version override.</param>
 127    /// <param name="runtimePackageId">Optional runtime package id override.</param>
 128    /// <param name="runtimeCache">Optional runtime cache directory override.</param>
 129    /// <returns>True when at least one runtime override argument was supplied.</returns>
 130    private static bool HasExplicitRuntimeOverride(
 131        string? runtimeSource,
 132        string? runtimePackage,
 133        string? runtimeVersion,
 134        string? runtimePackageId,
 135        string? runtimeCache)
 18136        => !string.IsNullOrWhiteSpace(runtimeSource)
 18137            || !string.IsNullOrWhiteSpace(runtimePackage)
 18138            || !string.IsNullOrWhiteSpace(runtimeVersion)
 18139            || !string.IsNullOrWhiteSpace(runtimePackageId)
 18140            || !string.IsNullOrWhiteSpace(runtimeCache);
 141
 142    /// <summary>
 143    /// Resolves the effective runtime package id for the current RID.
 144    /// </summary>
 145    /// <param name="rid">Resolved runtime identifier.</param>
 146    /// <param name="runtimePackageId">Optional runtime package id override.</param>
 147    /// <returns>Effective package id.</returns>
 148    private static string GetEffectiveRuntimePackageId(string rid, string? runtimePackageId)
 18149        => string.IsNullOrWhiteSpace(runtimePackageId)
 18150            ? $"{RuntimePackageIdPrefix}.{rid}"
 18151            : runtimePackageId.Trim();
 152
 153    /// <summary>
 154    /// Normalizes an optional runtime package version override.
 155    /// </summary>
 156    /// <param name="runtimeVersion">Optional runtime package version override.</param>
 157    /// <returns>Trimmed runtime version, or null when unspecified.</returns>
 158    private static string? NormalizeRuntimeVersion(string? runtimeVersion)
 18159        => string.IsNullOrWhiteSpace(runtimeVersion) ? null : runtimeVersion.Trim();
 160
 161    /// <summary>
 162    /// Resolves a runtime payload using extracted cache entries, configured sources, and optional tool fallback.
 163    /// </summary>
 164    /// <param name="rid">Runtime identifier.</param>
 165    /// <param name="effectivePackageId">Effective package id.</param>
 166    /// <param name="requestedVersion">Optional requested package version override.</param>
 167    /// <param name="runtimeSource">Optional runtime source override.</param>
 168    /// <param name="cacheRoot">Resolved runtime cache root.</param>
 169    /// <param name="bearerToken">Optional bearer token for HTTP sources.</param>
 170    /// <param name="customHeaders">Optional custom headers for HTTP sources.</param>
 171    /// <param name="ignoreCertificate">True to allow insecure HTTPS for HTTP sources.</param>
 172    /// <param name="requireModules">True when bundled modules are required.</param>
 173    /// <param name="allowToolDistributionFallback">True to allow staged tool fallback when package acquisition fails.</
 174    /// <param name="hasExplicitRuntimeOverride">True when explicit runtime-resolution overrides were provided.</param>
 175    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 176    /// <param name="error">Resolution error details.</param>
 177    /// <returns>True when a usable runtime payload is available.</returns>
 178    private static bool TryResolveServiceRuntimePackageFromCacheOrSources(
 179        string rid,
 180        string effectivePackageId,
 181        string? requestedVersion,
 182        string? runtimeSource,
 183        string cacheRoot,
 184        string? bearerToken,
 185        string[] customHeaders,
 186        bool ignoreCertificate,
 187        bool requireModules,
 188        bool allowToolDistributionFallback,
 189        bool hasExplicitRuntimeOverride,
 190        out ResolvedServiceRuntimePackage runtimePackageLayout,
 191        out string error)
 192    {
 6193        runtimePackageLayout = default!;
 194
 6195        var effectiveVersion = string.IsNullOrWhiteSpace(requestedVersion)
 6196            ? GetDefaultServiceRuntimePackageVersion()
 6197            : requestedVersion;
 198
 6199        if (string.IsNullOrWhiteSpace(effectiveVersion))
 200        {
 0201            error = "Unable to determine the default service runtime package version.";
 0202            return false;
 203        }
 204
 6205        var sourceCandidates = GetServiceRuntimeSourceCandidates(runtimeSource);
 6206        var errors = new List<string>();
 207
 6208        if (TryResolveServiceRuntimePackageFromExpandedCache(
 6209                rid,
 6210                effectivePackageId,
 6211                effectiveVersion,
 6212                cacheRoot,
 6213                requireModules,
 6214                out runtimePackageLayout,
 6215                out error))
 216        {
 0217            return true;
 218        }
 219
 6220        if (!string.IsNullOrWhiteSpace(error))
 221        {
 0222            errors.Add(error);
 223        }
 224
 22225        foreach (var sourceCandidate in sourceCandidates)
 226        {
 6227            if (TryResolveServiceRuntimePackageFromSource(
 6228                    rid,
 6229                    effectivePackageId,
 6230                    effectiveVersion,
 6231                    sourceCandidate,
 6232                    cacheRoot,
 6233                    bearerToken,
 6234                    customHeaders,
 6235                    ignoreCertificate,
 6236                    requireModules,
 6237                    out runtimePackageLayout,
 6238                    out error))
 239            {
 2240                return true;
 241            }
 242
 4243            if (!string.IsNullOrWhiteSpace(error))
 244            {
 4245                errors.Add(error);
 246            }
 247        }
 248
 4249        if (allowToolDistributionFallback && !hasExplicitRuntimeOverride && TryResolveServiceRuntimePackageFromToolDistr
 250        {
 3251            if (errors.Count > 0)
 252            {
 3253                Console.Error.WriteLine("Warning: unable to acquire the service runtime package from cache/NuGet; fallin
 3254                Console.Error.WriteLine($"  {errors[0]}");
 255            }
 256
 3257            return true;
 258        }
 259
 1260        error = BuildRuntimePackageNotFoundError(effectivePackageId, effectiveVersion, rid, allowToolDistributionFallbac
 1261        return false;
 2262    }
 263
 264    /// <summary>
 265    /// Builds a user-facing error message when no usable runtime package could be located.
 266    /// </summary>
 267    /// <param name="packageId">Requested package id.</param>
 268    /// <param name="packageVersion">Requested package version.</param>
 269    /// <param name="rid">Runtime identifier.</param>
 270    /// <param name="allowToolDistributionFallback">Whether the tool-distribution fallback was attempted.</param>
 271    /// <param name="errors">Accumulated acquisition errors.</param>
 272    /// <returns>Formatted error message.</returns>
 273    private static string BuildRuntimePackageNotFoundError(
 274        string packageId,
 275        string packageVersion,
 276        string rid,
 277        bool allowToolDistributionFallback,
 278        IReadOnlyList<string> errors)
 279    {
 1280        if (errors.Count == 0)
 281        {
 0282            var message = $"Unable to locate service runtime package '{packageId}' version '{packageVersion}' for RID '{
 0283            if (!allowToolDistributionFallback)
 284            {
 0285                message += $" Use --runtime-package <path> to specify a local package file, or --runtime-version to targ
 286            }
 287
 0288            return message;
 289        }
 290
 1291        var baseError = string.Join(Environment.NewLine, errors.Distinct(StringComparer.Ordinal));
 1292        if (!allowToolDistributionFallback)
 293        {
 1294            baseError += $"{Environment.NewLine}Use --runtime-package <path> to specify a local '{packageId}' package fi
 295        }
 296
 1297        return baseError;
 298    }
 299
 300    /// <summary>
 301    /// Resolves the currently staged runtime payload from the tool distribution when present.
 302    /// </summary>
 303    /// <param name="rid">Runtime identifier.</param>
 304    /// <param name="requireModules">True when bundled modules are required.</param>
 305    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 306    /// <returns>True when the staged runtime payload is present.</returns>
 307    private static bool TryResolveServiceRuntimePackageFromToolDistribution(
 308        string rid,
 309        bool requireModules,
 310        out ResolvedServiceRuntimePackage runtimePackageLayout)
 311    {
 3312        runtimePackageLayout = default!;
 3313        if (!TryResolveDedicatedServiceHostExecutableFromToolDistribution(out var serviceHostExecutablePath))
 314        {
 0315            return false;
 316        }
 317
 3318        var modulesPath = string.Empty;
 3319        if (requireModules && !TryResolvePowerShellModulesPayloadFromToolDistribution(out modulesPath))
 320        {
 0321            return false;
 322        }
 323
 3324        var extractionRoot = Path.GetDirectoryName(serviceHostExecutablePath) ?? string.Empty;
 3325        runtimePackageLayout = new ResolvedServiceRuntimePackage(
 3326            rid,
 3327            $"{RuntimePackageIdPrefix}.{rid}",
 3328            GetDefaultServiceRuntimePackageVersion(),
 3329            string.Empty,
 3330            extractionRoot,
 3331            serviceHostExecutablePath,
 3332            modulesPath);
 3333        return true;
 334    }
 335
 336    /// <summary>
 337    /// Resolves a runtime payload from an explicit local runtime package path.
 338    /// </summary>
 339    /// <param name="rid">Runtime identifier.</param>
 340    /// <param name="expectedPackageId">Expected package id.</param>
 341    /// <param name="expectedVersion">Expected package version.</param>
 342    /// <param name="runtimePackage">Explicit package path.</param>
 343    /// <param name="cacheRoot">Resolved cache root path.</param>
 344    /// <param name="requireModules">True when bundled modules are required.</param>
 345    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 346    /// <param name="error">Resolution error details.</param>
 347    /// <returns>True when the explicit package path resolves successfully.</returns>
 348    private static bool TryResolveServiceRuntimePackageFromExplicitPackage(
 349        string rid,
 350        string expectedPackageId,
 351        string? expectedVersion,
 352        string runtimePackage,
 353        string cacheRoot,
 354        bool requireModules,
 355        out ResolvedServiceRuntimePackage runtimePackageLayout,
 356        out string error)
 357    {
 11358        runtimePackageLayout = default!;
 359
 11360        if (!TryLoadAndValidateExplicitRuntimePackage(
 11361                rid,
 11362                expectedPackageId,
 11363                expectedVersion,
 11364                runtimePackage,
 11365                out var packagePath,
 11366                out var packageBytes,
 11367                out var packageId,
 11368                out var packageVersion,
 11369                out error))
 370        {
 1371            return false;
 372        }
 373
 10374        var effectivePackagePath = TryPrepareExplicitRuntimePackageCacheEntry(
 10375            packagePath,
 10376            cacheRoot,
 10377            packageId,
 10378            packageVersion,
 10379            out _);
 380
 10381        var packageHash = Convert.ToHexString(SHA256.HashData(packageBytes))[..12].ToLowerInvariant();
 10382        var extractionRoot = Path.Combine(cacheRoot, "expanded", SanitizePathToken(packageId), $"{packageVersion}-{packa
 10383        return TryPrepareResolvedServiceRuntimePackage(
 10384            rid,
 10385            packageId,
 10386            packageVersion,
 10387            effectivePackagePath,
 10388            packageBytes,
 10389            extractionRoot,
 10390            requireModules,
 10391            out runtimePackageLayout,
 10392            out error);
 393    }
 394
 395    /// <summary>
 396    /// Loads an explicit runtime package and validates its identity metadata against expected values.
 397    /// </summary>
 398    /// <param name="rid">Runtime identifier.</param>
 399    /// <param name="expectedPackageId">Expected package id.</param>
 400    /// <param name="expectedVersion">Optional expected package version.</param>
 401    /// <param name="runtimePackage">Explicit runtime package path.</param>
 402    /// <param name="packagePath">Resolved absolute runtime package path.</param>
 403    /// <param name="packageBytes">Runtime package bytes.</param>
 404    /// <param name="packageId">Resolved package id.</param>
 405    /// <param name="packageVersion">Resolved package version.</param>
 406    /// <param name="error">Validation error details.</param>
 407    /// <returns>True when the explicit runtime package path exists and matches expected identity metadata.</returns>
 408    private static bool TryLoadAndValidateExplicitRuntimePackage(
 409        string rid,
 410        string expectedPackageId,
 411        string? expectedVersion,
 412        string runtimePackage,
 413        out string packagePath,
 414        out byte[] packageBytes,
 415        out string packageId,
 416        out string packageVersion,
 417        out string error)
 418    {
 11419        packageBytes = [];
 11420        packageId = string.Empty;
 11421        packageVersion = string.Empty;
 422
 11423        if (!TryResolveExplicitRuntimePackagePath(runtimePackage, expectedPackageId, expectedVersion, out packagePath, o
 424        {
 1425            return false;
 426        }
 427
 10428        packageBytes = File.ReadAllBytes(packagePath);
 10429        if (!TryReadPackageIdentity(packageBytes, out packageId, out packageVersion))
 430        {
 0431            error = $"Runtime package '{packagePath}' does not contain a readable nuspec id/version.";
 0432            return false;
 433        }
 434
 10435        if (!string.Equals(packageId, expectedPackageId, StringComparison.OrdinalIgnoreCase))
 436        {
 0437            error = $"Runtime package '{packagePath}' has package id '{packageId}', but '{expectedPackageId}' was expect
 0438            return false;
 439        }
 440
 10441        if (!string.IsNullOrWhiteSpace(expectedVersion)
 10442            && !string.Equals(packageVersion, expectedVersion, StringComparison.OrdinalIgnoreCase))
 443        {
 0444            error = $"Runtime package '{packagePath}' has version '{packageVersion}', but '{expectedVersion}' was expect
 0445            return false;
 446        }
 447
 10448        return true;
 449    }
 450
 451    /// <summary>
 452    /// Resolves an explicit runtime package argument to a concrete package file path.
 453    /// </summary>
 454    /// <param name="runtimePackage">Explicit runtime package file or directory path.</param>
 455    /// <param name="expectedPackageId">Expected runtime package id.</param>
 456    /// <param name="expectedVersion">Optional expected runtime package version.</param>
 457    /// <param name="packagePath">Resolved runtime package file path.</param>
 458    /// <param name="error">Resolution error details.</param>
 459    /// <returns>True when the explicit runtime package argument resolves to a readable package file.</returns>
 460    private static bool TryResolveExplicitRuntimePackagePath(
 461        string runtimePackage,
 462        string expectedPackageId,
 463        string? expectedVersion,
 464        out string packagePath,
 465        out string error)
 466    {
 11467        packagePath = Path.GetFullPath(runtimePackage);
 11468        error = string.Empty;
 469
 11470        if (Directory.Exists(packagePath))
 471        {
 2472            return TryResolveExplicitRuntimePackageFromDirectory(packagePath, expectedPackageId, expectedVersion, out pa
 473        }
 474
 9475        if (!File.Exists(packagePath))
 476        {
 0477            error = runtimePackage.EndsWith(RuntimePackageExtension, StringComparison.OrdinalIgnoreCase)
 0478                ? $"Runtime package file was not found: {packagePath}"
 0479                : $"Runtime package path was not found: {packagePath}";
 0480            return false;
 481        }
 482
 9483        if (!packagePath.EndsWith(RuntimePackageExtension, StringComparison.OrdinalIgnoreCase))
 484        {
 0485            error = $"Runtime package file '{packagePath}' must point to a '{RuntimePackageExtension}' package.";
 0486            return false;
 487        }
 488
 9489        return true;
 490    }
 491
 492    /// <summary>
 493    /// Resolves the expected runtime package file from a directory passed to <c>--runtime-package</c>.
 494    /// </summary>
 495    /// <param name="runtimePackageDirectory">Directory containing runtime packages.</param>
 496    /// <param name="expectedPackageId">Expected runtime package id.</param>
 497    /// <param name="expectedVersion">Optional expected runtime package version.</param>
 498    /// <param name="packagePath">Resolved runtime package file path.</param>
 499    /// <param name="error">Resolution error details.</param>
 500    /// <returns>True when the directory contains the expected runtime package file.</returns>
 501    private static bool TryResolveExplicitRuntimePackageFromDirectory(
 502        string runtimePackageDirectory,
 503        string expectedPackageId,
 504        string? expectedVersion,
 505        out string packagePath,
 506        out string error)
 507    {
 2508        var effectiveVersion = string.IsNullOrWhiteSpace(expectedVersion)
 2509            ? GetDefaultServiceRuntimePackageVersion()
 2510            : expectedVersion;
 2511        var expectedFileName = $"{expectedPackageId}.{effectiveVersion}{RuntimePackageExtension}";
 512
 2513        packagePath = Directory
 2514            .EnumerateFiles(runtimePackageDirectory, $"*{RuntimePackageExtension}", SearchOption.TopDirectoryOnly)
 1515            .FirstOrDefault(path => string.Equals(Path.GetFileName(path), expectedFileName, StringComparison.OrdinalIgno
 2516            ?? string.Empty;
 517
 2518        if (!string.IsNullOrWhiteSpace(packagePath))
 519        {
 1520            error = string.Empty;
 1521            return true;
 522        }
 523
 1524        error = $"Runtime package '{expectedFileName}' was not found in folder '{runtimePackageDirectory}'.";
 1525        return false;
 526    }
 527
 528    /// <summary>
 529    /// Creates or reuses the structured cache entry for an explicit runtime package.
 530    /// </summary>
 531    /// <param name="packagePath">Resolved source package path.</param>
 532    /// <param name="cacheRoot">Resolved runtime cache root.</param>
 533    /// <param name="packageId">Runtime package id.</param>
 534    /// <param name="packageVersion">Runtime package version.</param>
 535    /// <param name="cachedPackagePath">Structured cache package path.</param>
 536    /// <returns>Path to use for subsequent package resolution (cached path when present; otherwise original package pat
 537    private static string TryPrepareExplicitRuntimePackageCacheEntry(
 538        string packagePath,
 539        string cacheRoot,
 540        string packageId,
 541        string packageVersion,
 542        out string cachedPackagePath)
 543    {
 10544        var packageFileName = $"{packageId}.{packageVersion}{RuntimePackageExtension}";
 10545        cachedPackagePath = Path.Combine(cacheRoot, "packages", SanitizePathToken(packageId), packageVersion, packageFil
 10546        var cachedPackageDirectory = Path.GetDirectoryName(cachedPackagePath);
 10547        if (!string.IsNullOrWhiteSpace(cachedPackageDirectory))
 548        {
 10549            _ = Directory.CreateDirectory(cachedPackageDirectory);
 550        }
 551
 10552        if (!File.Exists(cachedPackagePath))
 553        {
 554            try
 555            {
 8556                File.Copy(packagePath, cachedPackagePath, overwrite: false);
 8557            }
 0558            catch (IOException)
 559            {
 560                // Another process may have created the cache entry concurrently.
 0561            }
 562        }
 563
 10564        return File.Exists(cachedPackagePath) ? cachedPackagePath : packagePath;
 565    }
 566
 567    /// <summary>
 568    /// Resolves a runtime payload from a source candidate and cache root.
 569    /// </summary>
 570    /// <param name="rid">Runtime identifier.</param>
 571    /// <param name="packageId">Package id.</param>
 572    /// <param name="packageVersion">Package version.</param>
 573    /// <param name="sourceCandidate">Source candidate (directory or URL).</param>
 574    /// <param name="cacheRoot">Cache root path.</param>
 575    /// <param name="requireModules">True when bundled modules are required.</param>
 576    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 577    /// <param name="error">Resolution error details.</param>
 578    /// <returns>True when the source yields a usable runtime package.</returns>
 579    private static bool TryResolveServiceRuntimePackageFromSource(
 580        string rid,
 581        string packageId,
 582        string packageVersion,
 583        string sourceCandidate,
 584        string cacheRoot,
 585        string? bearerToken,
 586        string[] customHeaders,
 587        bool ignoreCertificate,
 588        bool requireModules,
 589        out ResolvedServiceRuntimePackage runtimePackageLayout,
 590        out string error)
 591    {
 6592        runtimePackageLayout = default!;
 593
 6594        if (!TryEnsureCachedRuntimePackageForSource(
 6595                sourceCandidate,
 6596                packageId,
 6597                packageVersion,
 6598                cacheRoot,
 6599                bearerToken,
 6600                customHeaders,
 6601                ignoreCertificate,
 6602                out var cachedPackagePath,
 6603                out error))
 604        {
 4605            return false;
 606        }
 607
 2608        if (!TryLoadAndValidateCachedRuntimePackage(
 2609                cachedPackagePath,
 2610                packageId,
 2611                packageVersion,
 2612                out var packageBytes,
 2613                out error))
 614        {
 0615            return false;
 616        }
 617
 2618        var extractionRoot = Path.Combine(cacheRoot, "expanded", SanitizePathToken(packageId), packageVersion);
 2619        return TryPrepareResolvedServiceRuntimePackage(
 2620            rid,
 2621            packageId,
 2622            packageVersion,
 2623            cachedPackagePath,
 2624            packageBytes,
 2625            extractionRoot,
 2626            requireModules,
 2627            out runtimePackageLayout,
 2628            out error);
 629    }
 630
 631    /// <summary>
 632    /// Ensures a source-provided runtime package exists in the structured cache location.
 633    /// </summary>
 634    /// <param name="sourceCandidate">Source candidate (directory or URL).</param>
 635    /// <param name="packageId">Package id.</param>
 636    /// <param name="packageVersion">Package version.</param>
 637    /// <param name="cacheRoot">Cache root path.</param>
 638    /// <param name="bearerToken">Optional bearer token for HTTP sources.</param>
 639    /// <param name="customHeaders">Optional custom headers for HTTP sources.</param>
 640    /// <param name="ignoreCertificate">True to allow insecure HTTPS for HTTP sources.</param>
 641    /// <param name="cachedPackagePath">Resolved structured cache package path.</param>
 642    /// <param name="error">Acquisition error details.</param>
 643    /// <returns>True when the requested package is present in the structured cache path.</returns>
 644    private static bool TryEnsureCachedRuntimePackageForSource(
 645        string sourceCandidate,
 646        string packageId,
 647        string packageVersion,
 648        string cacheRoot,
 649        string? bearerToken,
 650        string[] customHeaders,
 651        bool ignoreCertificate,
 652        out string cachedPackagePath,
 653        out string error)
 654    {
 6655        error = string.Empty;
 6656        var packageFileName = $"{packageId}.{packageVersion}{RuntimePackageExtension}";
 6657        cachedPackagePath = Path.Combine(cacheRoot, "packages", SanitizePathToken(packageId), packageVersion, packageFil
 6658        if (File.Exists(cachedPackagePath))
 659        {
 0660            return true;
 661        }
 662
 6663        var packageDirectory = Path.GetDirectoryName(cachedPackagePath);
 6664        if (!string.IsNullOrWhiteSpace(packageDirectory))
 665        {
 6666            _ = Directory.CreateDirectory(packageDirectory);
 667        }
 668
 669        // Before downloading, check whether the package was placed flat in the cache root
 670        // (e.g. copied there manually or left by an earlier tool version). If so, migrate it
 671        // to the structured location so subsequent lookups use the normal path.
 6672        var flatPackagePath = Path.Combine(cacheRoot, packageFileName);
 6673        if (File.Exists(flatPackagePath))
 674        {
 675            try
 676            {
 0677                File.Copy(flatPackagePath, cachedPackagePath, overwrite: false);
 0678            }
 0679            catch (IOException)
 680            {
 681                // Another process may have created it concurrently; continue with whatever is there.
 0682            }
 683        }
 684
 6685        if (File.Exists(cachedPackagePath))
 686        {
 0687            return true;
 688        }
 689
 6690        if (TryAcquireRuntimePackageFromSource(sourceCandidate, packageId, packageVersion, cachedPackagePath, bearerToke
 691        {
 2692            return true;
 693        }
 694
 4695        TryCleanupEmptyRuntimePackageDirectory(packageDirectory, cacheRoot);
 4696        return false;
 697    }
 698
 699    /// <summary>
 700    /// Loads and validates identity metadata for a cached runtime package.
 701    /// </summary>
 702    /// <param name="cachedPackagePath">Structured cache package path.</param>
 703    /// <param name="packageId">Expected package id.</param>
 704    /// <param name="packageVersion">Expected package version.</param>
 705    /// <param name="packageBytes">Cached package bytes.</param>
 706    /// <param name="error">Validation error details.</param>
 707    /// <returns>True when the cached package exists and matches the requested id/version.</returns>
 708    private static bool TryLoadAndValidateCachedRuntimePackage(
 709        string cachedPackagePath,
 710        string packageId,
 711        string packageVersion,
 712        out byte[] packageBytes,
 713        out string error)
 714    {
 2715        packageBytes = File.ReadAllBytes(cachedPackagePath);
 2716        if (!TryReadPackageIdentity(packageBytes, out var resolvedPackageId, out var resolvedPackageVersion))
 717        {
 0718            error = $"Runtime package '{cachedPackagePath}' does not contain a readable nuspec id/version.";
 0719            return false;
 720        }
 721
 2722        if (!string.Equals(resolvedPackageId, packageId, StringComparison.OrdinalIgnoreCase)
 2723            || !string.Equals(resolvedPackageVersion, packageVersion, StringComparison.OrdinalIgnoreCase))
 724        {
 0725            error = $"Runtime package '{cachedPackagePath}' resolved to '{resolvedPackageId}' version '{resolvedPackageV
 0726            return false;
 727        }
 728
 2729        error = string.Empty;
 2730        return true;
 731    }
 732
 733    /// <summary>
 734    /// Attempts to resolve a runtime payload from a previously extracted cache entry.
 735    /// </summary>
 736    /// <param name="rid">Runtime identifier.</param>
 737    /// <param name="packageId">Package id.</param>
 738    /// <param name="packageVersion">Package version.</param>
 739    /// <param name="cacheRoot">Cache root path.</param>
 740    /// <param name="requireModules">True when bundled modules are required.</param>
 741    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 742    /// <param name="error">Resolution error details.</param>
 743    /// <returns>True when a compatible extracted runtime payload is found.</returns>
 744    private static bool TryResolveServiceRuntimePackageFromExpandedCache(
 745        string rid,
 746        string packageId,
 747        string packageVersion,
 748        string cacheRoot,
 749        bool requireModules,
 750        out ResolvedServiceRuntimePackage runtimePackageLayout,
 751        out string error)
 752    {
 6753        runtimePackageLayout = default!;
 6754        error = string.Empty;
 755
 6756        var expandedPackageRoot = Path.Combine(cacheRoot, "expanded", SanitizePathToken(packageId));
 6757        if (!Directory.Exists(expandedPackageRoot))
 758        {
 6759            return false;
 760        }
 761
 0762        var versionCandidates = new List<string>();
 0763        var exactVersionRoot = Path.Combine(expandedPackageRoot, packageVersion);
 0764        if (Directory.Exists(exactVersionRoot))
 765        {
 0766            versionCandidates.Add(exactVersionRoot);
 767        }
 768
 0769        versionCandidates.AddRange(
 0770            Directory.EnumerateDirectories(expandedPackageRoot, $"{packageVersion}-*", SearchOption.TopDirectoryOnly)
 0771                .OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase));
 772
 0773        foreach (var extractionRoot in versionCandidates)
 774        {
 0775            if (TryResolveExtractedServiceRuntimePackageLayout(
 0776                    rid,
 0777                    packageId,
 0778                    packageVersion,
 0779                    packagePath: string.Empty,
 0780                    extractionRoot,
 0781                    requireModules,
 0782                    out runtimePackageLayout,
 0783                    out error))
 784            {
 0785                return true;
 786            }
 787        }
 788
 0789        return false;
 0790    }
 791
 792    /// <summary>
 793    /// Ensures an extracted runtime package is present and resolves its host/modules layout.
 794    /// </summary>
 795    /// <param name="rid">Runtime identifier.</param>
 796    /// <param name="packageId">Package id.</param>
 797    /// <param name="packageVersion">Package version.</param>
 798    /// <param name="packagePath">Package file path.</param>
 799    /// <param name="packageBytes">Package bytes.</param>
 800    /// <param name="extractionRoot">Extraction root path.</param>
 801    /// <param name="requireModules">True when bundled modules are required.</param>
 802    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 803    /// <param name="error">Resolution error details.</param>
 804    /// <returns>True when extraction and layout resolution succeed.</returns>
 805    private static bool TryPrepareResolvedServiceRuntimePackage(
 806        string rid,
 807        string packageId,
 808        string packageVersion,
 809        string packagePath,
 810        byte[] packageBytes,
 811        string extractionRoot,
 812        bool requireModules,
 813        out ResolvedServiceRuntimePackage runtimePackageLayout,
 814        out string error)
 815    {
 12816        runtimePackageLayout = default!;
 12817        if (!TryEnsureExtractedServiceRuntimePackage(packageBytes, extractionRoot, out error))
 818        {
 2819            return false;
 820        }
 821        // The extracted layout is expected to contain the service host executable at the root of the package, with bund
 10822        return TryResolveExtractedServiceRuntimePackageLayout(
 10823                rid,
 10824                packageId,
 10825                packageVersion,
 10826                packagePath,
 10827                extractionRoot,
 10828                requireModules,
 10829                out runtimePackageLayout,
 10830                out error);
 831    }
 832
 833    /// <summary>
 834    /// Resolves the runtime package source candidates in priority order.
 835    /// </summary>
 836    /// <param name="runtimeSource">Optional explicit source override.</param>
 837    /// <returns>Ordered source candidates.</returns>
 838    private static IReadOnlyList<string> GetServiceRuntimeSourceCandidates(string? runtimeSource) =>
 6839    !string.IsNullOrWhiteSpace(runtimeSource) ? [runtimeSource.Trim()] : [DefaultNuGetServiceIndexUrl];
 840
 841    /// <summary>
 842    /// Resolves the effective cache root used for runtime packages.
 843    /// </summary>
 844    /// <param name="runtimeCache">Optional cache root override.</param>
 845    /// <param name="cacheRoot">Resolved cache root.</param>
 846    /// <param name="error">Resolution error details.</param>
 847    /// <returns>True when the cache root is usable.</returns>
 848    private static bool TryResolveRuntimeCacheRoot(string? runtimeCache, out string cacheRoot, out string error)
 849    {
 18850        cacheRoot = string.Empty;
 18851        error = string.Empty;
 852
 18853        var candidateDisplay = string.IsNullOrWhiteSpace(runtimeCache)
 18854            ? GetDefaultRuntimeCacheRoot()
 18855            : runtimeCache;
 856
 857        try
 858        {
 18859            var candidate = string.IsNullOrWhiteSpace(runtimeCache)
 18860                ? candidateDisplay
 18861                : Path.GetFullPath(runtimeCache);
 862
 17863            _ = Directory.CreateDirectory(candidate);
 17864            cacheRoot = candidate;
 17865            return true;
 866        }
 1867        catch (Exception ex)
 868        {
 1869            error = $"Unable to use runtime cache directory '{candidateDisplay}': {ex.Message}";
 1870            return false;
 871        }
 18872    }
 873
 874    /// <summary>
 875    /// Returns the default runtime cache root for the current platform.
 876    /// </summary>
 877    /// <returns>Default cache root path.</returns>
 878    private static string GetDefaultRuntimeCacheRoot()
 879    {
 880        // On Windows, use the machine-wide common application data directory so that the cache is shared
 881        // across all users without requiring per-user downloads.
 4882        if (OperatingSystem.IsWindows())
 883        {
 0884            return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData), "Kestrun", "
 885        }
 886
 887        // On Unix-based platforms, system-wide cache directories (/Library/Caches, /var/cache) are typically
 888        // not writable by non-root users, so fall back to the per-user cache directory — consistent with the
 889        // approach used for service deployment roots on the same platforms.
 4890        if (OperatingSystem.IsMacOS())
 891        {
 0892            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 0893            if (!string.IsNullOrWhiteSpace(userProfile))
 894            {
 0895                return Path.Combine(userProfile, "Library", "Caches", "Kestrun", "RuntimePackages");
 896            }
 897        }
 898
 899        // On Linux, honour XDG_CACHE_HOME when set; otherwise fall back to ~/.cache, which is the XDG default.
 4900        var xdgCacheHome = Environment.GetEnvironmentVariable("XDG_CACHE_HOME");
 4901        if (!string.IsNullOrWhiteSpace(xdgCacheHome))
 902        {
 0903            return Path.Combine(xdgCacheHome, "kestrun", "runtime-packages");
 904        }
 905
 4906        var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 4907        if (!string.IsNullOrWhiteSpace(home))
 908        {
 4909            return Path.Combine(home, ".cache", "kestrun", "runtime-packages");
 910        }
 911
 912        // Ultimate fallback: /tmp is always writable and avoids a hard failure on unusual setups.
 0913        return Path.Combine(Path.GetTempPath(), "kestrun", "runtime-packages");
 914    }
 915
 916    /// <summary>
 917    /// Resolves a runtime payload from a direct source package path or URL.
 918    /// </summary>
 919    /// <param name="rid">Runtime identifier.</param>
 920    /// <param name="expectedPackageId">Expected package id.</param>
 921    /// <param name="expectedVersion">Optional expected package version.</param>
 922    /// <param name="runtimeSource">Explicit runtime source candidate.</param>
 923    /// <param name="cacheRoot">Resolved cache root path.</param>
 924    /// <param name="bearerToken">Optional bearer token for HTTP sources.</param>
 925    /// <param name="customHeaders">Optional custom headers for HTTP sources.</param>
 926    /// <param name="ignoreCertificate">True to allow insecure HTTPS for HTTP sources.</param>
 927    /// <param name="requireModules">True when bundled modules are required.</param>
 928    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 929    /// <param name="error">Resolution error details.</param>
 930    /// <param name="runtimeSourceWasDirect">True when the runtime source was treated as a direct package source.</param
 931    /// <returns>True when a direct runtime source resolves successfully.</returns>
 932    private static bool TryResolveServiceRuntimePackageFromDirectSource(
 933        string rid,
 934        string expectedPackageId,
 935        string? expectedVersion,
 936        string? runtimeSource,
 937        string cacheRoot,
 938        string? bearerToken,
 939        string[] customHeaders,
 940        bool ignoreCertificate,
 941        bool requireModules,
 942        out ResolvedServiceRuntimePackage runtimePackageLayout,
 943        out string error,
 944        out bool runtimeSourceWasDirect)
 945    {
 13946        runtimePackageLayout = default!;
 13947        error = string.Empty;
 13948        runtimeSourceWasDirect = false;
 949
 13950        if (string.IsNullOrWhiteSpace(runtimeSource))
 951        {
 3952            return false;
 953        }
 954
 10955        var trimmedSource = runtimeSource.Trim();
 10956        if (TryResolveDirectRuntimeSourceLocalPackagePath(trimmedSource, out var localPackagePath, out runtimeSourceWasD
 957        {
 7958            return TryResolveServiceRuntimePackageFromExplicitPackage(
 7959                rid,
 7960                expectedPackageId,
 7961                expectedVersion,
 7962                localPackagePath,
 7963                cacheRoot,
 7964                requireModules,
 7965                out runtimePackageLayout,
 7966                out error);
 967        }
 968
 3969        if (runtimeSourceWasDirect)
 970        {
 0971            return false;
 972        }
 973
 3974        if (TryParseServiceContentRootHttpUri(trimmedSource, out var packageUri) && IsDirectRuntimePackageUri(packageUri
 975        {
 0976            runtimeSourceWasDirect = true;
 0977            var downloadPath = GetDirectRuntimePackageDownloadPath(cacheRoot, expectedPackageId, expectedVersion, packag
 0978            var downloadDirectory = Path.GetDirectoryName(downloadPath);
 0979            if (!string.IsNullOrWhiteSpace(downloadDirectory))
 980            {
 0981                _ = Directory.CreateDirectory(downloadDirectory);
 982            }
 983
 0984            return (File.Exists(downloadPath)
 0985                || TryDownloadRuntimePackageFile(packageUri, downloadPath, bearerToken, customHeaders, ignoreCertificate
 0986                rid,
 0987                expectedPackageId,
 0988                expectedVersion,
 0989                downloadPath,
 0990                cacheRoot,
 0991                requireModules,
 0992                out runtimePackageLayout,
 0993                out error);
 994        }
 995
 3996        return false;
 997    }
 998
 999    /// <summary>
 1000    /// Returns the default runtime package version based on the running tool version.
 1001    /// </summary>
 1002    /// <returns>Normalized package version string.</returns>
 1003    private static string GetDefaultServiceRuntimePackageVersion()
 1004    {
 101005        var version = GetProductVersion();
 101006        var plusIndex = version.IndexOf('+');
 101007        return plusIndex >= 0 ? version[..plusIndex] : version;
 1008    }
 1009
 1010    /// <summary>
 1011    /// Downloads or copies a runtime package from the provided source into the cache path.
 1012    /// </summary>
 1013    /// <param name="sourceCandidate">Source candidate.</param>
 1014    /// <param name="packageId">Package id.</param>
 1015    /// <param name="packageVersion">Package version.</param>
 1016    /// <param name="destinationPath">Cached destination path.</param>
 1017    /// <param name="error">Acquisition error details.</param>
 1018    /// <returns>True when the package is acquired successfully.</returns>
 1019    private static bool TryAcquireRuntimePackageFromSource(
 1020        string sourceCandidate,
 1021        string packageId,
 1022        string packageVersion,
 1023        string destinationPath,
 1024        string? bearerToken,
 1025        string[] customHeaders,
 1026        bool ignoreCertificate,
 1027        out string error)
 1028    {
 61029        if (TryResolveDirectRuntimeSourceLocalPackagePath(sourceCandidate, out var localPackagePath, out var runtimeSour
 1030        {
 01031            File.Copy(localPackagePath, destinationPath, overwrite: true);
 01032            return true;
 1033        }
 1034
 61035        if (runtimeSourceWasDirect)
 1036        {
 01037            return false;
 1038        }
 1039
 61040        if (Directory.Exists(sourceCandidate))
 1041        {
 31042            return TryCopyRuntimePackageFromLocalSource(sourceCandidate, packageId, packageVersion, destinationPath, out
 1043        }
 1044
 31045        if (Uri.TryCreate(sourceCandidate, UriKind.Absolute, out var sourceUri)
 31046            && (sourceUri.Scheme == Uri.UriSchemeHttp || sourceUri.Scheme == Uri.UriSchemeHttps))
 1047        {
 31048            return IsDirectRuntimePackageUri(sourceUri)
 31049                ? TryDownloadRuntimePackageFile(sourceUri, destinationPath, bearerToken, customHeaders, ignoreCertificat
 31050                : TryDownloadRuntimePackageFromSource(sourceUri, packageId, packageVersion, destinationPath, bearerToken
 1051        }
 1052
 01053        error = $"Runtime source '{sourceCandidate}' is neither an existing directory, a readable '{RuntimePackageExtens
 01054        return false;
 1055    }
 1056
 1057    /// <summary>
 1058    /// Resolves a direct local runtime package source from a file path or file URI.
 1059    /// </summary>
 1060    /// <param name="sourceCandidate">Source candidate to inspect.</param>
 1061    /// <param name="packagePath">Resolved package path when the source is a direct local package.</param>
 1062    /// <param name="runtimeSourceWasDirect">True when the source was interpreted as a direct package reference.</param>
 1063    /// <param name="error">Resolution error details.</param>
 1064    /// <returns>True when a direct package path was resolved successfully.</returns>
 1065    private static bool TryResolveDirectRuntimeSourceLocalPackagePath(
 1066        string sourceCandidate,
 1067        out string packagePath,
 1068        out bool runtimeSourceWasDirect,
 1069        out string error)
 1070    {
 161071        packagePath = string.Empty;
 161072        error = string.Empty;
 161073        runtimeSourceWasDirect = false;
 1074
 161075        if (string.IsNullOrWhiteSpace(sourceCandidate))
 1076        {
 01077            return false;
 1078        }
 1079
 161080        if (Directory.Exists(sourceCandidate))
 1081        {
 61082            return false;
 1083        }
 1084
 101085        if (TryResolveDirectRuntimeSourceExistingFilePath(sourceCandidate, out packagePath, out runtimeSourceWasDirect, 
 1086        {
 71087            return true;
 1088        }
 1089
 31090        if (runtimeSourceWasDirect)
 1091        {
 01092            return false;
 1093        }
 1094
 31095        if (TryResolveDirectRuntimeSourceFileUri(sourceCandidate, out packagePath, out runtimeSourceWasDirect, out error
 1096        {
 01097            return true;
 1098        }
 1099
 31100        if (runtimeSourceWasDirect)
 1101        {
 01102            return false;
 1103        }
 1104
 31105        if (sourceCandidate.EndsWith(RuntimePackageExtension, StringComparison.OrdinalIgnoreCase)
 31106            && !Directory.Exists(sourceCandidate)
 31107            && !TryParseServiceContentRootHttpUri(sourceCandidate, out _))
 1108        {
 01109            runtimeSourceWasDirect = true;
 01110            error = $"Runtime package file was not found: {Path.GetFullPath(sourceCandidate)}";
 01111            return false;
 1112        }
 1113
 31114        return false;
 1115    }
 1116
 1117    /// <summary>
 1118    /// Resolves an existing local file path as a direct runtime package source.
 1119    /// </summary>
 1120    /// <param name="sourceCandidate">Source candidate to inspect.</param>
 1121    /// <param name="packagePath">Resolved package path when successful.</param>
 1122    /// <param name="runtimeSourceWasDirect">True when the source is a direct file reference.</param>
 1123    /// <param name="error">Resolution error details.</param>
 1124    /// <returns>True when the candidate resolves to an existing local package file.</returns>
 1125    private static bool TryResolveDirectRuntimeSourceExistingFilePath(
 1126        string sourceCandidate,
 1127        out string packagePath,
 1128        out bool runtimeSourceWasDirect,
 1129        out string error)
 1130    {
 101131        packagePath = string.Empty;
 101132        error = string.Empty;
 101133        runtimeSourceWasDirect = false;
 1134
 101135        if (!File.Exists(sourceCandidate))
 1136        {
 31137            return false;
 1138        }
 1139
 71140        runtimeSourceWasDirect = true;
 71141        if (!sourceCandidate.EndsWith(RuntimePackageExtension, StringComparison.OrdinalIgnoreCase))
 1142        {
 01143            error = $"Runtime source file '{sourceCandidate}' must point to a '{RuntimePackageExtension}' package.";
 01144            return false;
 1145        }
 1146
 71147        packagePath = Path.GetFullPath(sourceCandidate);
 71148        return true;
 1149    }
 1150
 1151    /// <summary>
 1152    /// Resolves a file URI as a direct runtime package source.
 1153    /// </summary>
 1154    /// <param name="sourceCandidate">Source candidate to inspect.</param>
 1155    /// <param name="packagePath">Resolved package path when successful.</param>
 1156    /// <param name="runtimeSourceWasDirect">True when the source was interpreted as a direct file URI reference.</param
 1157    /// <param name="error">Resolution error details.</param>
 1158    /// <returns>True when the source resolves to a valid local package file URI.</returns>
 1159    private static bool TryResolveDirectRuntimeSourceFileUri(
 1160        string sourceCandidate,
 1161        out string packagePath,
 1162        out bool runtimeSourceWasDirect,
 1163        out string error)
 1164    {
 31165        packagePath = string.Empty;
 31166        error = string.Empty;
 31167        runtimeSourceWasDirect = false;
 1168
 31169        if (!Uri.TryCreate(sourceCandidate, UriKind.Absolute, out var sourceUri) || !sourceUri.IsFile)
 1170        {
 31171            return false;
 1172        }
 1173
 01174        runtimeSourceWasDirect = true;
 01175        var localPath = sourceUri.LocalPath;
 01176        if (Directory.Exists(localPath))
 1177        {
 01178            runtimeSourceWasDirect = false;
 01179            return false;
 1180        }
 1181
 01182        if (!localPath.EndsWith(RuntimePackageExtension, StringComparison.OrdinalIgnoreCase))
 1183        {
 01184            error = $"Runtime source file '{sourceCandidate}' must point to a '{RuntimePackageExtension}' package.";
 01185            return false;
 1186        }
 1187
 01188        if (!File.Exists(localPath))
 1189        {
 01190            error = $"Runtime package file was not found: {localPath}";
 01191            return false;
 1192        }
 1193
 01194        packagePath = Path.GetFullPath(localPath);
 01195        return true;
 1196    }
 1197
 1198    /// <summary>
 1199    /// Determines whether a runtime source URI points directly at a package file.
 1200    /// </summary>
 1201    /// <param name="sourceUri">Source URI.</param>
 1202    /// <returns>True when the URI points to a .nupkg payload.</returns>
 1203    private static bool IsDirectRuntimePackageUri(Uri sourceUri)
 31204        => sourceUri.AbsolutePath.EndsWith(RuntimePackageExtension, StringComparison.OrdinalIgnoreCase);
 1205
 1206    /// <summary>
 1207    /// Resolves the cache path used for direct runtime package downloads.
 1208    /// </summary>
 1209    /// <param name="cacheRoot">Resolved cache root path.</param>
 1210    /// <param name="packageId">Expected package id.</param>
 1211    /// <param name="packageVersion">Optional expected package version.</param>
 1212    /// <param name="sourceUri">Source package URI.</param>
 1213    /// <returns>Cached download path.</returns>
 1214    private static string GetDirectRuntimePackageDownloadPath(string cacheRoot, string packageId, string? packageVersion
 1215    {
 01216        var sourceHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(sourceUri.AbsoluteUri)))[..12].ToLow
 01217        var sourceFileName = Path.GetFileName(Uri.UnescapeDataString(sourceUri.AbsolutePath));
 01218        var safeFileStem = SanitizePathToken(string.IsNullOrWhiteSpace(sourceFileName) ? packageId : Path.GetFileNameWit
 01219        var fileName = string.IsNullOrWhiteSpace(safeFileStem)
 01220            ? $"{SanitizePathToken(packageId)}{RuntimePackageExtension}"
 01221            : $"{safeFileStem}{RuntimePackageExtension}";
 01222        var versionFolder = string.IsNullOrWhiteSpace(packageVersion) ? "floating" : SanitizePathToken(packageVersion);
 01223        return Path.Combine(cacheRoot, "downloads", SanitizePathToken(packageId), versionFolder, sourceHash, fileName);
 1224    }
 1225
 1226    /// <summary>
 1227    /// Copies a runtime package from a local source directory.
 1228    /// </summary>
 1229    /// <param name="sourceDirectory">Source directory path.</param>
 1230    /// <param name="packageId">Package id.</param>
 1231    /// <param name="packageVersion">Package version.</param>
 1232    /// <param name="destinationPath">Cached destination path.</param>
 1233    /// <param name="error">Copy error details.</param>
 1234    /// <returns>True when the package is copied successfully.</returns>
 1235    private static bool TryCopyRuntimePackageFromLocalSource(
 1236        string sourceDirectory,
 1237        string packageId,
 1238        string packageVersion,
 1239        string destinationPath,
 1240        out string error)
 1241    {
 31242        error = string.Empty;
 31243        var expectedFileName = $"{packageId}.{packageVersion}{RuntimePackageExtension}";
 31244        var sourcePath = Directory
 31245            .EnumerateFiles(sourceDirectory, $"*{RuntimePackageExtension}", SearchOption.TopDirectoryOnly)
 51246            .FirstOrDefault(path => string.Equals(Path.GetFileName(path), expectedFileName, StringComparison.OrdinalIgno
 1247
 31248        if (sourcePath is null)
 1249        {
 11250            error = $"Runtime package '{expectedFileName}' was not found in local source '{sourceDirectory}'.";
 11251            return false;
 1252        }
 1253
 21254        File.Copy(sourcePath, destinationPath, overwrite: true);
 21255        return true;
 1256    }
 1257
 1258    /// <summary>
 1259    /// Removes empty runtime package cache directories left behind by a failed acquisition attempt.
 1260    /// </summary>
 1261    /// <param name="packageDirectory">Package-version directory created for the attempted acquisition.</param>
 1262    /// <param name="cacheRoot">Resolved runtime cache root.</param>
 1263    private static void TryCleanupEmptyRuntimePackageDirectory(string? packageDirectory, string cacheRoot)
 1264    {
 51265        if (string.IsNullOrWhiteSpace(packageDirectory))
 1266        {
 01267            return;
 1268        }
 1269
 1270        try
 1271        {
 51272            var packagesRoot = Path.Combine(cacheRoot, "packages");
 51273            var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal
 51274            var currentDirectory = Path.GetFullPath(packageDirectory);
 41275            var normalizedPackagesRoot = Path.GetFullPath(packagesRoot);
 1276
 121277            while (currentDirectory.StartsWith(normalizedPackagesRoot, comparison) && Directory.Exists(currentDirectory)
 1278            {
 121279                if (Directory.EnumerateFileSystemEntries(currentDirectory).Any())
 1280                {
 1281                    break;
 1282                }
 1283
 121284                Directory.Delete(currentDirectory, recursive: false);
 121285                if (string.Equals(currentDirectory, normalizedPackagesRoot, comparison))
 1286                {
 1287                    break;
 1288                }
 1289
 81290                currentDirectory = Path.GetDirectoryName(currentDirectory) ?? string.Empty;
 1291            }
 41292        }
 11293        catch
 1294        {
 1295            // Best-effort cleanup only. Runtime package resolution should continue even when cache cleanup fails.
 11296        }
 51297    }
 1298
 1299    /// <summary>
 1300    /// Downloads a runtime package from a NuGet v3 source or flat-container base URL.
 1301    /// </summary>
 1302    /// <param name="sourceUri">Source URI.</param>
 1303    /// <param name="packageId">Package id.</param>
 1304    /// <param name="packageVersion">Package version.</param>
 1305    /// <param name="destinationPath">Cached destination path.</param>
 1306    /// <param name="error">Download error details.</param>
 1307    /// <returns>True when the package is downloaded successfully.</returns>
 1308    private static bool TryDownloadRuntimePackageFromSource(
 1309        Uri sourceUri,
 1310        string packageId,
 1311        string packageVersion,
 1312        string destinationPath,
 1313        string? bearerToken,
 1314        string[] customHeaders,
 1315        bool ignoreCertificate,
 1316        out string error)
 1317    {
 31318        if (!TryResolvePackageBaseAddress(sourceUri, bearerToken, customHeaders, ignoreCertificate, out var packageBaseA
 1319        {
 01320            return false;
 1321        }
 1322
 31323        var lowerPackageId = packageId.ToLowerInvariant();
 31324        var lowerPackageVersion = packageVersion.ToLowerInvariant();
 31325        var downloadUri = new Uri(packageBaseAddress, $"{lowerPackageId}/{lowerPackageVersion}/{lowerPackageId}.{lowerPa
 1326
 31327        return TryDownloadRuntimePackageFile(downloadUri, destinationPath, bearerToken, customHeaders, ignoreCertificate
 1328    }
 1329
 1330    /// <summary>
 1331    /// Downloads a runtime package file to a local destination path.
 1332    /// </summary>
 1333    /// <param name="packageUri">Package URI.</param>
 1334    /// <param name="destinationPath">Destination file path.</param>
 1335    /// <param name="bearerToken">Optional bearer token for HTTP requests.</param>
 1336    /// <param name="customHeaders">Optional custom headers for HTTP requests.</param>
 1337    /// <param name="ignoreCertificate">True to allow insecure HTTPS downloads.</param>
 1338    /// <param name="error">Download error details.</param>
 1339    /// <returns>True when the file is downloaded successfully.</returns>
 1340    private static bool TryDownloadRuntimePackageFile(
 1341        Uri packageUri,
 1342        string destinationPath,
 1343        string? bearerToken,
 1344        string[] customHeaders,
 1345        bool ignoreCertificate,
 1346        out string error)
 1347    {
 31348        error = string.Empty;
 31349        if (ignoreCertificate && !packageUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
 1350        {
 01351            error = "--content-root-ignore-certificate is only valid for HTTPS URLs.";
 01352            return false;
 1353        }
 1354
 1355        try
 1356        {
 31357            using var request = new HttpRequestMessage(HttpMethod.Get, packageUri);
 31358            if (!string.IsNullOrWhiteSpace(bearerToken))
 1359            {
 01360                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
 1361            }
 1362
 31363            if (!TryApplyServiceContentRootCustomHeaders(request, customHeaders, out error))
 1364            {
 01365                return false;
 1366            }
 1367
 31368            if (!ignoreCertificate)
 1369            {
 31370                using var response = ServiceContentRootHttpClient.Send(request, HttpCompletionOption.ResponseHeadersRead
 31371                if (!response.IsSuccessStatusCode)
 1372                {
 31373                    error = $"Failed to download runtime package from '{packageUri}'. HTTP {(int)response.StatusCode} {r
 31374                    return false;
 1375                }
 1376
 01377                using var sourceStream = response.Content.ReadAsStream();
 01378                using var destinationStream = File.Create(destinationPath);
 01379                sourceStream.CopyTo(destinationStream);
 01380                return true;
 1381            }
 1382
 01383            using var insecureHandler = new HttpClientHandler
 01384            {
 01385                ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidat
 01386            };
 01387            using var insecureClient = new HttpClient(insecureHandler)
 01388            {
 01389                Timeout = TimeSpan.FromMinutes(5),
 01390            };
 01391            insecureClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0"));
 01392            using var insecureResponse = insecureClient.Send(request, HttpCompletionOption.ResponseHeadersRead);
 01393            if (!insecureResponse.IsSuccessStatusCode)
 1394            {
 01395                error = $"Failed to download runtime package from '{packageUri}'. HTTP {(int)insecureResponse.StatusCode
 01396                return false;
 1397            }
 1398
 01399            using var insecureSourceStream = insecureResponse.Content.ReadAsStream();
 01400            using var insecureDestinationStream = File.Create(destinationPath);
 01401            insecureSourceStream.CopyTo(insecureDestinationStream);
 01402            return true;
 1403        }
 01404        catch (Exception ex)
 1405        {
 01406            error = $"Failed to download runtime package from '{packageUri}': {ex.Message}";
 01407            return false;
 1408        }
 31409    }
 1410
 1411    /// <summary>
 1412    /// Resolves a package base address from a NuGet service index or direct flat-container base URI.
 1413    /// </summary>
 1414    /// <param name="sourceUri">Source URI.</param>
 1415    /// <param name="packageBaseAddress">Resolved package base address.</param>
 1416    /// <param name="error">Resolution error details.</param>
 1417    /// <returns>True when the package base address is resolved.</returns>
 1418    private static bool TryResolvePackageBaseAddress(
 1419        Uri sourceUri,
 1420        string? bearerToken,
 1421        string[] customHeaders,
 1422        bool ignoreCertificate,
 1423        out Uri packageBaseAddress,
 1424        out string error)
 1425    {
 31426        if (!TryResolveFlatContainerBaseAddress(sourceUri, out packageBaseAddress))
 1427        {
 01428            error = string.Empty;
 01429            return true;
 1430        }
 1431
 1432        try
 1433        {
 31434            if (!TryGetRuntimeSourceJsonDocument(sourceUri, bearerToken, customHeaders, ignoreCertificate, out var docum
 1435            {
 01436                packageBaseAddress = null!;
 01437                return false;
 1438            }
 1439
 31440            using (document)
 1441            {
 31442                return TryResolvePackageBaseAddressFromServiceIndexDocument(sourceUri, document, out packageBaseAddress,
 1443            }
 1444        }
 01445        catch (Exception ex)
 1446        {
 01447            packageBaseAddress = null!;
 01448            error = $"Failed to read NuGet service index '{sourceUri}': {ex.Message}";
 01449            return false;
 1450        }
 31451    }
 1452
 1453    /// <summary>
 1454    /// Determines whether a source URI can be treated as a direct flat-container package base address.
 1455    /// </summary>
 1456    /// <param name="sourceUri">Source URI.</param>
 1457    /// <param name="packageBaseAddress">Resolved package base address when the source URI is a flat-container base addr
 1458    /// <returns>True when the source URI is a flat-container base address; otherwise, false to indicate the source shou
 1459    private static bool TryResolveFlatContainerBaseAddress(Uri sourceUri, out Uri packageBaseAddress)
 1460    {
 31461        packageBaseAddress = null!;
 31462        if (sourceUri.AbsolutePath.EndsWith("index.json", StringComparison.OrdinalIgnoreCase))
 1463        {
 31464            return true;
 1465        }
 1466
 01467        var absoluteUri = sourceUri.AbsoluteUri.EndsWith('/') ? sourceUri.AbsoluteUri : $"{sourceUri.AbsoluteUri}/";
 01468        packageBaseAddress = new Uri(absoluteUri, UriKind.Absolute);
 01469        return false;
 1470    }
 1471
 1472    /// <summary>
 1473    /// Resolves the package base address from a NuGet service index document.
 1474    /// </summary>
 1475    /// <param name="sourceUri">Source URI for error context.</param>
 1476    /// <param name="document">Parsed service index document.</param>
 1477    /// <param name="packageBaseAddress">Resolved package base address.</param>
 1478    /// <param name="error">Resolution error details.</param>
 1479    /// <returns>True when a valid PackageBaseAddress resource is found.</returns>
 1480    private static bool TryResolvePackageBaseAddressFromServiceIndexDocument(
 1481        Uri sourceUri,
 1482        JsonDocument document,
 1483        out Uri packageBaseAddress,
 1484        out string error)
 1485    {
 31486        packageBaseAddress = null!;
 1487
 31488        if (!TryGetRuntimeSourceResourcesArray(sourceUri, document, out var resources, out error))
 1489        {
 01490            return false;
 1491        }
 1492
 31493        if (TryResolvePackageBaseAddressFromResources(resources, out packageBaseAddress))
 1494        {
 31495            error = string.Empty;
 31496            return true;
 1497        }
 1498
 01499        error = $"NuGet service index '{sourceUri}' does not advertise a PackageBaseAddress resource.";
 01500        return false;
 1501    }
 1502
 1503    /// <summary>
 1504    /// Reads and validates the resources array from a NuGet service index document.
 1505    /// </summary>
 1506    /// <param name="sourceUri">Source URI for error context.</param>
 1507    /// <param name="document">Parsed service index document.</param>
 1508    /// <param name="resources">Resolved resources array element.</param>
 1509    /// <param name="error">Validation error details.</param>
 1510    /// <returns>True when a resources array is present.</returns>
 1511    private static bool TryGetRuntimeSourceResourcesArray(
 1512        Uri sourceUri,
 1513        JsonDocument document,
 1514        out JsonElement resources,
 1515        out string error)
 1516    {
 31517        error = string.Empty;
 31518        if (document.RootElement.TryGetProperty("resources", out resources) && resources.ValueKind == JsonValueKind.Arra
 1519        {
 31520            return true;
 1521        }
 1522
 01523        error = $"NuGet service index '{sourceUri}' does not contain a resources array.";
 01524        return false;
 1525    }
 1526
 1527    /// <summary>
 1528    /// Resolves the first PackageBaseAddress resource URI from a service index resources array.
 1529    /// </summary>
 1530    /// <param name="resources">Service index resources array.</param>
 1531    /// <param name="packageBaseAddress">Resolved package base address.</param>
 1532    /// <returns>True when a valid PackageBaseAddress URI is found.</returns>
 1533    private static bool TryResolvePackageBaseAddressFromResources(JsonElement resources, out Uri packageBaseAddress)
 1534    {
 31535        packageBaseAddress = null!;
 511536        foreach (var resource in resources.EnumerateArray())
 1537        {
 241538            if (!TryResolvePackageBaseAddressFromResource(resource, out packageBaseAddress))
 1539            {
 1540                continue;
 1541            }
 1542
 31543            return true;
 1544        }
 1545
 01546        return false;
 31547    }
 1548
 1549    /// <summary>
 1550    /// Resolves a PackageBaseAddress URI from a single service index resource entry.
 1551    /// </summary>
 1552    /// <param name="resource">Service index resource entry.</param>
 1553    /// <param name="packageBaseAddress">Resolved package base address.</param>
 1554    /// <returns>True when the resource represents PackageBaseAddress and contains a valid URI.</returns>
 1555    private static bool TryResolvePackageBaseAddressFromResource(JsonElement resource, out Uri packageBaseAddress)
 1556    {
 241557        packageBaseAddress = null!;
 241558        if (!resource.TryGetProperty("@id", out var idProperty) || !resource.TryGetProperty("@type", out var typePropert
 1559        {
 01560            return false;
 1561        }
 1562
 241563        if (!IsPackageBaseAddressResourceType(typeProperty))
 1564        {
 211565            return false;
 1566        }
 1567
 31568        var packageBase = idProperty.GetString();
 31569        if (string.IsNullOrWhiteSpace(packageBase))
 1570        {
 01571            return false;
 1572        }
 1573
 31574        packageBaseAddress = new Uri(packageBase.EndsWith('/') ? packageBase : $"{packageBase}/", UriKind.Absolute);
 31575        return true;
 1576    }
 1577
 1578    /// <summary>
 1579    /// Determines whether a service index resource type entry represents PackageBaseAddress.
 1580    /// </summary>
 1581    /// <param name="typeProperty">Resource type property value.</param>
 1582    /// <returns>True when the resource type indicates PackageBaseAddress.</returns>
 1583    private static bool IsPackageBaseAddressResourceType(JsonElement typeProperty)
 241584        => typeProperty.ValueKind switch
 241585        {
 241586            JsonValueKind.String => typeProperty.GetString()?.Contains("PackageBaseAddress", StringComparison.OrdinalIgn
 01587            JsonValueKind.Array => typeProperty.EnumerateArray().Any(static entry =>
 01588                entry.ValueKind == JsonValueKind.String
 01589                && entry.GetString()!.Contains("PackageBaseAddress", StringComparison.OrdinalIgnoreCase)),
 01590            _ => false,
 241591        };
 1592
 1593    /// <summary>
 1594    /// Downloads and parses a JSON document from a runtime source URL.
 1595    /// </summary>
 1596    /// <param name="sourceUri">Source URI.</param>
 1597    /// <param name="bearerToken">Optional bearer token.</param>
 1598    /// <param name="customHeaders">Optional custom headers.</param>
 1599    /// <param name="ignoreCertificate">True to allow insecure HTTPS.</param>
 1600    /// <param name="document">Parsed JSON document.</param>
 1601    /// <param name="error">Download or parse error details.</param>
 1602    /// <returns>True when the JSON document is available.</returns>
 1603    private static bool TryGetRuntimeSourceJsonDocument(
 1604        Uri sourceUri,
 1605        string? bearerToken,
 1606        string[] customHeaders,
 1607        bool ignoreCertificate,
 1608        out JsonDocument document,
 1609        out string error)
 1610    {
 31611        document = null!;
 31612        error = string.Empty;
 1613
 31614        if (ignoreCertificate && !sourceUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
 1615        {
 01616            error = "--content-root-ignore-certificate is only valid for HTTPS URLs.";
 01617            return false;
 1618        }
 1619
 1620        try
 1621        {
 31622            using var request = new HttpRequestMessage(HttpMethod.Get, sourceUri);
 31623            if (!string.IsNullOrWhiteSpace(bearerToken))
 1624            {
 01625                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
 1626            }
 1627
 31628            if (!TryApplyServiceContentRootCustomHeaders(request, customHeaders, out error))
 1629            {
 01630                return false;
 1631            }
 1632
 31633            if (!ignoreCertificate)
 1634            {
 31635                using var response = ServiceContentRootHttpClient.Send(request, HttpCompletionOption.ResponseHeadersRead
 31636                if (!response.IsSuccessStatusCode)
 1637                {
 01638                    error = $"Failed to query NuGet service index '{sourceUri}'. HTTP {(int)response.StatusCode} {respon
 01639                    return false;
 1640                }
 1641
 31642                using var responseStream = response.Content.ReadAsStream();
 31643                document = JsonDocument.Parse(responseStream);
 31644                return true;
 1645            }
 1646
 01647            using var insecureHandler = new HttpClientHandler
 01648            {
 01649                ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidat
 01650            };
 01651            using var insecureClient = new HttpClient(insecureHandler)
 01652            {
 01653                Timeout = TimeSpan.FromMinutes(5),
 01654            };
 01655            insecureClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0"));
 01656            using var insecureResponse = insecureClient.Send(request, HttpCompletionOption.ResponseHeadersRead);
 01657            if (!insecureResponse.IsSuccessStatusCode)
 1658            {
 01659                error = $"Failed to query NuGet service index '{sourceUri}'. HTTP {(int)insecureResponse.StatusCode} {in
 01660                return false;
 1661            }
 1662
 01663            using var insecureResponseStream = insecureResponse.Content.ReadAsStream();
 01664            document = JsonDocument.Parse(insecureResponseStream);
 01665            return true;
 1666        }
 01667        catch (Exception ex)
 1668        {
 01669            error = $"Failed to read NuGet service index '{sourceUri}': {ex.Message}";
 01670            return false;
 1671        }
 31672    }
 1673
 1674    /// <summary>
 1675    /// Extracts a runtime package into the requested extraction root when needed.
 1676    /// </summary>
 1677    /// <param name="packageBytes">Package bytes.</param>
 1678    /// <param name="extractionRoot">Extraction root path.</param>
 1679    /// <param name="error">Extraction error details.</param>
 1680    /// <returns>True when the package is extracted and ready.</returns>
 1681    private static bool TryEnsureExtractedServiceRuntimePackage(byte[] packageBytes, string extractionRoot, out string e
 1682    {
 121683        error = string.Empty;
 121684        var manifestPath = Path.Combine(extractionRoot, RuntimePackageManifestFileName);
 121685        var extractionCompleteMarkerPath = Path.Combine(extractionRoot, RuntimePackageExtractionCompleteMarkerFileName);
 1686
 1687        // Prefer the explicit extraction-complete marker, but always validate the extracted payload
 1688        // structure so interrupted/partial extractions cannot be treated as complete.
 121689        if (File.Exists(extractionCompleteMarkerPath)
 121690            && TryValidateExtractedServiceRuntimePackagePayload(extractionRoot, manifestPath, out _))
 1691        {
 01692            return true;
 1693        }
 1694
 1695        // Backfill the completion marker for valid legacy extractions that predate marker creation.
 121696        if (TryValidateExtractedServiceRuntimePackagePayload(extractionRoot, manifestPath, out _))
 1697        {
 01698            TryWriteRuntimePackageExtractionMarker(extractionCompleteMarkerPath);
 01699            return true;
 1700        }
 1701
 1702        try
 1703        {
 121704            if (Directory.Exists(extractionRoot))
 1705            {
 21706                TryDeleteDirectoryWithRetry(extractionRoot, maxAttempts: 5, initialDelayMs: 50);
 1707            }
 1708
 121709            _ = Directory.CreateDirectory(extractionRoot);
 121710            var temporaryPackagePath = Path.Combine(extractionRoot, $"payload{RuntimePackageExtension}");
 121711            File.WriteAllBytes(temporaryPackagePath, packageBytes);
 1712            try
 1713            {
 121714                if (!TryExtractZipArchiveSafely(temporaryPackagePath, extractionRoot, out error))
 1715                {
 01716                    return false;
 1717                }
 1718
 121719                if (!TryValidateExtractedServiceRuntimePackagePayload(extractionRoot, manifestPath, out error))
 1720                {
 21721                    return false;
 1722                }
 1723
 101724                TryWriteRuntimePackageExtractionMarker(extractionCompleteMarkerPath);
 101725                return true;
 1726            }
 1727            finally
 1728            {
 121729                TryDeleteFileQuietly(temporaryPackagePath);
 121730            }
 1731        }
 01732        catch (Exception ex)
 1733        {
 01734            error = $"Failed to extract runtime package into '{extractionRoot}': {ex.Message}";
 01735            return false;
 1736        }
 121737    }
 1738
 1739    /// <summary>
 1740    /// Validates the required structure of an extracted runtime package payload.
 1741    /// </summary>
 1742    /// <param name="extractionRoot">Extraction root path.</param>
 1743    /// <param name="manifestPath">Runtime package manifest path.</param>
 1744    /// <param name="error">Validation error details.</param>
 1745    /// <returns>True when the extracted payload is structurally complete.</returns>
 1746    private static bool TryValidateExtractedServiceRuntimePackagePayload(string extractionRoot, string manifestPath, out
 1747    {
 261748        if (!TryValidateExtractedRuntimePackagePreconditions(extractionRoot, manifestPath, out error))
 1749        {
 101750            return false;
 1751        }
 1752
 1753        try
 1754        {
 161755            using var document = JsonDocument.Parse(File.ReadAllText(manifestPath, Encoding.UTF8));
 161756            var root = document.RootElement;
 1757
 161758            return TryResolveRuntimePackageHostPath(extractionRoot, manifestPath, root, out var hostPath, out error)
 161759                && TryValidateRuntimePackageHostPath(extractionRoot, hostPath, out error)
 161760                && TryValidateRuntimePackageModulesPath(extractionRoot, manifestPath, root, out error);
 1761        }
 01762        catch (Exception ex)
 1763        {
 01764            error = $"Failed to validate runtime package extraction at '{extractionRoot}': {ex.Message}";
 01765            return false;
 1766        }
 161767    }
 1768
 1769    /// <summary>
 1770    /// Validates that extraction root and runtime manifest are present before manifest inspection.
 1771    /// </summary>
 1772    /// <param name="extractionRoot">Extraction root path.</param>
 1773    /// <param name="manifestPath">Runtime package manifest path.</param>
 1774    /// <param name="error">Validation error details.</param>
 1775    /// <returns>True when required files and directories exist.</returns>
 1776    private static bool TryValidateExtractedRuntimePackagePreconditions(string extractionRoot, string manifestPath, out 
 1777    {
 261778        error = string.Empty;
 261779        if (!Directory.Exists(extractionRoot))
 1780        {
 101781            error = $"Runtime extraction root '{extractionRoot}' does not exist.";
 101782            return false;
 1783        }
 1784
 161785        if (!File.Exists(manifestPath))
 1786        {
 01787            error = $"Runtime package manifest '{manifestPath}' was not found in extraction root '{extractionRoot}'.";
 01788            return false;
 1789        }
 1790
 161791        return true;
 1792    }
 1793
 1794    /// <summary>
 1795    /// Validates that the runtime host executable resolved from manifest metadata exists.
 1796    /// </summary>
 1797    /// <param name="extractionRoot">Extraction root path.</param>
 1798    /// <param name="hostPath">Resolved host executable path.</param>
 1799    /// <param name="error">Validation error details.</param>
 1800    /// <returns>True when the host executable exists.</returns>
 1801    private static bool TryValidateRuntimePackageHostPath(string extractionRoot, string hostPath, out string error)
 1802    {
 151803        error = string.Empty;
 151804        if (File.Exists(hostPath))
 1805        {
 111806            return true;
 1807        }
 1808
 41809        error = $"Runtime package host executable '{hostPath}' was not found in extraction root '{extractionRoot}'.";
 41810        return false;
 1811    }
 1812
 1813    /// <summary>
 1814    /// Validates the optional modules payload location declared in the runtime manifest.
 1815    /// </summary>
 1816    /// <param name="extractionRoot">Extraction root path.</param>
 1817    /// <param name="manifestPath">Runtime package manifest path.</param>
 1818    /// <param name="manifestRoot">Runtime manifest root element.</param>
 1819    /// <param name="error">Validation error details.</param>
 1820    /// <returns>True when modulesPath is absent, empty, or resolves to an existing directory.</returns>
 1821    private static bool TryValidateRuntimePackageModulesPath(string extractionRoot, string manifestPath, JsonElement man
 1822    {
 111823        error = string.Empty;
 111824        if (!manifestRoot.TryGetProperty("modulesPath", out var modulesPathProperty))
 1825        {
 01826            return true;
 1827        }
 1828
 111829        var modulesPath = modulesPathProperty.GetString();
 111830        if (string.IsNullOrWhiteSpace(modulesPath))
 1831        {
 01832            return true;
 1833        }
 1834
 111835        if (!TryResolveRuntimeManifestPayloadPath(
 111836                extractionRoot,
 111837                manifestPath,
 111838                "modulesPath",
 111839                modulesPath,
 111840                out var resolvedModulesPath,
 111841                out error))
 1842        {
 11843            return false;
 1844        }
 1845
 101846        if (Directory.Exists(resolvedModulesPath))
 1847        {
 101848            return true;
 1849        }
 1850
 01851        error = $"Runtime package modules directory '{resolvedModulesPath}' was not found in extraction root '{extractio
 01852        return false;
 1853    }
 1854
 1855    /// <summary>
 1856    /// Resolves the expected service host executable path from the runtime manifest.
 1857    /// </summary>
 1858    /// <param name="extractionRoot">Extraction root path.</param>
 1859    /// <param name="manifestPath">Manifest file path.</param>
 1860    /// <param name="manifestRoot">Runtime manifest root element.</param>
 1861    /// <param name="hostPath">Resolved host executable path.</param>
 1862    /// <param name="error">Manifest validation error details.</param>
 1863    /// <returns>True when host path resolves inside extraction root.</returns>
 1864    private static bool TryResolveRuntimePackageHostPath(
 1865        string extractionRoot,
 1866        string manifestPath,
 1867        JsonElement manifestRoot,
 1868        out string hostPath,
 1869        out string error)
 1870    {
 161871        error = string.Empty;
 161872        if (manifestRoot.TryGetProperty("entryPoint", out var entryPointProperty))
 1873        {
 161874            var entryPoint = entryPointProperty.GetString();
 161875            if (!string.IsNullOrWhiteSpace(entryPoint))
 1876            {
 161877                return TryResolveRuntimeManifestPayloadPath(
 161878                    extractionRoot,
 161879                    manifestPath,
 161880                    "entryPoint",
 161881                    entryPoint,
 161882                    out hostPath,
 161883                    out error);
 1884            }
 1885        }
 1886
 01887        var hostBinaryName = OperatingSystem.IsWindows() ? "kestrun-service-host.exe" : "kestrun-service-host";
 01888        hostPath = Path.Combine(extractionRoot, "host", hostBinaryName);
 01889        return true;
 1890    }
 1891
 1892    /// <summary>
 1893    /// Resolves a runtime manifest payload-relative path and validates that it remains within the extraction root.
 1894    /// </summary>
 1895    /// <param name="extractionRoot">Extraction root path.</param>
 1896    /// <param name="manifestPath">Manifest file path.</param>
 1897    /// <param name="propertyName">Manifest property name.</param>
 1898    /// <param name="propertyValue">Manifest property value.</param>
 1899    /// <param name="resolvedPath">Resolved absolute payload path.</param>
 1900    /// <param name="error">Manifest validation error details.</param>
 1901    /// <returns>True when the resolved path is anchored under extraction root.</returns>
 1902    private static bool TryResolveRuntimeManifestPayloadPath(
 1903        string extractionRoot,
 1904        string manifestPath,
 1905        string propertyName,
 1906        string propertyValue,
 1907        out string resolvedPath,
 1908        out string error)
 1909    {
 471910        resolvedPath = string.Empty;
 471911        error = string.Empty;
 1912
 471913        var normalizedExtractionRoot = Path.GetFullPath(extractionRoot);
 471914        var extractionRootWithSeparator = normalizedExtractionRoot.EndsWith(Path.DirectorySeparatorChar)
 471915            ? normalizedExtractionRoot
 471916            : normalizedExtractionRoot + Path.DirectorySeparatorChar;
 1917
 471918        var candidatePath = Path.GetFullPath(Path.Combine(
 471919            normalizedExtractionRoot,
 471920            propertyValue.Replace('/', Path.DirectorySeparatorChar)));
 1921
 471922        var comparison = OperatingSystem.IsWindows() ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal;
 471923        if (!candidatePath.StartsWith(extractionRootWithSeparator, comparison))
 1924        {
 21925            error = $"Runtime manifest '{manifestPath}' property '{propertyName}' resolves outside extraction root '{nor
 21926            return false;
 1927        }
 1928
 451929        resolvedPath = candidatePath;
 451930        return true;
 1931    }
 1932
 1933    /// <summary>
 1934    /// Writes an extraction-complete marker file for extracted runtime payloads.
 1935    /// </summary>
 1936    /// <param name="markerPath">Marker file path.</param>
 1937    private static void TryWriteRuntimePackageExtractionMarker(string markerPath)
 1938    {
 1939        try
 1940        {
 101941            File.WriteAllText(markerPath, "ok", Encoding.UTF8);
 101942        }
 01943        catch
 1944        {
 1945            // Marker creation is opportunistic; payload validation remains authoritative.
 01946        }
 101947    }
 1948
 1949    /// <summary>
 1950    /// Resolves the service host and modules layout from an extracted runtime package.
 1951    /// </summary>
 1952    /// <param name="rid">Runtime identifier.</param>
 1953    /// <param name="packageId">Package id.</param>
 1954    /// <param name="packageVersion">Package version.</param>
 1955    /// <param name="packagePath">Package file path.</param>
 1956    /// <param name="extractionRoot">Extraction root path.</param>
 1957    /// <param name="requireModules">True when bundled modules are required.</param>
 1958    /// <param name="runtimePackageLayout">Resolved runtime payload layout.</param>
 1959    /// <param name="error">Resolution error details.</param>
 1960    /// <returns>True when the extracted layout is valid.</returns>
 1961    private static bool TryResolveExtractedServiceRuntimePackageLayout(
 1962        string rid,
 1963        string packageId,
 1964        string packageVersion,
 1965        string packagePath,
 1966        string extractionRoot,
 1967        bool requireModules,
 1968        out ResolvedServiceRuntimePackage runtimePackageLayout,
 1969        out string error)
 1970    {
 101971        runtimePackageLayout = default!;
 101972        error = string.Empty;
 1973
 101974        var hostBinaryName = OperatingSystem.IsWindows() ? "kestrun-service-host.exe" : "kestrun-service-host";
 101975        var serviceHostExecutablePath = Path.Combine(extractionRoot, "host", hostBinaryName);
 101976        var modulesPath = Path.Combine(extractionRoot, "modules");
 101977        var manifestPath = Path.Combine(extractionRoot, RuntimePackageManifestFileName);
 1978
 101979        if (File.Exists(manifestPath))
 1980        {
 101981            if (!TryReadRuntimePackageManifest(manifestPath, rid, ref serviceHostExecutablePath, ref modulesPath, out er
 1982            {
 01983                return false;
 1984            }
 1985        }
 1986
 101987        if (!File.Exists(serviceHostExecutablePath))
 1988        {
 01989            error = $"Runtime package '{packagePath}' did not contain the service host at '{serviceHostExecutablePath}'.
 01990            return false;
 1991        }
 1992
 101993        if (requireModules && !Directory.Exists(modulesPath))
 1994        {
 01995            error = $"Runtime package '{packagePath}' did not contain bundled modules at '{modulesPath}'.";
 01996            return false;
 1997        }
 1998
 101999        runtimePackageLayout = new ResolvedServiceRuntimePackage(
 102000            rid,
 102001            packageId,
 102002            packageVersion,
 102003            packagePath,
 102004            extractionRoot,
 102005            Path.GetFullPath(serviceHostExecutablePath),
 102006            requireModules ? Path.GetFullPath(modulesPath) : string.Empty);
 102007        return true;
 2008    }
 2009
 2010    /// <summary>
 2011    /// Reads runtime package manifest metadata and resolves payload-relative paths.
 2012    /// </summary>
 2013    /// <param name="manifestPath">Manifest file path.</param>
 2014    /// <param name="expectedRid">Expected runtime identifier.</param>
 2015    /// <param name="serviceHostExecutablePath">Resolved service host path.</param>
 2016    /// <param name="modulesPath">Resolved modules path.</param>
 2017    /// <param name="error">Manifest validation error details.</param>
 2018    /// <returns>True when the manifest is valid.</returns>
 2019    private static bool TryReadRuntimePackageManifest(
 2020        string manifestPath,
 2021        string expectedRid,
 2022        ref string serviceHostExecutablePath,
 2023        ref string modulesPath,
 2024        out string error)
 2025    {
 102026        error = string.Empty;
 2027        try
 2028        {
 102029            using var document = JsonDocument.Parse(File.ReadAllText(manifestPath, Encoding.UTF8));
 102030            var root = document.RootElement;
 102031            if (root.TryGetProperty("rid", out var ridProperty))
 2032            {
 102033                var manifestRid = ridProperty.GetString();
 102034                if (!string.Equals(manifestRid, expectedRid, StringComparison.OrdinalIgnoreCase))
 2035                {
 02036                    error = $"Runtime manifest '{manifestPath}' targets RID '{manifestRid}', but '{expectedRid}' was exp
 02037                    return false;
 2038                }
 2039            }
 2040
 102041            var extractionRoot = Path.GetDirectoryName(manifestPath) ?? string.Empty;
 102042            if (root.TryGetProperty("entryPoint", out var entryPointProperty))
 2043            {
 102044                var entryPoint = entryPointProperty.GetString();
 102045                if (!string.IsNullOrWhiteSpace(entryPoint))
 2046                {
 102047                    if (!TryResolveRuntimeManifestPayloadPath(
 102048                            extractionRoot,
 102049                            manifestPath,
 102050                            "entryPoint",
 102051                            entryPoint,
 102052                            out var resolvedHostPath,
 102053                            out error))
 2054                    {
 02055                        return false;
 2056                    }
 2057
 102058                    serviceHostExecutablePath = resolvedHostPath;
 2059                }
 2060            }
 2061
 102062            if (root.TryGetProperty("modulesPath", out var modulesPathProperty))
 2063            {
 102064                var relativeModulesPath = modulesPathProperty.GetString();
 102065                if (!string.IsNullOrWhiteSpace(relativeModulesPath))
 2066                {
 102067                    if (!TryResolveRuntimeManifestPayloadPath(
 102068                            extractionRoot,
 102069                            manifestPath,
 102070                            "modulesPath",
 102071                            relativeModulesPath,
 102072                            out var resolvedModulesPath,
 102073                            out error))
 2074                    {
 02075                        return false;
 2076                    }
 2077
 102078                    modulesPath = resolvedModulesPath;
 2079                }
 2080            }
 2081
 102082            return true;
 2083        }
 02084        catch (Exception ex)
 2085        {
 02086            error = $"Failed to read runtime manifest '{manifestPath}': {ex.Message}";
 02087            return false;
 2088        }
 102089    }
 2090
 2091    /// <summary>
 2092    /// Reads the package id and version from a nupkg payload.
 2093    /// </summary>
 2094    /// <param name="packageBytes">Package bytes.</param>
 2095    /// <param name="packageId">Resolved package id.</param>
 2096    /// <param name="packageVersion">Resolved package version.</param>
 2097    /// <returns>True when package identity metadata is available.</returns>
 2098    private static bool TryReadPackageIdentity(byte[] packageBytes, out string packageId, out string packageVersion)
 2099    {
 122100        packageId = string.Empty;
 122101        packageVersion = string.Empty;
 2102
 122103        using var stream = new MemoryStream(packageBytes, writable: false);
 122104        using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
 242105        var nuspecEntry = archive.Entries.FirstOrDefault(static entry => entry.FullName.EndsWith(".nuspec", StringCompar
 122106        if (nuspecEntry is null)
 2107        {
 02108            return false;
 2109        }
 2110
 122111        using var reader = new StreamReader(nuspecEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
 122112        var nuspecText = reader.ReadToEnd();
 122113        if (string.IsNullOrWhiteSpace(nuspecText))
 2114        {
 02115            return false;
 2116        }
 2117
 122118        var document = XDocument.Parse(nuspecText);
 122119        var idElement = document.Descendants()
 482120            .FirstOrDefault(static element => string.Equals(element.Name.LocalName, "id", StringComparison.OrdinalIgnore
 122121        var versionElement = document.Descendants()
 602122            .FirstOrDefault(static element => string.Equals(element.Name.LocalName, "version", StringComparison.OrdinalI
 122123        if (idElement is null || versionElement is null)
 2124        {
 02125            return false;
 2126        }
 2127
 122128        packageId = idElement.Value.Trim();
 122129        packageVersion = versionElement.Value.Trim();
 122130        return !string.IsNullOrWhiteSpace(packageId) && !string.IsNullOrWhiteSpace(packageVersion);
 122131    }
 2132
 2133    /// <summary>
 2134    /// Converts an arbitrary token into a cache-safe path segment.
 2135    /// </summary>
 2136    /// <param name="value">Raw value.</param>
 2137    /// <returns>Cache-safe token.</returns>
 2138    private static string SanitizePathToken(string value)
 2139    {
 342140        var invalidCharacters = Path.GetInvalidFileNameChars();
 342141        var builder = new StringBuilder(value.Length);
 17682142        foreach (var character in value)
 2143        {
 8502144            _ = builder.Append(invalidCharacters.Contains(character) ? '-' : character);
 2145        }
 2146
 342147        return builder.ToString();
 2148    }
 2149}

Methods/Properties

Main(System.String[])
TryHandleInternalServiceRegisterMode(System.String[],System.Int32&)
TryDispatchParsedCommand(Kestrun.Tool.Program/ParsedCommand,Kestrun.Tool.Program/GlobalOptions,System.String[],System.Int32&)
HandleModuleCommand(Kestrun.Tool.Program/ParsedCommand,System.String[])
RequiresWindowsElevationForGlobalModuleOperation(Kestrun.Tool.Program/ParsedCommand)
HandleServiceRemoveCommand(Kestrun.Tool.Program/ParsedCommand,System.String[])
HandleServiceStartCommand(Kestrun.Tool.Program/ParsedCommand,System.String[])
HandleServiceStopCommand(Kestrun.Tool.Program/ParsedCommand,System.String[])
ExecuteRunMode(Kestrun.Tool.Program/ParsedCommand,System.Boolean)
IsWindowsAdministrator()
TryPreflightWindowsServiceInstall(Kestrun.Tool.Program/ParsedCommand,System.String,System.Int32&)
TryPreflightWindowsServiceRemove(Kestrun.Tool.Program/ParsedCommand,System.Int32&)
TryPreflightWindowsServiceControl(Kestrun.Tool.Program/ParsedCommand,System.Int32&,System.String&)
WindowsServiceExists(System.String)
RelaunchElevatedOnWindows(System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.Boolean)
TryResolveElevationExecutablePath(System.String,System.String&)
WriteElevationWrapperScript(System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
StartElevatedProcess(System.String,System.String,System.Boolean)
RelayElevatedOutput(System.String)
WriteElevationCanceledMessage(System.Boolean)
WriteElevationFailureMessage(System.String,System.Boolean)
TryDeleteFileQuietly(System.String)
BuildElevatedRelaunchArguments(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
IsDotnetHostExecutable(System.String)
BuildRunnerArgumentsForService(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.String)
BuildDaemonHostArgumentsForService(System.String,System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String)
InstallWindowsService(Kestrun.Tool.Program/ParsedCommand,System.String,System.String,System.String,System.String,System.String,System.String)
RegisterWindowsService(Kestrun.Tool.Program/ServiceRegisterOptions)
CreateWindowsServiceRegistration(System.String,System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.String,System.String)
NormalizeWindowsServiceAccountName(System.String)
IsWindowsBuiltinServiceAccount(System.String)
UsesDedicatedServiceHostExecutable(System.String)
BuildWindowsServiceRegisterArguments(Kestrun.Tool.Program/ParsedCommand,System.String,System.String,System.String,System.String,System.String,System.String)
BuildDedicatedServiceHostArguments(System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String)
RemoveWindowsService(Kestrun.Tool.Program/ParsedCommand)
WaitForWindowsServiceToStopOrDisappear(System.String,System.Int32,System.Int32)
WriteServiceOperationLog(System.String,System.String,System.String)
WriteServiceOperationResult(System.String,System.String,System.String,System.Int32,System.String)
ResolveServiceOperationLogPath(System.String,System.String)
TryGetWindowsServiceLogPath(System.String,System.String&)
TryResolveServiceRuntimeExecutableFromModule(System.String,System.String&,System.String&)
EnumerateServiceRuntimeExecutableCandidates()
TryResolveDedicatedServiceHostExecutableFromToolDistribution(System.String&)
TryResolvePowerShellModulesPayloadFromToolDistribution(System.String&)
EnumerateDedicatedServiceHostCandidates()
EnumeratePowerShellModulesPayloadCandidates()
EnumerateDirectoryAndParents()
TryResolveServiceScriptSource(Kestrun.Tool.Program/ParsedCommand,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&)
TryClassifyServiceContentRoot(System.String,System.String&,System.Uri&,System.String&)
TryResolveServiceScriptFromContentRoot(Kestrun.Tool.Program/ParsedCommand,System.String,System.Uri,System.String,Kestrun.Tool.Program/ServiceContentRootOptionFlags,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&)
get_HasArchiveChecksum()
get_HasBearerToken()
get_IgnoreCertificate()
get_HasHeaders()
ResolveRequestedServiceScriptPath(System.String,System.Boolean)
GetServiceContentRootOptionFlags(Kestrun.Tool.Program/ParsedCommand)
TryResolveServiceScriptWithoutContentRoot(System.String,Kestrun.Tool.Program/ServiceContentRootOptionFlags,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&)
TryResolveServiceScriptFromHttpContentRoot(Kestrun.Tool.Program/ParsedCommand,System.String,System.Uri,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&)
TryResolveServiceScriptFromDirectoryContentRoot(System.String,System.String,Kestrun.Tool.Program/ServiceContentRootOptionFlags,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&)
TryResolveServiceScriptFromArchiveContentRoot(Kestrun.Tool.Program/ParsedCommand,System.String,System.String,Kestrun.Tool.Program/ServiceContentRootOptionFlags,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&)
TryResolveServiceScriptFromExtractedArchiveContentRoot(System.String,System.String,System.String,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&)
TryValidateOptionsForMissingContentRoot(Kestrun.Tool.Program/ServiceContentRootOptionFlags,System.String&)
TryValidateUrlOnlyContentRootOptions(Kestrun.Tool.Program/ServiceContentRootOptionFlags,System.String&)
TryValidateDirectoryContentRootOptions(Kestrun.Tool.Program/ServiceContentRootOptionFlags,System.String&)
TryValidateLocalArchiveContentRootOptions(Kestrun.Tool.Program/ServiceContentRootOptionFlags,System.String&)
TryValidateHttpContentRootScriptPath(System.String,System.String&)
TryResolveServiceInstallDescriptor(System.String,Kestrun.Tool.Program/ServiceInstallDescriptor&,System.String&)
TryReadNormalizedServiceDescriptorText(System.String,System.String&,System.String&)
TryResolveServiceDescriptorCoreFields(System.String,System.String&,System.String&,System.String&,System.String&)
TryResolveServiceDescriptorEntryPointAndVersion(System.String,System.String,System.String&,System.String&,System.String&,System.String&)
TryResolveOptionalServiceDescriptorVersion(System.String,System.String&,System.String&)
NormalizeServiceDescriptorText(System.String)
TryGetServiceDescriptorStringValue(System.String,System.String,System.Boolean,System.String&,System.String&)
TryGetServiceDescriptorStringArrayValue(System.String,System.String,System.String[]&,System.String&)
TryResolveServiceDescriptorScriptPath(System.String,Kestrun.Tool.Program/ServiceInstallDescriptor,System.String&,System.String&)
ApplyDescriptorMetadata(Kestrun.Tool.Program/ResolvedServiceScriptSource,Kestrun.Tool.Program/ServiceInstallDescriptor)
TryDownloadAndExtractHttpContentRoot(Kestrun.Tool.Program/ParsedCommand,System.Uri,System.String,System.String,System.String&)
TryResolveScriptFromResolvedContentRoot(System.String,System.String,System.String,System.String,System.String,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&)
CreateEmptyResolvedServiceScriptSource()
CreateServiceContentRootExtractionDirectory(System.String)
TryParseServiceContentRootHttpUri(System.String,System.Uri&)
TryDownloadServiceContentRootArchive(System.Uri,System.String,System.String,System.String[],System.Boolean,System.String&,System.String&)
TryApplyServiceContentRootCustomHeaders(System.Net.Http.HttpRequestMessage,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String&)
TryWriteDownloadedContentRootArchive(System.String,System.Uri,System.Net.Http.HttpResponseMessage,System.String&,System.String&)
GetSafeServiceContentRootArchiveFileName(System.String,System.String)
TryResolveDownloadedServiceContentRootArchiveFileName(System.Uri,System.String,System.String,System.Net.Http.HttpResponseMessage,System.String&,System.String&)
TryGetServiceContentRootArchiveExtensionFromMediaType(System.String,System.String&)
TryDetectServiceContentRootArchiveExtensionFromSignature(System.String,System.String&)
BuildServiceContentRootArchiveFileName(System.String,System.String)
TryResolveServiceContentRootArchiveFileName(System.Uri,System.Net.Http.HttpResponseMessage)
IsSupportedServiceContentRootArchive(System.String)
TryValidateServiceContentRootArchiveChecksum(Kestrun.Tool.Program/ParsedCommand,System.String,System.String&)
TryCreateChecksumAlgorithm(System.String,System.Security.Cryptography.HashAlgorithm&,System.String&,System.String&)
TryExtractServiceContentRootArchive(System.String,System.String,System.String&)
TryExtractZipArchiveSafely(System.String,System.String,System.String&)
TryExtractTarArchiveSafely(System.IO.Stream,System.String,System.String&)
IsPathWithinDirectory(System.String,System.String)
TryResolveServiceDeploymentRoot(System.String,System.String&,System.String&)
TryEnsureDirectoryWritable(System.String,System.String&)
GetServiceDeploymentRootCandidates()
TryRemoveServiceBundle(System.String,System.String)
IsExpectedUnixProtectedRootCleanupFailure(System.String,System.Exception,System.String)
IsProtectedUnixServiceRoot(System.String)
TryDeleteDirectoryWithRetry(System.String,System.Int32,System.Int32)
GetServiceDeploymentDirectoryName(System.String)
TryGetServiceRuntimeRid(System.String&,System.String&)
TryEnsureServiceRuntimeExecutablePermissions(System.String)
NormalizeServiceLogPath(System.String,System.String)
EscapeXml(System.String)
EscapeSystemdToken(System.String)
BuildWindowsCommandLine(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
EscapeWindowsCommandLineArgument(System.String)
GetLinuxUnitName(System.String)
IsLikelyRunningAsRootOnLinux()
IsLikelyRunningAsRootOnUnix()
WriteLinuxUserSystemdFailureHint(Kestrun.Tool.Program/ProcessResult)
RunProcess(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Boolean)
get_ExitCode()
ResolveRunModuleManifestPath(System.String,System.String)
BuildDedicatedServiceHostRunArguments(System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Boolean)
ShouldDiscoverPowerShellHomeForManifest(System.String)
ResolveCurrentProcessPathOrFallback(System.String)
ParseGlobalOptions(System.String[])
IsNoCheckOption(System.String)
TryValidateInstallAction(System.String,System.String,System.String&)
TryValidateUpdateAction(System.String,System.String,System.Boolean,System.String&)
TryReadPackageVersion(System.Byte[],System.String&)
TryGetPackagePayloadPath(System.String,System.String&)
CopyDirectoryContents(System.String,System.String,System.Boolean)
CopyDirectoryContents(System.String,System.String,System.Boolean,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
ShouldExcludeCopyFile(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.Text.RegularExpressions.Regex>)
BuildCopyExclusionRegexes(System.Collections.Generic.IReadOnlyList`1<System.String>)
NormalizeCopyPath(System.String)
TryRemoveInstalledModule(System.String,System.Boolean,System.String&)
CopyStreamWithProgress(System.IO.Stream,System.IO.Stream,Kestrun.Tool.ConsoleProgressBar)
FormatByteProgressDetail(System.Int64,System.Nullable`1<System.Int64>)
FormatFileProgressDetail(System.Int64,System.Nullable`1<System.Int64>)
FormatServiceBundleStepProgressDetail(System.Int64,System.Nullable`1<System.Int64>)
FormatByteSize(System.Int64)
WarnIfNewerGalleryVersionExists(System.String,System.String)
WriteWarningToLogOrConsole(System.String,System.String)
TryGetLatestInstalledModuleVersionText(Kestrun.Tool.Program/ModuleStorageScope,System.String&)
TryGetLatestInstalledModuleVersionTextFromModuleRoot(System.String,System.String&)
TryGetInstalledModuleVersionText(System.String,System.String&)
TryReadModuleSemanticVersionFromManifest(System.String,System.String&)
TryGetLatestGalleryVersionString(System.String&,System.String&)
TryGetLatestGalleryVersionStringFromClient(System.Net.Http.HttpClient,System.String&,System.String&)
TryGetGalleryModuleVersions(System.Collections.Generic.List`1<System.String>&,System.String&)
TryGetGalleryModuleVersionsFromClient(System.Net.Http.HttpClient,System.Collections.Generic.List`1<System.String>&,System.String&)
TryParseGalleryModuleVersions(System.String,System.Collections.Generic.List`1<System.String>&,System.String&)
CreateGalleryHttpClient()
CreateServiceContentRootHttpClient()
TryParseVersionValue(System.String,System.Version&)
TryNormalizeModuleVersion(System.String,System.String&)
TryParseModuleScope(System.String,Kestrun.Tool.Program/ModuleStorageScope&)
GetScopeToken(Kestrun.Tool.Program/ModuleStorageScope)
CompareModuleVersionValues(System.String,System.String)
HasPrereleaseSuffix(System.String)
TryReadModuleVersionFromManifest(System.String,System.String&)
GetInstalledModuleRecords(System.String)
GetPowerShellModulePath(Kestrun.Tool.Program/ModuleStorageScope)
GetGlobalPowerShellModulePath()
GetDefaultPowerShellModulePath()
WriteModuleNotFoundMessage(System.String,System.String,System.Action`1<System.String>)
TryParseArguments(System.String[],Kestrun.Tool.Program/ParsedCommand&,System.String&)
TryParseLeadingKestrunOptions(System.String[],System.Int32&,System.String&,System.String&,System.String&)
TryConsumeLeadingOptionValue(System.String[],System.Int32&,System.String,System.String&,System.String&)
TryParseCommandFromToken(System.String[],System.Int32,System.String,System.String,Kestrun.Tool.Program/ParsedCommand&,System.String&)
TryHandleMetaCommands(System.String[],System.Int32&)
IsHelpToken(System.String)
TryGetHelpTopic(System.String,System.String&)
FilterGlobalOptions(System.String[])
PrintUsage()
PrintHelpForTopic(System.String)
PrintVersion()
PrintInfo()
GetProductVersion()
LocateModuleManifest(System.String,System.String)
EnumerateExecutableManifestCandidates()
GetExecutableDirectory()
EnumerateModulePathManifestCandidates()
ManageModuleCommand(Kestrun.Tool.Program/ParsedCommand)
PrintModuleInfo(Kestrun.Tool.Program/ModuleStorageScope)
ManageModuleFromGallery(Kestrun.Tool.Program/ModuleCommandAction,System.String,Kestrun.Tool.Program/ModuleStorageScope,System.Boolean)
HandleModuleRemoveAction(System.String,Kestrun.Tool.Program/ModuleStorageScope,System.Boolean)
TryValidateModuleInstallAction(Kestrun.Tool.Program/ModuleCommandAction,System.String,Kestrun.Tool.Program/ModuleStorageScope,System.String&)
TryExecuteModuleInstallOrUpdate(Kestrun.Tool.Program/ModuleCommandAction,System.String,System.String,System.Boolean,System.String&,System.String&,System.String&)
WriteModuleActionFailure(Kestrun.Tool.Program/ModuleCommandAction,System.String)
WriteModuleInstallOrUpdateSuccess(Kestrun.Tool.Program/ModuleCommandAction,Kestrun.Tool.Program/ModuleStorageScope,System.String,System.String,System.String)
TryInstallOrUpdateModuleFromGallery(Kestrun.Tool.Program/ModuleCommandAction,System.String,System.String,System.Boolean,System.Boolean,System.String&,System.String&,System.String&)
TryDownloadModulePackage(System.String,System.Boolean,System.Byte[]&,System.String&,System.String&)
NormalizeRequestedModuleVersion(System.String)
BuildGalleryPackageUrl(System.String)
TryHandlePackageDownloadResponseStatus(System.Net.Http.HttpResponseMessage,System.String,System.Boolean,System.Byte[]&,System.String&,System.String&)
TryDownloadPackagePayload(System.Net.Http.HttpResponseMessage,System.Boolean,System.Byte[]&,System.String&)
TryResolveDownloadedPackageVersion(System.Byte[],System.String,System.String&,System.String&)
TryExtractModulePackage(System.Byte[],System.String,System.String,System.Boolean,System.Boolean,System.String&,System.String&)
TryCollectModulePayloadEntries(System.IO.Compression.ZipArchive,System.Collections.Generic.List`1<System.ValueTuple`2<System.IO.Compression.ZipArchiveEntry,System.String>>&,System.String&)
ShouldStripModulePrefix(System.Collections.Generic.IReadOnlyList`1<System.ValueTuple`2<System.IO.Compression.ZipArchiveEntry,System.String>>)
TryExtractPayloadEntriesToStaging(System.Collections.Generic.IReadOnlyList`1<System.ValueTuple`2<System.IO.Compression.ZipArchiveEntry,System.String>>,System.String,System.StringComparison,System.Boolean,System.Boolean,System.String&)
NormalizePayloadRelativePath(System.String,System.Boolean)
TryResolveSafeStagingDestination(System.String,System.String,System.String,System.String,System.StringComparison,System.String&,System.String&)
TryResolveExtractedManifestPath(System.String,System.String&,System.String&)
TryInstallExtractedModule(System.String,System.String,System.String,System.Boolean,System.Boolean,System.String&,System.String&)
TryDeleteDirectoryQuietly(System.String)
TryParseModuleArguments(System.String[],System.Int32,Kestrun.Tool.Program/ParsedCommand&,System.String&)
get_ActionToken()
get_Mode()
get_ModuleVersion()
get_ModuleScope()
get_ModuleScopeSet()
get_ModuleForce()
get_ModuleForceSet()
CreateDefaultModuleParsedCommand()
TryResolveModuleAction(System.String[],System.Int32,Kestrun.Tool.Program/ModuleParseState&,System.String&)
TryParseModuleOptionLoop(System.String[],System.Int32,Kestrun.Tool.Program/ModuleParseState,System.String&)
TryConsumeModuleOption(System.String[],Kestrun.Tool.Program/ModuleParseState,System.Int32&,System.String&)
TryConsumeModuleScopeOption(System.String[],Kestrun.Tool.Program/ModuleParseState,System.Int32&,System.String&)
TryConsumeModuleVersionOption(System.String[],Kestrun.Tool.Program/ModuleParseState,System.Boolean,System.Int32&,System.String&)
TryConsumeModuleForceOption(Kestrun.Tool.Program/ModuleParseState,System.Int32&,System.String&)
TryConsumeOptionValue(System.String[],System.Int32&,System.String,System.String&,System.String&,System.String)
CreateParsedModuleCommand(Kestrun.Tool.Program/ModuleParseState)
.cctor()
.ctor(Kestrun.Tool.Program/CommandMode,System.String,System.Boolean,System.String[],System.String,System.String,System.String,System.Boolean,System.String,System.String,System.String,System.String,Kestrun.Tool.Program/ModuleStorageScope,System.Boolean,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.Boolean,System.String[],System.Boolean,System.Boolean,System.Boolean,System.Boolean)
get_Mode()
get_ScriptPath()
get_ScriptPathProvided()
get_ScriptArguments()
get_KestrunFolder()
get_KestrunManifestPath()
get_ServiceName()
get_ServiceNameProvided()
get_ServiceLogPath()
get_ServiceUser()
get_ServicePassword()
get_ModuleVersion()
get_ModuleScope()
get_ModuleForce()
get_ServiceContentRoot()
get_ServiceDeploymentRoot()
get_ServiceRuntimeSource()
get_ServiceRuntimePackage()
get_ServiceRuntimeVersion()
get_ServiceRuntimePackageId()
get_ServiceRuntimeCache()
get_ServiceContentRootChecksum()
get_ServiceContentRootChecksumAlgorithm()
get_ServiceContentRootBearerToken()
get_ServiceContentRootIgnoreCertificate()
get_ServiceContentRootHeaders()
get_ServiceFailback()
get_ServiceUseRepositoryKestrun()
get_JsonOutput()
get_RawOutput()
.ctor(System.String,System.String,System.String,System.String,System.String,System.String[],System.String,System.String,System.String)
get_ServiceName()
get_ServiceHostExecutablePath()
get_RunnerExecutablePath()
get_ScriptPath()
get_ModuleManifestPath()
get_ScriptArguments()
get_ServiceLogPath()
get_ServiceUser()
get_ServicePassword()
.ctor(System.String[],System.Boolean)
get_CommandArgs()
get_SkipGalleryCheck()
.ctor(System.String,System.String)
get_Version()
get_ManifestPath()
.ctor(System.String,System.String,System.String,System.String,System.String)
get_RootPath()
get_RuntimeExecutablePath()
get_ServiceHostExecutablePath()
get_ScriptPath()
get_ModuleManifestPath()
.ctor(System.String,System.String,System.String,System.String,System.String,System.String,System.String)
get_Rid()
get_PackageId()
get_PackageVersion()
get_PackagePath()
get_ExtractionRoot()
get_ServiceHostExecutablePath()
get_ModulesPath()
.ctor(System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.IReadOnlyList`1<System.String>)
get_FullScriptPath()
get_FullContentRoot()
get_RelativeScriptPath()
get_TemporaryContentRootPath()
get_DescriptorServiceName()
get_DescriptorServiceDescription()
get_DescriptorServiceVersion()
get_DescriptorServiceLogPath()
get_DescriptorPreservePaths()
get_DescriptorApplicationDataFolders()
.ctor(System.String,System.String,System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.IReadOnlyList`1<System.String>)
get_FormatVersion()
get_Name()
get_EntryPoint()
get_Description()
get_Version()
get_ServiceLogPath()
get_PreservePaths()
get_ApplicationDataFolders()
ExecuteScriptViaServiceHost(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String)
RunForegroundProcess(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
TryParseRunArguments(System.String[],System.Int32,System.String,System.String,Kestrun.Tool.Program/ParsedCommand&,System.String&)
TryCaptureRunScriptArguments(System.String[],System.String,Kestrun.Tool.Program/RunParseState&)
TryConsumeRunOption(System.String[],System.String,Kestrun.Tool.Program/RunParseState&,System.String&)
TryConsumeRunScriptOption(System.String[],Kestrun.Tool.Program/RunParseState&,System.String&)
TryConsumeRunKestrunFolderOption(System.String[],Kestrun.Tool.Program/RunParseState&,System.String&)
TryConsumeRunKestrunManifestOption(System.String[],Kestrun.Tool.Program/RunParseState&,System.String&)
.ctor(System.Int32,System.String,System.String)
get_Index()
get_KestrunFolder()
get_KestrunManifestPath()
get_ScriptPath()
get_ScriptPathSet()
get_ScriptArguments()
InstallService(Kestrun.Tool.Program/ParsedCommand,System.Boolean)
IsRuntimeOnlyServiceInstall(Kestrun.Tool.Program/ParsedCommand)
CacheServiceRuntimePackage(Kestrun.Tool.Program/ParsedCommand)
TryResolveInstallServiceInputs(Kestrun.Tool.Program/ParsedCommand,System.String&,System.String&,System.String&,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.String&,System.Int32&)
TryCleanupTemporaryServiceContentRoot(System.String)
TryRunInstallServicePreflight(Kestrun.Tool.Program/ParsedCommand,System.String,System.String,System.String,System.Boolean,System.Int32&)
TryPrepareInstallServiceBundle(Kestrun.Tool.Program/ParsedCommand,System.String,System.String,Kestrun.Tool.Program/ResolvedServiceScriptSource,System.String,Kestrun.Tool.Program/ServiceBundleLayout&,System.Int32&)
InstallPreparedServiceForCurrentPlatform(Kestrun.Tool.Program/ParsedCommand,System.String,System.String,Kestrun.Tool.Program/ServiceBundleLayout)
RemoveService(Kestrun.Tool.Program/ParsedCommand)
StartService(Kestrun.Tool.Program/ParsedCommand)
StopService(Kestrun.Tool.Program/ParsedCommand)
QueryService(Kestrun.Tool.Program/ParsedCommand)
.ctor(System.String,System.String,System.String,System.String,System.Nullable`1<System.Int32>,System.Int32,System.String,System.String,System.String)
get_Operation()
get_ServiceName()
get_Platform()
get_State()
get_Pid()
get_ExitCode()
get_Message()
get_RawOutput()
get_RawError()
get_Success()
WriteServiceControlResult(Kestrun.Tool.Program/ParsedCommand,Kestrun.Tool.Program/ServiceControlResult)
InfoService(Kestrun.Tool.Program/ParsedCommand)
WriteServiceInfoHumanReadable(System.String,System.String,System.String,Kestrun.Tool.Program/ServiceInstallDescriptor,System.Collections.Generic.IReadOnlyList`1<Kestrun.Tool.Program/ServiceBackupSnapshot>)
get_Version()
GetServiceBackupSnapshots(System.String)
TryEnumerateInstalledServiceBundleRoots(System.String,System.Collections.Generic.List`1<System.String>&,System.String&)
UpdateService(Kestrun.Tool.Program/ParsedCommand)
ExecuteServiceUpdateFlow(Kestrun.Tool.Program/ParsedCommand,System.String,System.Boolean,System.Boolean,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.Boolean&)
TryPrepareServiceUpdateExecution(System.String,System.String,Kestrun.Tool.Program/ServiceUpdatePaths&,System.Int32&)
TryRunServiceUpdateOperations(Kestrun.Tool.Program/ParsedCommand,System.Boolean,System.Boolean,Kestrun.Tool.Program/ServiceUpdatePaths,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.Boolean&,System.Boolean&,System.Boolean&,System.Boolean&,System.Int32&)
TryValidateUpdateServiceCommand(Kestrun.Tool.Program/ParsedCommand,System.Boolean&,System.Boolean&,System.Int32&)
TryResolveUpdateServiceIdentity(Kestrun.Tool.Program/ParsedCommand,System.Boolean,System.String&,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.Boolean&,System.Int32&)
TryResolveServiceUpdatePaths(System.String,System.String,Kestrun.Tool.Program/ServiceUpdatePaths&,System.Int32&)
TryExecuteServiceFailback(Kestrun.Tool.Program/ServiceUpdatePaths,System.Int32&)
TryApplyServicePackageUpdate(Kestrun.Tool.Program/ParsedCommand,System.Boolean,Kestrun.Tool.Program/ServiceUpdatePaths,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.Boolean&,System.Boolean&,System.Int32&)
TryEnsureServicePackageSourceResolved(Kestrun.Tool.Program/ParsedCommand,Kestrun.Tool.Program/ResolvedServiceScriptSource&,System.Boolean&,System.Int32&)
TryValidateServicePackageUpdateContext(System.String,Kestrun.Tool.Program/ResolvedServiceScriptSource,System.String&,System.Int32&)
TryApplyServiceApplicationReplacement(Kestrun.Tool.Program/ServiceUpdatePaths,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Int32&)
TryApplyServiceModuleUpdate(Kestrun.Tool.Program/ParsedCommand,System.Boolean,Kestrun.Tool.Program/ServiceUpdatePaths,System.Boolean&,System.Int32&)
TryApplyDirectModuleReplacement(System.String,Kestrun.Tool.Program/ServiceUpdatePaths,System.Boolean&,System.Int32&)
TryResolveUpdateManifestPath(Kestrun.Tool.Program/ParsedCommand,System.String&,System.Int32&)
TryResolveUpdateManifestPath(Kestrun.Tool.Program/ParsedCommand,System.String,System.String&,System.Int32&)
TryResolveSourceModuleRoot(System.String,System.String&,System.String&)
TryApplyServiceHostUpdate(Kestrun.Tool.Program/ServiceUpdatePaths,System.Boolean&,System.Int32&)
WriteServiceUpdateSummary(System.String,Kestrun.Tool.Program/ServiceUpdatePaths,System.Boolean,System.Boolean,System.Boolean)
get_ServiceRootPath()
get_ScriptRoot()
get_ModuleRoot()
get_BackupRoot()
ResolveRepositoryModuleManifestPath()
ResolveRepositoryModuleManifestPath(System.String)
TryEvaluateRepositoryModuleUpdateNeeded(System.String,System.String,System.Boolean&,System.String&,System.String&)
TryFailbackServiceFromBackup(System.String,System.String,System.String,System.Object&,System.String&)
TryResolveLatestServiceBackupDirectory(System.String,System.String&,System.String&)
TryParseServiceDescriptorVersion(System.String,System.Version&,System.String&)
TryValidateServicePackageVersionUpdate(System.String,System.String,System.Version&,System.String&,System.String&)
TryEnsureServiceIsStopped(System.String,System.String&)
TryEnsureWindowsServiceIsStopped(System.String,System.String&)
TryEnsureLinuxServiceIsStopped(System.String,System.String&)
TryEnsureMacServiceIsStopped(System.String,System.String&)
TryBackupDirectory(System.String,System.String,System.String&)
TryReplaceDirectoryFromSource(System.String,System.String,System.String,System.String&,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Collections.Generic.IReadOnlyList`1<System.String>)
TryStagePreservedPaths(System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String&,System.String&)
TryRestorePreservedPaths(System.String,System.String,System.String&)
TryNormalizePreservePath(System.String,System.String&,System.String&)
TryUpdateBundledServiceHostIfNewer(System.String,System.String,System.String&,System.Boolean&)
TryResolveServiceHostUpdatePaths(System.String,System.String&,System.String&,System.String&)
TryCopyServiceHostBinary(System.String,System.String,System.String&,System.Boolean&)
ShouldReplaceBundledServiceHostBinary(System.String,System.String)
TryBackupAndReplaceServiceHostBinary(System.String,System.String,System.String,System.String&,System.Boolean&)
TryReadFileVersion(System.String,System.Version&)
TryResolveInstalledServiceBundleRoot(System.String,System.String,System.String&,System.String&)
StartWindowsService(System.String,System.String,System.Boolean)
StopWindowsService(System.String,System.String,System.Boolean)
IsWindowsServiceAlreadyStopped(Kestrun.Tool.Program/ProcessResult)
QueryWindowsService(System.String,System.String,System.Boolean)
InstallLinuxUserDaemon(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.String)
BuildLinuxSystemdUnitContent(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.String)
RemoveLinuxUserDaemon(System.String)
StartLinuxUserDaemon(System.String,System.String,System.Boolean)
StopLinuxUserDaemon(System.String,System.String,System.Boolean)
IsLinuxServiceAlreadyStopped(Kestrun.Tool.Program/ProcessResult)
QueryLinuxUserDaemon(System.String,System.String,System.Boolean)
RunLinuxSystemctl(System.Boolean,System.Collections.Generic.IReadOnlyList`1<System.String>,System.Boolean)
IsLinuxSystemUnitInstalled(System.String)
TryGetInstalledLinuxUnitScope(System.String,System.Boolean&)
InstallMacLaunchAgent(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String,System.String)
RemoveMacLaunchAgent(System.String)
StartMacLaunchAgent(System.String,System.String,System.Boolean)
StopMacLaunchAgent(System.String,System.String,System.Boolean)
IsMacServiceAlreadyStopped(Kestrun.Tool.Program/ProcessResult)
QueryMacLaunchAgent(System.String,System.String,System.Boolean)
TryExtractWindowsServicePid(System.String)
TryQueryLinuxServicePid(System.Boolean,System.String)
TryExtractMacServicePid(System.String)
IsMacSystemLaunchDaemonInstalled(System.String)
BuildLaunchdPlist(System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>,System.String)
TryPrepareServiceBundle(System.String,System.String,System.String,System.String,System.String,Kestrun.Tool.Program/ResolvedServiceRuntimePackage,Kestrun.Tool.Program/ServiceBundleLayout&,System.String&,System.String,System.String)
TryResolveServiceBundleContext(System.String,System.String,System.String,Kestrun.Tool.Program/ResolvedServiceRuntimePackage,System.String,System.String,Kestrun.Tool.Program/ServiceBundleContext&,System.String&)
RecreateServiceBundleDirectories(Kestrun.Tool.Program/ServiceBundleContext)
CopyServiceRuntimeExecutable(Kestrun.Tool.Program/ServiceBundleContext)
TryCopyServiceHostExecutable(Kestrun.Tool.Program/ServiceBundleContext,System.String&,System.String&)
TryCopyBundledRuntimeModules(Kestrun.Tool.Program/ServiceBundleContext,System.Boolean,System.String&)
EnsureBundleExecutablesAreRunnable(System.String,System.String)
TryCopyServiceModuleFiles(Kestrun.Tool.Program/ServiceBundleContext,System.Boolean,System.String&,System.String&)
TryCopyServiceScriptFiles(Kestrun.Tool.Program/ServiceBundleContext,System.String,System.String,System.Boolean,System.String&,System.String&)
get_FullScriptPath()
get_FullManifestPath()
get_RuntimeExecutablePath()
get_ModuleRoot()
get_RuntimePackageServiceHostPath()
get_RuntimePackageModulesPath()
get_ServiceRoot()
get_RuntimeDirectory()
get_ModulesDirectory()
get_ModuleDirectory()
get_ScriptDirectory()
get_ServiceName()
get_ServiceNameSet()
get_ScriptPath()
get_ScriptPathSet()
get_ScriptArguments()
get_ServiceLogPath()
get_ServiceUser()
get_ServicePassword()
get_ServiceContentRoot()
get_ServicePackageSet()
get_ServiceDeploymentRoot()
get_ServiceRuntimeSource()
get_ServiceRuntimePackage()
get_ServiceRuntimeVersion()
get_ServiceRuntimePackageId()
get_ServiceRuntimeCache()
get_ServiceContentRootChecksum()
get_ServiceContentRootChecksumAlgorithm()
get_ServiceContentRootBearerToken()
get_ServiceContentRootIgnoreCertificate()
get_ServiceFailbackRequested()
get_ServiceUseRepositoryKestrun()
get_ServiceJsonOutputRequested()
get_ServiceRawOutputRequested()
get_ServiceContentRootHeaders()
get_ServiceName()
get_ServiceHostExecutablePath()
get_RunnerExecutablePath()
get_ScriptPath()
get_ModuleManifestPath()
get_ScriptArguments()
get_ServiceLogPath()
get_ServiceUser()
get_ServicePassword()
TryParseServiceArguments(System.String[],System.Int32,System.String,System.String,Kestrun.Tool.Program/ParsedCommand&,System.String&)
CreateDefaultServiceParsedCommand(System.String,System.String)
TryResolveServiceMode(System.String[],System.Int32,Kestrun.Tool.Program/CommandMode&,System.String&)
TryParseServiceOptionLoop(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32,System.String&,System.String&,System.String&)
CreateServiceParsedCommand(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.String,System.String)
TryParseServiceMode(System.String,Kestrun.Tool.Program/CommandMode&,System.String&)
TryConsumeServiceOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&,System.String&,System.String&)
TryConsumeServiceJsonOption(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceRawOption(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceRepositoryKestrunOption(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceFailbackOption(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceScriptOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceNameOption(System.String[],Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeKestrunFolderOption(System.String[],System.String&,System.Int32&,System.String&)
TryConsumeKestrunManifestOption(System.String[],System.String&,System.Int32&,System.String,System.String&)
TryConsumeServiceLogPathOption(System.String[],Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceUserOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServicePasswordOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceDeploymentRootOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServicePackageOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceRuntimeSourceOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceRuntimePackageOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceRuntimeVersionOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceRuntimePackageIdOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceRuntimeCacheOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeDeprecatedServiceContentRootOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceContentRootChecksumOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceContentRootChecksumAlgorithmOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceContentRootBearerTokenOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceContentRootIgnoreCertificateOption(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeServiceContentRootHeaderOption(System.String[],Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.Int32&,System.String&)
TryConsumeOptionValue(System.String[],System.Int32&,System.String,System.String&,System.String&)
TryConsumeServicePositionalScript(System.String,Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.String&)
TryValidateServiceParseState(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.String&)
TryValidateServiceRuntimeOptions(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.String&)
TryValidateServiceUpdateOptions(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.String&)
TryValidateServiceName(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.String&)
ApplyDefaultServiceInstallScript(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState)
TryValidateServiceCredentialOptions(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.String&)
TryValidateServiceContentRootDependentOptions(Kestrun.Tool.Program/CommandMode,Kestrun.Tool.Program/ServiceParseState,System.String&)
TryValidateServiceInstallScriptContentRootSelection(Kestrun.Tool.Program/ServiceParseState,System.Boolean,System.String&)
HasServiceRuntimeAcquisitionRequest(Kestrun.Tool.Program/ServiceParseState)
TryValidateServiceInstallPackageExtension(Kestrun.Tool.Program/ServiceParseState,System.Boolean,System.String&)
TryValidateServiceContentRootLinkedOptions(Kestrun.Tool.Program/ServiceParseState,System.Boolean,System.String&)
TryValidateServiceContentRootChecksumOptions(Kestrun.Tool.Program/ServiceParseState,System.Boolean,System.String&)
TryValidateContentRootLinkedOption(System.Boolean,System.Boolean,System.String,System.String&)
TryParseServiceRegisterArguments(System.String[],Kestrun.Tool.Program/ServiceRegisterOptions&,System.String&)
TryParseServiceRegisterOptionLoop(System.String[],Kestrun.Tool.Program/ServiceRegisterParseState,System.String&)
TryConsumeServiceRegisterOption(System.String[],Kestrun.Tool.Program/ServiceRegisterParseState,System.Int32&,System.String&)
TryApplyServiceRegisterOptionValue(System.String,System.String,Kestrun.Tool.Program/ServiceRegisterParseState,System.String&)
IsServiceRegisterOptionWithValue(System.String)
TryConsumeServiceRegisterOptionValue(System.String[],System.Int32&,System.String,System.String&)
TryBuildServiceRegisterOptions(Kestrun.Tool.Program/ServiceRegisterParseState,Kestrun.Tool.Program/ServiceRegisterOptions&,System.String&)
TryResolveServiceRuntimePackage(System.String,System.String,System.String,System.String,System.String,System.String,System.String[],System.Boolean,System.Boolean,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&,System.String&)
HasExplicitRuntimeOverride(System.String,System.String,System.String,System.String,System.String)
GetEffectiveRuntimePackageId(System.String,System.String)
NormalizeRuntimeVersion(System.String)
TryResolveServiceRuntimePackageFromCacheOrSources(System.String,System.String,System.String,System.String,System.String,System.String,System.String[],System.Boolean,System.Boolean,System.Boolean,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&,System.String&)
BuildRuntimePackageNotFoundError(System.String,System.String,System.String,System.Boolean,System.Collections.Generic.IReadOnlyList`1<System.String>)
TryResolveServiceRuntimePackageFromToolDistribution(System.String,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&)
TryResolveServiceRuntimePackageFromExplicitPackage(System.String,System.String,System.String,System.String,System.String,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&,System.String&)
TryLoadAndValidateExplicitRuntimePackage(System.String,System.String,System.String,System.String,System.String&,System.Byte[]&,System.String&,System.String&,System.String&)
TryResolveExplicitRuntimePackagePath(System.String,System.String,System.String,System.String&,System.String&)
TryResolveExplicitRuntimePackageFromDirectory(System.String,System.String,System.String,System.String&,System.String&)
TryPrepareExplicitRuntimePackageCacheEntry(System.String,System.String,System.String,System.String,System.String&)
TryResolveServiceRuntimePackageFromSource(System.String,System.String,System.String,System.String,System.String,System.String,System.String[],System.Boolean,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&,System.String&)
TryEnsureCachedRuntimePackageForSource(System.String,System.String,System.String,System.String,System.String,System.String[],System.Boolean,System.String&,System.String&)
TryLoadAndValidateCachedRuntimePackage(System.String,System.String,System.String,System.Byte[]&,System.String&)
TryResolveServiceRuntimePackageFromExpandedCache(System.String,System.String,System.String,System.String,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&,System.String&)
TryPrepareResolvedServiceRuntimePackage(System.String,System.String,System.String,System.String,System.Byte[],System.String,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&,System.String&)
GetServiceRuntimeSourceCandidates(System.String)
TryResolveRuntimeCacheRoot(System.String,System.String&,System.String&)
GetDefaultRuntimeCacheRoot()
TryResolveServiceRuntimePackageFromDirectSource(System.String,System.String,System.String,System.String,System.String,System.String,System.String[],System.Boolean,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&,System.String&,System.Boolean&)
GetDefaultServiceRuntimePackageVersion()
TryAcquireRuntimePackageFromSource(System.String,System.String,System.String,System.String,System.String,System.String[],System.Boolean,System.String&)
TryResolveDirectRuntimeSourceLocalPackagePath(System.String,System.String&,System.Boolean&,System.String&)
TryResolveDirectRuntimeSourceExistingFilePath(System.String,System.String&,System.Boolean&,System.String&)
TryResolveDirectRuntimeSourceFileUri(System.String,System.String&,System.Boolean&,System.String&)
IsDirectRuntimePackageUri(System.Uri)
GetDirectRuntimePackageDownloadPath(System.String,System.String,System.String,System.Uri)
TryCopyRuntimePackageFromLocalSource(System.String,System.String,System.String,System.String,System.String&)
TryCleanupEmptyRuntimePackageDirectory(System.String,System.String)
TryDownloadRuntimePackageFromSource(System.Uri,System.String,System.String,System.String,System.String,System.String[],System.Boolean,System.String&)
TryDownloadRuntimePackageFile(System.Uri,System.String,System.String,System.String[],System.Boolean,System.String&)
TryResolvePackageBaseAddress(System.Uri,System.String,System.String[],System.Boolean,System.Uri&,System.String&)
TryResolveFlatContainerBaseAddress(System.Uri,System.Uri&)
TryResolvePackageBaseAddressFromServiceIndexDocument(System.Uri,System.Text.Json.JsonDocument,System.Uri&,System.String&)
TryGetRuntimeSourceResourcesArray(System.Uri,System.Text.Json.JsonDocument,System.Text.Json.JsonElement&,System.String&)
TryResolvePackageBaseAddressFromResources(System.Text.Json.JsonElement,System.Uri&)
TryResolvePackageBaseAddressFromResource(System.Text.Json.JsonElement,System.Uri&)
IsPackageBaseAddressResourceType(System.Text.Json.JsonElement)
TryGetRuntimeSourceJsonDocument(System.Uri,System.String,System.String[],System.Boolean,System.Text.Json.JsonDocument&,System.String&)
TryEnsureExtractedServiceRuntimePackage(System.Byte[],System.String,System.String&)
TryValidateExtractedServiceRuntimePackagePayload(System.String,System.String,System.String&)
TryValidateExtractedRuntimePackagePreconditions(System.String,System.String,System.String&)
TryValidateRuntimePackageHostPath(System.String,System.String,System.String&)
TryValidateRuntimePackageModulesPath(System.String,System.String,System.Text.Json.JsonElement,System.String&)
TryResolveRuntimePackageHostPath(System.String,System.String,System.Text.Json.JsonElement,System.String&,System.String&)
TryResolveRuntimeManifestPayloadPath(System.String,System.String,System.String,System.String,System.String&,System.String&)
TryWriteRuntimePackageExtractionMarker(System.String)
TryResolveExtractedServiceRuntimePackageLayout(System.String,System.String,System.String,System.String,System.String,System.Boolean,Kestrun.Tool.Program/ResolvedServiceRuntimePackage&,System.String&)
TryReadRuntimePackageManifest(System.String,System.String,System.String&,System.String&,System.String&)
TryReadPackageIdentity(System.Byte[],System.String&,System.String&)
SanitizePathToken(System.String)