< Summary - Kestrun — Combined Coverage

Line coverage
63%
Covered lines: 2980
Uncovered lines: 1713
Coverable lines: 4693
Total lines: 11769
Line coverage: 63.4%
Branch coverage
57%
Covered branches: 1532
Total branches: 2681
Branch coverage: 57.1%
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@844b5179fb0492dc6b1182bae3ff65fa7365521d 03/26/2026 - 03:54:59 Line coverage: 63.4% (2980/4693) Branch coverage: 57.1% (1532/2681) Total lines: 11769 Tag: Kestrun/Kestrun@844b5179fb0492dc6b1182bae3ff65fa7365521d

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(...)80%101092%
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(...)62.5%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_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_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: .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 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(...)0%4260%
File 5: TryResolveInstallServiceInputs(...)66.67%141274.29%
File 5: TryCleanupTemporaryServiceContentRoot(...)100%4471.43%
File 5: TryRunInstallServicePreflight(...)0%342180%
File 5: TryPrepareInstallServiceBundle(...)0%2040%
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%252486.9%
File 5: WriteServiceInfoHumanReadable(...)58.33%121287.5%
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%10866.67%
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(...)81.25%171685.19%
File 5: TryRestorePreservedPaths(...)100%6682.35%
File 5: TryNormalizePreservePath(...)50%11862.5%
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%
File 5: TryResolveServiceBundleContext(...)50%9876.47%
File 5: RecreateServiceBundleDirectories(...)50%2285.71%
File 5: CopyServiceRuntimeExecutable(...)100%11100%
File 5: TryCopyServiceHostExecutable(...)25%4475%
File 5: TryCopyBundledToolModules(...)50%2281.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_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_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(...)90.91%11111096.15%
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: 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%1010100%
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(...)83.33%7675%
File 6: TryValidateServiceInstallPackageExtension(...)22.22%1921818.75%
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(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    {
 3638        if (string.IsNullOrWhiteSpace(path))
 639        {
 0640            return;
 641        }
 642
 643        try
 644        {
 3645            if (File.Exists(path))
 646            {
 3647                File.Delete(path);
 648            }
 3649        }
 0650        catch
 651        {
 652            // Best-effort cleanup only.
 0653        }
 3654    }
 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    {
 51242        runtimeExecutablePath = string.Empty;
 51243        error = string.Empty;
 1244
 51245        var fullManifestPath = Path.GetFullPath(moduleManifestPath);
 51246        var moduleRoot = Path.GetDirectoryName(fullManifestPath);
 51247        if (string.IsNullOrWhiteSpace(moduleRoot) || !Directory.Exists(moduleRoot))
 1248        {
 01249            error = $"Unable to resolve module root from manifest path: {fullManifestPath}";
 01250            return false;
 1251        }
 1252
 51253        if (!TryGetServiceRuntimeRid(out var runtimeRid, out var ridError))
 1254        {
 01255            error = ridError;
 01256            return false;
 1257        }
 1258
 51259        var runtimeBinaryName = OperatingSystem.IsWindows() ? WindowsServiceRuntimeBinaryName : UnixServiceRuntimeBinary
 231260        foreach (var candidate in EnumerateServiceRuntimeExecutableCandidates(moduleRoot, runtimeRid, runtimeBinaryName)
 1261        {
 91262            if (!File.Exists(candidate))
 1263            {
 1264                continue;
 1265            }
 1266
 51267            runtimeExecutablePath = Path.GetFullPath(candidate);
 51268            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;
 51288    }
 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    {
 51299        var candidates = new List<string>
 51300        {
 51301            Path.Combine(moduleRoot, "runtimes", runtimeRid, runtimeBinaryName),
 51302            Path.Combine(moduleRoot, "lib", "runtimes", runtimeRid, runtimeBinaryName),
 51303            Path.Combine(GetExecutableDirectory(), "runtimes", runtimeRid, runtimeBinaryName),
 51304            Path.Combine(GetExecutableDirectory(), "lib", "runtimes", runtimeRid, runtimeBinaryName),
 51305        };
 1306
 51307        var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory);
 51308        var executableDirectory = GetExecutableDirectory();
 51309        if (!string.Equals(baseDirectory, executableDirectory, StringComparison.OrdinalIgnoreCase))
 1310        {
 51311            candidates.Add(Path.Combine(baseDirectory, "runtimes", runtimeRid, runtimeBinaryName));
 51312            candidates.Add(Path.Combine(baseDirectory, "lib", "runtimes", runtimeRid, runtimeBinaryName));
 1313        }
 1314
 581315        foreach (var parent in EnumerateDirectoryAndParents(Environment.CurrentDirectory))
 1316        {
 241317            candidates.Add(Path.Combine(parent, "src", "PowerShell", "Kestrun", "runtimes", runtimeRid, runtimeBinaryNam
 241318            candidates.Add(Path.Combine(parent, "src", "PowerShell", "Kestrun", "lib", "runtimes", runtimeRid, runtimeBi
 1319        }
 1320
 231321        foreach (var candidate in candidates.Distinct(StringComparer.OrdinalIgnoreCase))
 1322        {
 91323            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    {
 151456        var current = Path.GetFullPath(startDirectory);
 681457        while (!string.IsNullOrWhiteSpace(current))
 1458        {
 681459            yield return current;
 1460
 661461            var parent = Directory.GetParent(current);
 661462            if (parent is null)
 1463            {
 1464                break;
 1465            }
 1466
 531467            current = parent.FullName;
 1468        }
 131469    }
 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    {
 201480        var optionFlags = GetServiceContentRootOptionFlags(command);
 201481        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
 191487        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
 191494        if (command.ScriptPathProvided)
 1495        {
 01496            scriptSource = CreateEmptyResolvedServiceScriptSource();
 01497            error = "--script (or positional script path) is not supported when --package/--content-root is used. Define
 01498            return false;
 1499        }
 1500
 191501        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.
 191504        return TryResolveServiceScriptFromContentRoot(
 191505            command,
 191506            requestedScriptPath,
 191507            contentRootUri,
 191508            fullContentRoot,
 191509            optionFlags,
 191510            out scriptSource,
 191511            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    {
 221528        normalizedContentRoot = string.Empty;
 221529        contentRootUri = null;
 221530        fullContentRoot = string.Empty;
 1531
 221532        if (string.IsNullOrWhiteSpace(rawContentRoot))
 1533        {
 21534            return false;
 1535        }
 1536
 201537        normalizedContentRoot = rawContentRoot.Trim();
 201538        if (TryParseServiceContentRootHttpUri(normalizedContentRoot, out var parsedUri))
 1539        {
 71540            contentRootUri = parsedUri;
 71541            return true;
 1542        }
 1543
 131544        fullContentRoot = Path.GetFullPath(normalizedContentRoot);
 131545        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    {
 191568        if (contentRootUri is not null)
 1569        {
 61570            return TryResolveServiceScriptFromHttpContentRoot(command, requestedScriptPath, contentRootUri, out scriptSo
 1571        }
 1572
 131573        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.
 71579        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,
 131587        bool HasBearerToken,
 131588        bool IgnoreCertificate,
 121589        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)
 201598        => string.IsNullOrWhiteSpace(scriptPath)
 201599            ? (useDefaultWhenMissing ? ServiceDefaultScriptFileName : string.Empty)
 201600            : 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)
 201608        => new(
 201609            !string.IsNullOrWhiteSpace(command.ServiceContentRootChecksum),
 201610            !string.IsNullOrWhiteSpace(command.ServiceContentRootBearerToken),
 201611            command.ServiceContentRootIgnoreCertificate,
 201612            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    {
 71783        scriptSource = CreateEmptyResolvedServiceScriptSource();
 71784        if (!File.Exists(fullContentRoot))
 1785        {
 11786            error = $"Service content root path was not found: {fullContentRoot}";
 11787            return false;
 1788        }
 1789
 61790        if (!TryValidateLocalArchiveContentRootOptions(optionFlags, out error))
 1791        {
 21792            return false;
 1793        }
 1794
 41795        if (!IsSupportedServiceContentRootArchive(fullContentRoot))
 1796        {
 01797            error = $"Unsupported package format. Supported extensions: {ServicePackageExtension}, .zip, .tar, .tgz, .ta
 01798            return false;
 1799        }
 1800
 41801        if (!TryValidateServiceContentRootArchiveChecksum(command, fullContentRoot, out error))
 1802        {
 11803            return false;
 1804        }
 1805
 31806        var extractedContentRoot = CreateServiceContentRootExtractionDirectory(command.ServiceName);
 1807        try
 1808        {
 31809            if (TryResolveServiceScriptFromExtractedArchiveContentRoot(
 31810                    requestedScriptPath,
 31811                    fullContentRoot,
 31812                    extractedContentRoot,
 31813                    out var extractedScriptSource,
 31814                    out error))
 1815            {
 31816                scriptSource = extractedScriptSource;
 31817                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        }
 31828    }
 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    {
 31846        scriptSource = CreateEmptyResolvedServiceScriptSource();
 1847
 31848        if (!TryExtractServiceContentRootArchive(fullContentRoot, extractedContentRoot, out error))
 1849        {
 01850            return false;
 1851        }
 1852
 31853        if (!TryResolveServiceInstallDescriptor(extractedContentRoot, out var descriptor, out error))
 1854        {
 01855            return false;
 1856        }
 1857
 31858        if (!TryResolveServiceDescriptorScriptPath(requestedScriptPath, descriptor, out var resolvedScriptPath, out erro
 1859        {
 01860            return false;
 1861        }
 1862
 31863        if (!TryResolveScriptFromResolvedContentRoot(
 31864                resolvedScriptPath,
 31865                extractedContentRoot,
 31866                $"Script path '{resolvedScriptPath}' escapes the extracted archive content root.",
 31867                $"Script file '{resolvedScriptPath}' was not found inside extracted archive '{fullContentRoot}'.",
 31868                extractedContentRoot,
 31869                out scriptSource,
 31870                out error))
 1871        {
 01872            return false;
 1873        }
 1874
 31875        scriptSource = ApplyDescriptorMetadata(scriptSource, descriptor);
 31876        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    {
 121923        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
 121929        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
 111935        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
 101941        error = string.Empty;
 101942        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 
 61969        => 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    {
 191998        descriptor = new ServiceInstallDescriptor(string.Empty, string.Empty, string.Empty, string.Empty, null, null, []
 191999        if (!TryReadNormalizedServiceDescriptorText(fullContentRoot, out var descriptorText, out error))
 2000        {
 02001            return false;
 2002        }
 2003
 192004        if (!TryResolveServiceDescriptorCoreFields(descriptorText, out var name, out var description, out var formatVers
 2005        {
 12006            return false;
 2007        }
 2008
 182009        if (!TryResolveServiceDescriptorEntryPointAndVersion(
 182010                descriptorText,
 182011                formatVersion,
 182012                out var normalizedFormatVersion,
 182013                out var entryPoint,
 182014                out var version,
 182015                out error))
 2016        {
 12017            return false;
 2018        }
 2019
 172020        _ = TryGetServiceDescriptorStringValue(descriptorText, "ServiceLogPath", required: false, out var serviceLogPath
 2021
 172022        if (!TryGetServiceDescriptorStringArrayValue(descriptorText, "PreservePaths", out var preservePaths, out error))
 2023        {
 02024            return false;
 2025        }
 2026
 172027        descriptor = new ServiceInstallDescriptor(
 172028            normalizedFormatVersion,
 172029            name,
 172030            entryPoint,
 172031            description,
 172032            version,
 172033            string.IsNullOrWhiteSpace(serviceLogPath) ? null : serviceLogPath,
 172034            preservePaths);
 172035        return true;
 2036    }
 2037
 2038    /// <summary>
 2039    /// Reads service descriptor text from disk and normalizes escaped newlines for regex parsing.
 2040    /// </summary>
 2041    /// <param name="fullContentRoot">Absolute content-root directory path.</param>
 2042    /// <param name="descriptorText">Normalized service descriptor text.</param>
 2043    /// <param name="error">Error details when file resolution or read fails.</param>
 2044    /// <returns>True when descriptor text is available and normalized.</returns>
 2045    private static bool TryReadNormalizedServiceDescriptorText(string fullContentRoot, out string descriptorText, out st
 2046    {
 192047        descriptorText = string.Empty;
 192048        var descriptorPath = Path.Combine(fullContentRoot, ServiceDescriptorFileName);
 192049        if (!File.Exists(descriptorPath))
 2050        {
 02051            error = $"Service descriptor file '{ServiceDescriptorFileName}' was not found at content-root '{fullContentR
 02052            return false;
 2053        }
 2054
 2055        try
 2056        {
 192057            descriptorText = File.ReadAllText(descriptorPath, Encoding.UTF8);
 192058        }
 02059        catch (Exception ex)
 2060        {
 02061            error = $"Failed to read service descriptor '{descriptorPath}': {ex.Message}";
 02062            return false;
 2063        }
 2064
 192065        descriptorText = NormalizeServiceDescriptorText(descriptorText);
 192066        error = string.Empty;
 192067        return true;
 02068    }
 2069
 2070    /// <summary>
 2071    /// Resolves required descriptor core fields and optional format version.
 2072    /// </summary>
 2073    /// <param name="descriptorText">Normalized service descriptor text.</param>
 2074    /// <param name="name">Resolved service name.</param>
 2075    /// <param name="description">Resolved service description.</param>
 2076    /// <param name="formatVersion">Optional format version token.</param>
 2077    /// <param name="error">Validation error details.</param>
 2078    /// <returns>True when required core fields are valid.</returns>
 2079    private static bool TryResolveServiceDescriptorCoreFields(
 2080        string descriptorText,
 2081        out string name,
 2082        out string description,
 2083        out string formatVersion,
 2084        out string error)
 2085    {
 192086        if (!TryGetServiceDescriptorStringValue(descriptorText, "Name", required: true, out name, out error))
 2087        {
 02088            description = string.Empty;
 02089            formatVersion = string.Empty;
 02090            return false;
 2091        }
 2092
 192093        if (!TryGetServiceDescriptorStringValue(descriptorText, "Description", required: true, out description, out erro
 2094        {
 12095            formatVersion = string.Empty;
 12096            return false;
 2097        }
 2098
 182099        _ = TryGetServiceDescriptorStringValue(descriptorText, "FormatVersion", required: false, out formatVersion, out 
 182100        error = string.Empty;
 182101        return true;
 2102    }
 2103
 2104    /// <summary>
 2105    /// Resolves descriptor entrypoint/version fields for legacy and format-1.0 descriptors.
 2106    /// </summary>
 2107    /// <param name="descriptorText">Normalized service descriptor text.</param>
 2108    /// <param name="formatVersion">Optional format version token.</param>
 2109    /// <param name="normalizedFormatVersion">Normalized format marker used by runtime metadata.</param>
 2110    /// <param name="entryPoint">Resolved script entrypoint path.</param>
 2111    /// <param name="version">Optional parsed version string.</param>
 2112    /// <param name="error">Validation error details.</param>
 2113    /// <returns>True when entrypoint/version resolution succeeds.</returns>
 2114    private static bool TryResolveServiceDescriptorEntryPointAndVersion(
 2115        string descriptorText,
 2116        string formatVersion,
 2117        out string normalizedFormatVersion,
 2118        out string entryPoint,
 2119        out string? version,
 2120        out string error)
 2121    {
 182122        version = null;
 2123
 182124        if (string.IsNullOrWhiteSpace(formatVersion))
 2125        {
 112126            normalizedFormatVersion = "legacy";
 112127            if (!TryGetServiceDescriptorStringValue(descriptorText, "Script", required: false, out entryPoint, out error
 2128            {
 02129                return false;
 2130            }
 2131
 112132            if (string.IsNullOrWhiteSpace(entryPoint))
 2133            {
 32134                entryPoint = ServiceDefaultScriptFileName;
 2135            }
 2136
 112137            return TryResolveOptionalServiceDescriptorVersion(descriptorText, out version, out error);
 2138        }
 2139
 72140        var trimmedFormatVersion = formatVersion.Trim();
 72141        normalizedFormatVersion = trimmedFormatVersion;
 72142        if (!string.Equals(trimmedFormatVersion, "1.0", StringComparison.Ordinal))
 2143        {
 02144            entryPoint = string.Empty;
 02145            error = "Service descriptor FormatVersion must be '1.0'.";
 02146            return false;
 2147        }
 2148
 72149        return TryGetServiceDescriptorStringValue(descriptorText, "EntryPoint", required: true, out entryPoint, out erro
 72150            && TryResolveOptionalServiceDescriptorVersion(descriptorText, out version, out error);
 2151    }
 2152
 2153    /// <summary>
 2154    /// Resolves and validates optional descriptor version metadata.
 2155    /// </summary>
 2156    /// <param name="descriptorText">Normalized service descriptor text.</param>
 2157    /// <param name="version">Resolved version string when present.</param>
 2158    /// <param name="error">Validation error details.</param>
 2159    /// <returns>True when version is absent or parseable by <see cref="Version"/>.</returns>
 2160    private static bool TryResolveOptionalServiceDescriptorVersion(string descriptorText, out string? version, out strin
 2161    {
 182162        version = null;
 182163        _ = TryGetServiceDescriptorStringValue(descriptorText, "Version", required: false, out var rawVersion, out _);
 182164        if (string.IsNullOrWhiteSpace(rawVersion))
 2165        {
 02166            error = string.Empty;
 02167            return true;
 2168        }
 2169
 182170        var trimmedVersion = rawVersion.Trim();
 182171        if (!Version.TryParse(trimmedVersion, out _))
 2172        {
 12173            error = $"Service descriptor '{ServiceDescriptorFileName}' key 'Version' must be compatible with System.Vers
 12174            return false;
 2175        }
 2176
 172177        version = trimmedVersion;
 172178        error = string.Empty;
 172179        return true;
 2180    }
 2181
 2182    /// <summary>
 2183    /// Normalizes service descriptor text for regex parsing.
 2184    /// </summary>
 2185    /// <param name="descriptorText">Raw descriptor text.</param>
 2186    /// <returns>Descriptor text with PowerShell escaped newline sequences expanded.</returns>
 2187    private static string NormalizeServiceDescriptorText(string descriptorText)
 192188        => descriptorText
 192189            .Replace("`r`n", "\n", StringComparison.Ordinal)
 192190            .Replace("`n", "\n", StringComparison.Ordinal)
 192191            .Replace("`r", "\n", StringComparison.Ordinal);
 2192
 2193    /// <summary>
 2194    /// Reads a string-valued key from Service.psd1.
 2195    /// </summary>
 2196    /// <param name="descriptorText">Raw descriptor content.</param>
 2197    /// <param name="key">Descriptor key name.</param>
 2198    /// <param name="required">True when a missing key should fail validation.</param>
 2199    /// <param name="value">Resolved string value.</param>
 2200    /// <param name="error">Validation error details when required values are missing.</param>
 2201    /// <returns>True when key resolution succeeded for the required/optional mode.</returns>
 2202    private static bool TryGetServiceDescriptorStringValue(string descriptorText, string key, bool required, out string 
 2203    {
 1092204        var match = Regex.Match(
 1092205            descriptorText,
 1092206            $@"(?mi)(?:^|[;{{\r\n])\s*{Regex.Escape(key)}\s*=\s*(?:'(?<single>[^']*)'|""(?<double>[^""]*)"")",
 1092207            RegexOptions.CultureInvariant);
 2208
 1092209        if (!match.Success)
 2210        {
 262211            value = string.Empty;
 262212            error = required
 262213                ? $"Service descriptor '{ServiceDescriptorFileName}' is missing required key '{key}'."
 262214                : string.Empty;
 262215            return !required;
 2216        }
 2217
 832218        value = (match.Groups["single"].Success ? match.Groups["single"].Value : match.Groups["double"].Value).Trim();
 832219        if (required && string.IsNullOrWhiteSpace(value))
 2220        {
 02221            error = $"Service descriptor '{ServiceDescriptorFileName}' key '{key}' must not be empty.";
 02222            return false;
 2223        }
 2224
 832225        error = string.Empty;
 832226        return true;
 2227    }
 2228
 2229    /// <summary>
 2230    /// Reads a string-array key from Service.psd1 using PowerShell array syntax: Key = @( 'a', 'b' ).
 2231    /// </summary>
 2232    /// <param name="descriptorText">Raw descriptor content.</param>
 2233    /// <param name="key">Descriptor key name.</param>
 2234    /// <param name="values">Resolved array values.</param>
 2235    /// <param name="error">Validation error details.</param>
 2236    /// <returns>True when array resolution succeeds.</returns>
 2237    private static bool TryGetServiceDescriptorStringArrayValue(string descriptorText, string key, out string[] values, 
 2238    {
 172239        values = [];
 172240        error = string.Empty;
 2241
 172242        var arrayMatch = Regex.Match(
 172243            descriptorText,
 172244            $@"(?mis)(?:^|[;{{\r\n])\s*{Regex.Escape(key)}\s*=\s*@\((?<items>.*?)\)",
 172245            RegexOptions.CultureInvariant);
 2246
 172247        if (!arrayMatch.Success)
 2248        {
 162249            return true;
 2250        }
 2251
 12252        var itemsText = arrayMatch.Groups["items"].Value;
 12253        var itemMatches = Regex.Matches(
 12254            itemsText,
 12255            "'(?<single>(?:''|[^'])*)'|\"(?<double>(?:\"\"|[^\"])*)\"",
 12256            RegexOptions.CultureInvariant);
 2257
 12258        if (itemMatches.Count == 0 && !string.IsNullOrWhiteSpace(itemsText))
 2259        {
 02260            error = $"Service descriptor '{ServiceDescriptorFileName}' key '{key}' must be a string array, for example: 
 02261            return false;
 2262        }
 2263
 12264        values = [.. itemMatches
 12265            .Select(static match =>
 12266            {
 32267                var raw = match.Groups["single"].Success
 32268                    ? match.Groups["single"].Value.Replace("''", "'", StringComparison.Ordinal)
 32269                    : match.Groups["double"].Value.Replace("\"\"", "\"", StringComparison.Ordinal);
 32270                return raw.Trim();
 12271            })
 42272            .Where(static path => !string.IsNullOrWhiteSpace(path))];
 12273        return true;
 2274    }
 2275
 2276    /// <summary>
 2277    /// Resolves the script path for descriptor-driven service installs.
 2278    /// </summary>
 2279    /// <param name="requestedScriptPath">Script path requested on the command line.</param>
 2280    /// <param name="descriptor">Resolved service descriptor.</param>
 2281    /// <param name="resolvedScriptPath">Final script path relative to content root.</param>
 2282    /// <param name="error">Validation error details.</param>
 2283    /// <returns>True when the resolved script path is valid.</returns>
 2284    private static bool TryResolveServiceDescriptorScriptPath(string requestedScriptPath, ServiceInstallDescriptor descr
 2285    {
 102286        if (!string.IsNullOrWhiteSpace(requestedScriptPath))
 2287        {
 02288            resolvedScriptPath = string.Empty;
 02289            error = "--script (or positional script path) is not supported when --package/--content-root is used. Define
 02290            return false;
 2291        }
 2292
 102293        resolvedScriptPath = descriptor.EntryPoint;
 2294
 102295        if (Path.IsPathRooted(resolvedScriptPath))
 2296        {
 12297            error = $"Service descriptor '{ServiceDescriptorFileName}' EntryPoint/Script must be a relative path within 
 12298            return false;
 2299        }
 2300
 92301        error = string.Empty;
 92302        return true;
 2303    }
 2304
 2305    /// <summary>
 2306    /// Applies descriptor metadata to a resolved service script source.
 2307    /// </summary>
 2308    /// <param name="scriptSource">Resolved script source.</param>
 2309    /// <param name="descriptor">Descriptor metadata.</param>
 2310    /// <returns>Script source enriched with descriptor metadata.</returns>
 2311    private static ResolvedServiceScriptSource ApplyDescriptorMetadata(ResolvedServiceScriptSource scriptSource, Service
 92312        => new(
 92313            scriptSource.FullScriptPath,
 92314            scriptSource.FullContentRoot,
 92315            scriptSource.RelativeScriptPath,
 92316            scriptSource.TemporaryContentRootPath,
 92317            descriptor.Name,
 92318            descriptor.Description,
 92319            descriptor.Version,
 92320            descriptor.ServiceLogPath,
 92321            descriptor.PreservePaths);
 2322
 2323    /// <summary>
 2324    /// Downloads and extracts an HTTP content-root archive into the supplied directory.
 2325    /// </summary>
 2326    /// <param name="command">Parsed service command.</param>
 2327    /// <param name="contentRootUri">HTTP(S) archive URI.</param>
 2328    /// <param name="temporaryRoot">Temporary root folder used for download output.</param>
 2329    /// <param name="downloadedContentRoot">Folder where the archive should be extracted.</param>
 2330    /// <param name="error">Error details when any stage fails.</param>
 2331    /// <returns>True when download, checksum, and extraction all succeed.</returns>
 2332    private static bool TryDownloadAndExtractHttpContentRoot(
 2333        ParsedCommand command,
 2334        Uri contentRootUri,
 2335        string temporaryRoot,
 2336        string downloadedContentRoot,
 2337        out string error)
 2338    {
 62339        if (!TryDownloadServiceContentRootArchive(
 62340                contentRootUri,
 62341                temporaryRoot,
 62342                command.ServiceContentRootBearerToken,
 62343                command.ServiceContentRootHeaders,
 62344                command.ServiceContentRootIgnoreCertificate,
 62345                out var downloadedArchivePath,
 62346                out error))
 2347        {
 32348            return false;
 2349        }
 2350
 2351        try
 2352        {
 32353            return
 32354                TryValidateServiceContentRootArchiveChecksum(command, downloadedArchivePath, out error) &&
 32355                TryExtractServiceContentRootArchive(downloadedArchivePath, downloadedContentRoot, out error);
 2356        }
 2357        finally
 2358        {
 2359            // Best-effort cleanup to avoid retaining large downloaded archives after extraction attempts.
 32360            TryDeleteFileQuietly(downloadedArchivePath);
 32361        }
 32362    }
 2363
 2364    /// <summary>
 2365    /// Resolves a relative script path from an already materialized content-root directory.
 2366    /// </summary>
 2367    /// <param name="requestedScriptPath">Requested relative script path.</param>
 2368    /// <param name="fullContentRoot">Absolute content-root path.</param>
 2369    /// <param name="escapedPathError">Error message used when the script path escapes the content root.</param>
 2370    /// <param name="missingScriptError">Error message used when the script file does not exist.</param>
 2371    /// <param name="temporaryContentRootPath">Optional temporary content-root path for cleanup ownership.</param>
 2372    /// <param name="scriptSource">Resolved script source details.</param>
 2373    /// <param name="error">Error details when validation fails.</param>
 2374    /// <returns>True when the script path resolves and exists inside the root.</returns>
 2375    private static bool TryResolveScriptFromResolvedContentRoot(
 2376        string requestedScriptPath,
 2377        string fullContentRoot,
 2378        string escapedPathError,
 2379        string missingScriptError,
 2380        string? temporaryContentRootPath,
 2381        out ResolvedServiceScriptSource scriptSource,
 2382        out string error)
 2383    {
 92384        scriptSource = CreateEmptyResolvedServiceScriptSource();
 92385        var fullScriptPathFromRoot = Path.GetFullPath(Path.Combine(fullContentRoot, requestedScriptPath));
 92386        if (!IsPathWithinDirectory(fullScriptPathFromRoot, fullContentRoot))
 2387        {
 02388            error = escapedPathError;
 02389            return false;
 2390        }
 2391
 92392        if (!File.Exists(fullScriptPathFromRoot))
 2393        {
 02394            error = missingScriptError;
 02395            return false;
 2396        }
 2397
 92398        var relativeScriptPath = Path.GetRelativePath(fullContentRoot, fullScriptPathFromRoot);
 92399        scriptSource = new ResolvedServiceScriptSource(fullScriptPathFromRoot, fullContentRoot, relativeScriptPath, temp
 92400        error = string.Empty;
 92401        return true;
 2402    }
 2403
 2404    /// <summary>
 2405    /// Creates an empty service-script-source placeholder.
 2406    /// </summary>
 2407    /// <returns>Empty resolved service script source value.</returns>
 2408    private static ResolvedServiceScriptSource CreateEmptyResolvedServiceScriptSource()
 362409        => new(string.Empty, null, string.Empty, null, null, null, null, null, []);
 2410
 2411    /// <summary>
 2412    /// Creates a temporary extraction directory for archive-based service content roots.
 2413    /// </summary>
 2414    /// <param name="serviceName">Optional service name for easier diagnostics.</param>
 2415    /// <returns>Newly created extraction directory path.</returns>
 2416    private static string CreateServiceContentRootExtractionDirectory(string? serviceName)
 2417    {
 92418        var safeServiceName = string.IsNullOrWhiteSpace(serviceName)
 92419            ? "service"
 92420            : string.Concat(serviceName.Where(ch => char.IsLetterOrDigit(ch) || ch is '-' or '_'));
 2421
 92422        if (string.IsNullOrWhiteSpace(safeServiceName))
 2423        {
 02424            safeServiceName = "service";
 2425        }
 2426
 92427        var extractionRoot = Path.Combine(Path.GetTempPath(), "kestrun-content-root", safeServiceName, Guid.NewGuid().To
 92428        _ = Directory.CreateDirectory(extractionRoot);
 92429        return extractionRoot;
 2430    }
 2431
 2432    /// <summary>
 2433    /// Parses an HTTP(S) URI for service content-root input.
 2434    /// </summary>
 2435    /// <param name="contentRootInput">Raw content-root input token.</param>
 2436    /// <param name="uri">Parsed HTTP(S) URI.</param>
 2437    /// <returns>True when input is an absolute HTTP(S) URI.</returns>
 2438    private static bool TryParseServiceContentRootHttpUri(string contentRootInput, out Uri uri)
 2439    {
 202440        if (Uri.TryCreate(contentRootInput, UriKind.Absolute, out var parsed)
 202441            && parsed is not null
 202442            && (parsed.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)
 202443                || parsed.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)))
 2444        {
 72445            uri = parsed;
 72446            return true;
 2447        }
 2448
 132449        uri = null!;
 132450        return false;
 2451    }
 2452
 2453    /// <summary>
 2454    /// Downloads a service content-root archive from HTTP(S) into a temporary directory.
 2455    /// </summary>
 2456    /// <param name="uri">HTTP(S) source URI.</param>
 2457    /// <param name="temporaryRoot">Temporary root directory for download output.</param>
 2458    /// <param name="archivePath">Downloaded archive path.</param>
 2459    /// <param name="error">Error details when download fails.</param>
 2460    /// <returns>True when archive is downloaded and has a supported extension.</returns>
 2461    private static bool TryDownloadServiceContentRootArchive(
 2462        Uri uri,
 2463        string temporaryRoot,
 2464        string? bearerToken,
 2465        string[] customHeaders,
 2466        bool ignoreCertificate,
 2467        out string archivePath,
 2468        out string error)
 2469    {
 62470        archivePath = string.Empty;
 62471        error = string.Empty;
 2472
 62473        if (ignoreCertificate && !uri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))
 2474        {
 02475            error = "--content-root-ignore-certificate is only valid for HTTPS URLs.";
 02476            return false;
 2477        }
 2478
 2479        try
 2480        {
 62481            using var request = new HttpRequestMessage(HttpMethod.Get, uri);
 62482            if (!string.IsNullOrWhiteSpace(bearerToken))
 2483            {
 12484                request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", bearerToken);
 2485            }
 2486
 62487            if (!TryApplyServiceContentRootCustomHeaders(request, customHeaders, out error))
 2488            {
 32489                return false;
 2490            }
 2491
 32492            if (!ignoreCertificate)
 2493            {
 32494                using var response = ServiceContentRootHttpClient.Send(request, HttpCompletionOption.ResponseHeadersRead
 32495                if (!response.IsSuccessStatusCode)
 2496                {
 02497                    error = $"Failed to download service content root from '{uri}'. HTTP {(int)response.StatusCode} {res
 02498                    return false;
 2499                }
 2500
 32501                return TryWriteDownloadedContentRootArchive(temporaryRoot, uri, response, out archivePath, out error);
 2502            }
 2503
 02504            using var insecureHandler = new HttpClientHandler
 02505            {
 02506                ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidat
 02507            };
 02508            using var insecureClient = new HttpClient(insecureHandler)
 02509            {
 02510                Timeout = TimeSpan.FromMinutes(5),
 02511            };
 02512            insecureClient.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0"));
 02513            using var insecureResponse = insecureClient.Send(request, HttpCompletionOption.ResponseHeadersRead);
 02514            if (!insecureResponse.IsSuccessStatusCode)
 2515            {
 02516                error = $"Failed to download service content root from '{uri}'. HTTP {(int)insecureResponse.StatusCode} 
 02517                return false;
 2518            }
 2519
 02520            return TryWriteDownloadedContentRootArchive(temporaryRoot, uri, insecureResponse, out archivePath, out error
 2521        }
 02522        catch (Exception ex)
 2523        {
 02524            error = $"Failed to download service content root from '{uri}': {ex.Message}";
 02525            return false;
 2526        }
 62527    }
 2528
 2529    /// <summary>
 2530    /// Applies custom request headers used for service content-root URL downloads.
 2531    /// </summary>
 2532    /// <param name="request">HTTP request message to update.</param>
 2533    /// <param name="customHeaders">Custom header tokens in <c>name:value</c> format.</param>
 2534    /// <param name="error">Validation error when a header token cannot be applied.</param>
 2535    /// <returns>True when all headers are valid and applied to the request.</returns>
 2536    private static bool TryApplyServiceContentRootCustomHeaders(HttpRequestMessage request, IReadOnlyList<string> custom
 2537    {
 62538        error = string.Empty;
 172539        foreach (var headerToken in customHeaders)
 2540        {
 42541            if (string.IsNullOrWhiteSpace(headerToken))
 2542            {
 02543                error = "--content-root-header value cannot be empty. Use <name:value>.";
 02544                return false;
 2545            }
 2546
 42547            var separatorIndex = headerToken.IndexOf(':');
 42548            if (separatorIndex <= 0)
 2549            {
 12550                error = $"Invalid --content-root-header value '{headerToken}'. Use <name:value>.";
 12551                return false;
 2552            }
 2553
 32554            var headerName = headerToken[..separatorIndex].Trim();
 32555            var headerValue = headerToken[(separatorIndex + 1)..].Trim();
 32556            if (string.IsNullOrWhiteSpace(headerName))
 2557            {
 02558                error = $"Invalid --content-root-header value '{headerToken}'. Header name cannot be empty.";
 02559                return false;
 2560            }
 2561
 32562            if (headerName.Contains('\r') || headerName.Contains('\n'))
 2563            {
 12564                error = $"Invalid --content-root-header value '{headerToken}'. Header name cannot contain CR or LF chara
 12565                return false;
 2566            }
 2567
 22568            if (headerValue.Contains('\r') || headerValue.Contains('\n'))
 2569            {
 12570                error = $"Invalid --content-root-header value '{headerToken}'. Header value cannot contain CR or LF char
 12571                return false;
 2572            }
 2573
 12574            if (!request.Headers.TryAddWithoutValidation(headerName, headerValue))
 2575            {
 02576                error = $"Invalid --content-root-header value '{headerToken}'.";
 02577                return false;
 2578            }
 2579        }
 2580
 32581        return true;
 32582    }
 2583
 2584    /// <summary>
 2585    /// Writes a downloaded service content-root archive response to disk.
 2586    /// </summary>
 2587    /// <param name="temporaryRoot">Temporary root directory for archive output.</param>
 2588    /// <param name="uri">Source URI.</param>
 2589    /// <param name="response">HTTP response with archive payload.</param>
 2590    /// <param name="archivePath">Written archive path.</param>
 2591    /// <param name="error">Error details when write or validation fails.</param>
 2592    /// <returns>True when the archive is written and has a supported extension.</returns>
 2593    private static bool TryWriteDownloadedContentRootArchive(
 2594        string temporaryRoot,
 2595        Uri uri,
 2596        HttpResponseMessage response,
 2597        out string archivePath,
 2598        out string error)
 2599    {
 32600        archivePath = string.Empty;
 32601        error = string.Empty;
 2602
 32603        var resolvedFileName = TryResolveServiceContentRootArchiveFileName(uri, response)
 32604            ?? "content-root";
 32605        resolvedFileName = GetSafeServiceContentRootArchiveFileName(resolvedFileName, "content-root");
 2606
 32607        var provisionalArchivePath = Path.Combine(temporaryRoot, resolvedFileName);
 32608        using (var sourceStream = response.Content.ReadAsStream())
 32609        using (var destinationStream = File.Create(provisionalArchivePath))
 2610        {
 32611            sourceStream.CopyTo(destinationStream);
 32612        }
 2613
 32614        if (!TryResolveDownloadedServiceContentRootArchiveFileName(
 32615                uri,
 32616                resolvedFileName,
 32617                provisionalArchivePath,
 32618                response,
 32619                out var finalizedFileName,
 32620                out error))
 2621        {
 2622            try
 2623            {
 02624                if (File.Exists(provisionalArchivePath))
 2625                {
 02626                    File.Delete(provisionalArchivePath);
 2627                }
 02628            }
 02629            catch
 2630            {
 2631                // Ignore cleanup errors because the original archive-validation error is more actionable.
 02632            }
 02633            return false;
 2634        }
 2635
 32636        archivePath = provisionalArchivePath;
 32637        if (!string.Equals(finalizedFileName, resolvedFileName, StringComparison.OrdinalIgnoreCase))
 2638        {
 12639            var finalizedArchivePath = Path.Combine(temporaryRoot, finalizedFileName);
 12640            File.Move(provisionalArchivePath, finalizedArchivePath, overwrite: true);
 12641            archivePath = finalizedArchivePath;
 2642        }
 2643
 32644        return true;
 2645    }
 2646
 2647    /// <summary>
 2648    /// Converts an archive file name candidate to a filesystem-safe file name.
 2649    /// </summary>
 2650    /// <param name="candidate">Raw file name candidate from headers or URI metadata.</param>
 2651    /// <param name="fallbackFileName">Fallback file name when the candidate is empty or invalid.</param>
 2652    /// <returns>Filesystem-safe file name.</returns>
 2653    private static string GetSafeServiceContentRootArchiveFileName(string? candidate, string fallbackFileName)
 2654    {
 32655        var fileName = Path.GetFileName(candidate ?? string.Empty);
 32656        if (string.IsNullOrWhiteSpace(fileName))
 2657        {
 02658            return fallbackFileName;
 2659        }
 2660
 32661        var invalidChars = Path.GetInvalidFileNameChars();
 32662        var builder = new StringBuilder(fileName.Length);
 502663        foreach (var ch in fileName)
 2664        {
 222665            if (char.IsControl(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar || inval
 2666            {
 02667                _ = builder.Append('-');
 02668                continue;
 2669            }
 2670
 222671            _ = builder.Append(ch);
 2672        }
 2673
 32674        var sanitized = builder.ToString().Trim().Trim('.');
 32675        return string.IsNullOrWhiteSpace(sanitized) ? fallbackFileName : sanitized;
 2676    }
 2677
 2678    /// <summary>
 2679    /// Resolves a supported archive file name for a downloaded content-root payload.
 2680    /// </summary>
 2681    /// <param name="uri">Source URI.</param>
 2682    /// <param name="resolvedFileName">Initially resolved file name candidate.</param>
 2683    /// <param name="downloadedArchivePath">Downloaded archive payload path.</param>
 2684    /// <param name="response">HTTP response metadata.</param>
 2685    /// <param name="finalizedFileName">Finalized archive file name with supported extension.</param>
 2686    /// <param name="error">Validation error details when archive type cannot be resolved.</param>
 2687    /// <returns>True when a supported archive file name is resolved.</returns>
 2688    private static bool TryResolveDownloadedServiceContentRootArchiveFileName(
 2689        Uri uri,
 2690        string resolvedFileName,
 2691        string downloadedArchivePath,
 2692        HttpResponseMessage response,
 2693        out string finalizedFileName,
 2694        out string error)
 2695    {
 32696        finalizedFileName = resolvedFileName;
 32697        error = string.Empty;
 2698
 32699        if (IsSupportedServiceContentRootArchive(finalizedFileName))
 2700        {
 22701            return true;
 2702        }
 2703
 12704        var mediaType = response.Content.Headers.ContentType?.MediaType;
 12705        if (TryGetServiceContentRootArchiveExtensionFromMediaType(mediaType, out var archiveExtension)
 12706            || TryDetectServiceContentRootArchiveExtensionFromSignature(downloadedArchivePath, out archiveExtension))
 2707        {
 12708            finalizedFileName = BuildServiceContentRootArchiveFileName(resolvedFileName, archiveExtension);
 12709            return true;
 2710        }
 2711
 02712        error = $"Downloaded package from '{uri}' is not a supported archive. Supported extensions: {ServicePackageExten
 02713        return false;
 2714    }
 2715
 2716    /// <summary>
 2717    /// Maps content-type metadata to a preferred service content-root archive extension.
 2718    /// </summary>
 2719    /// <param name="mediaType">HTTP response media type.</param>
 2720    /// <param name="archiveExtension">Resolved archive extension when available.</param>
 2721    /// <returns>True when the media type maps to a supported archive extension.</returns>
 2722    private static bool TryGetServiceContentRootArchiveExtensionFromMediaType(string? mediaType, out string archiveExten
 2723    {
 62724        switch (mediaType?.ToLowerInvariant())
 2725        {
 2726            case "application/zip":
 2727            case "application/x-zip-compressed":
 12728                archiveExtension = ".zip";
 12729                return true;
 2730            case "application/x-tar":
 12731                archiveExtension = ".tar";
 12732                return true;
 2733            case "application/gzip":
 2734            case "application/x-gzip":
 22735                archiveExtension = ".tgz";
 22736                return true;
 2737            default:
 22738                archiveExtension = string.Empty;
 22739                return false;
 2740        }
 2741    }
 2742
 2743    /// <summary>
 2744    /// Detects archive extension from file signature when metadata does not provide a usable file name.
 2745    /// </summary>
 2746    /// <param name="archivePath">Downloaded archive payload path.</param>
 2747    /// <param name="archiveExtension">Detected archive extension.</param>
 2748    /// <returns>True when a supported archive signature is recognized.</returns>
 2749    private static bool TryDetectServiceContentRootArchiveExtensionFromSignature(string archivePath, out string archiveE
 2750    {
 52751        archiveExtension = string.Empty;
 2752        try
 2753        {
 52754            Span<byte> signature = stackalloc byte[512];
 52755            using var stream = File.OpenRead(archivePath);
 52756            var bytesRead = stream.Read(signature);
 52757            if (bytesRead <= 0)
 2758            {
 02759                return false;
 2760            }
 2761
 52762            if (bytesRead >= 4
 52763                && signature[0] == 0x50
 52764                && signature[1] == 0x4B
 52765                && ((signature[2] == 0x03 && signature[3] == 0x04)
 52766                    || (signature[2] == 0x05 && signature[3] == 0x06)
 52767                    || (signature[2] == 0x07 && signature[3] == 0x08)))
 2768            {
 12769                archiveExtension = ".zip";
 12770                return true;
 2771            }
 2772
 42773            if (bytesRead >= 2 && signature[0] == 0x1F && signature[1] == 0x8B)
 2774            {
 22775                archiveExtension = ".tgz";
 22776                return true;
 2777            }
 2778
 22779            if (bytesRead >= 262
 22780                && signature[257] == (byte)'u'
 22781                && signature[258] == (byte)'s'
 22782                && signature[259] == (byte)'t'
 22783                && signature[260] == (byte)'a'
 22784                && signature[261] == (byte)'r')
 2785            {
 12786                archiveExtension = ".tar";
 12787                return true;
 2788            }
 2789
 12790            return false;
 2791        }
 02792        catch
 2793        {
 02794            return false;
 2795        }
 52796    }
 2797
 2798    /// <summary>
 2799    /// Builds a normalized archive file name using the detected archive extension.
 2800    /// </summary>
 2801    /// <param name="resolvedFileName">Initially resolved file name candidate.</param>
 2802    /// <param name="archiveExtension">Detected archive extension.</param>
 2803    /// <returns>Normalized file name with archive extension.</returns>
 2804    private static string BuildServiceContentRootArchiveFileName(string resolvedFileName, string archiveExtension)
 2805    {
 42806        var baseName = Path.GetFileNameWithoutExtension(resolvedFileName);
 42807        if (archiveExtension.Equals(".tar.gz", StringComparison.OrdinalIgnoreCase)
 42808            && resolvedFileName.EndsWith(".tar", StringComparison.OrdinalIgnoreCase))
 2809        {
 12810            baseName = Path.GetFileNameWithoutExtension(baseName);
 2811        }
 2812
 42813        if (string.IsNullOrWhiteSpace(baseName))
 2814        {
 12815            baseName = "content-root";
 2816        }
 2817
 42818        return $"{baseName}{archiveExtension}";
 2819    }
 2820
 2821    /// <summary>
 2822    /// Resolves a best-effort archive file name from response headers and URI metadata.
 2823    /// </summary>
 2824    /// <param name="uri">Source URI.</param>
 2825    /// <param name="response">HTTP response.</param>
 2826    /// <returns>Resolved file name when available; otherwise null.</returns>
 2827    private static string? TryResolveServiceContentRootArchiveFileName(Uri uri, HttpResponseMessage response)
 2828    {
 62829        var contentDisposition = response.Content.Headers.ContentDisposition;
 62830        var dispositionFileName = contentDisposition?.FileNameStar ?? contentDisposition?.FileName;
 62831        if (!string.IsNullOrWhiteSpace(dispositionFileName))
 2832        {
 12833            var trimmed = dispositionFileName.Trim().Trim('"');
 12834            if (!string.IsNullOrWhiteSpace(trimmed))
 2835            {
 12836                return trimmed;
 2837            }
 2838        }
 2839
 52840        var uriFileName = Path.GetFileName(uri.AbsolutePath);
 52841        if (!string.IsNullOrWhiteSpace(uriFileName))
 2842        {
 42843            return uriFileName;
 2844        }
 2845
 2846        // Fall back to media-type-based extension inference when no usable file name metadata is available,
 2847        // to at least get a correct extension for archive type detection and validation even if the base name is generi
 12848        return TryGetServiceContentRootArchiveExtensionFromMediaType(
 12849            response.Content.Headers.ContentType?.MediaType,
 12850            out var archiveExtension)
 12851            ? BuildServiceContentRootArchiveFileName("content-root", archiveExtension)
 12852            : null;
 2853    }
 2854
 2855    /// <summary>
 2856    /// Returns true when the package archive path uses the supported extension.
 2857    /// </summary>
 2858    /// <param name="archivePath">Archive file path.</param>
 2859    /// <returns>True when archive extension is supported.</returns>
 2860    private static bool IsSupportedServiceContentRootArchive(string archivePath)
 2861    {
 132862        var lowerPath = archivePath.ToLowerInvariant();
 132863        return lowerPath.EndsWith(ServicePackageExtension, StringComparison.Ordinal)
 132864            || lowerPath.EndsWith(".zip", StringComparison.Ordinal)
 132865            || lowerPath.EndsWith(".tar", StringComparison.Ordinal)
 132866            || lowerPath.EndsWith(".tar.gz", StringComparison.Ordinal)
 132867            || lowerPath.EndsWith(".tgz", StringComparison.Ordinal);
 2868    }
 2869
 2870    /// <summary>
 2871    /// Validates a content-root archive checksum when a checksum was provided.
 2872    /// </summary>
 2873    /// <param name="command">Parsed service command.</param>
 2874    /// <param name="archivePath">Archive path to validate.</param>
 2875    /// <param name="error">Error details when checksum validation fails.</param>
 2876    /// <returns>True when checksum is valid or not requested.</returns>
 2877    private static bool TryValidateServiceContentRootArchiveChecksum(ParsedCommand command, string archivePath, out stri
 2878    {
 102879        error = string.Empty;
 102880        if (string.IsNullOrWhiteSpace(command.ServiceContentRootChecksum))
 2881        {
 62882            return true;
 2883        }
 2884
 42885        var expectedHash = command.ServiceContentRootChecksum.Trim();
 42886        if (!Regex.IsMatch(expectedHash, "^[0-9a-fA-F]+$"))
 2887        {
 12888            error = "--content-root-checksum must be a hexadecimal hash string.";
 12889            return false;
 2890        }
 2891
 32892        var algorithmName = string.IsNullOrWhiteSpace(command.ServiceContentRootChecksumAlgorithm)
 32893            ? "sha256"
 32894            : command.ServiceContentRootChecksumAlgorithm.Trim();
 2895
 32896        if (!TryCreateChecksumAlgorithm(algorithmName, out var algorithm, out var normalizedAlgorithmName, out error))
 2897        {
 02898            return false;
 2899        }
 2900
 32901        using (algorithm)
 32902        using (var stream = File.OpenRead(archivePath))
 2903        {
 32904            var actualHash = Convert.ToHexString(algorithm.ComputeHash(stream));
 32905            if (!string.Equals(actualHash, expectedHash, StringComparison.OrdinalIgnoreCase))
 2906            {
 22907                error = $"Archive checksum mismatch for '{archivePath}'. Expected {normalizedAlgorithmName}:{expectedHas
 22908                return false;
 2909            }
 12910        }
 2911
 12912        return true;
 22913    }
 2914
 2915    /// <summary>
 2916    /// Creates a hash algorithm instance from a user-provided token.
 2917    /// </summary>
 2918    /// <param name="algorithmToken">Algorithm token from CLI.</param>
 2919    /// <param name="algorithm">Created hash algorithm instance.</param>
 2920    /// <param name="normalizedName">Normalized algorithm name.</param>
 2921    /// <param name="error">Validation error text when algorithm creation fails.</param>
 2922    /// <returns>True when the algorithm token is supported and can be created.</returns>
 2923    private static bool TryCreateChecksumAlgorithm(string algorithmToken, out HashAlgorithm algorithm, out string normal
 2924    {
 52925        algorithm = null!;
 52926        normalizedName = string.Empty;
 52927        error = string.Empty;
 2928
 52929        var compact = algorithmToken.Replace("-", string.Empty, StringComparison.Ordinal).Trim().ToLowerInvariant();
 2930
 52931        Func<HashAlgorithm>? algorithmFactory = compact switch
 52932        {
 02933            "md5" => MD5.Create,
 02934            "sha1" or "sha" => SHA1.Create,
 42935            "sha2" or "sha256" => SHA256.Create,
 02936            "sha384" => SHA384.Create,
 02937            "sha512" => SHA512.Create,
 12938            _ => null,
 52939        };
 2940
 52941        if (algorithmFactory is null)
 2942        {
 12943            error = "Unsupported --content-root-checksum-algorithm. Supported values: md5, sha1, sha256, sha384, sha512.
 12944            return false;
 2945        }
 2946
 42947        normalizedName = compact switch
 42948        {
 02949            "sha" => "sha1",
 12950            "sha2" => "sha256",
 32951            _ => compact,
 42952        };
 2953
 2954        try
 2955        {
 42956            algorithm = algorithmFactory();
 42957            return true;
 2958        }
 02959        catch (Exception ex)
 2960        {
 02961            error = $"Unable to create checksum algorithm '{normalizedName}'. The algorithm may be disabled by system po
 02962            algorithm = null!;
 02963            normalizedName = string.Empty;
 02964            return false;
 2965        }
 42966    }
 2967
 2968    /// <summary>
 2969    /// Extracts a supported archive into the specified target directory.
 2970    /// </summary>
 2971    /// <param name="archivePath">Archive file path.</param>
 2972    /// <param name="destinationDirectory">Extraction destination directory.</param>
 2973    /// <param name="error">Error details when extraction fails.</param>
 2974    /// <returns>True when extraction succeeds.</returns>
 2975    private static bool TryExtractServiceContentRootArchive(string archivePath, string destinationDirectory, out string 
 2976    {
 62977        error = string.Empty;
 2978
 2979        try
 2980        {
 62981            var lowerPath = archivePath.ToLowerInvariant();
 62982            if (lowerPath.EndsWith(ServicePackageExtension, StringComparison.Ordinal)
 62983                || lowerPath.EndsWith(".zip", StringComparison.Ordinal))
 2984            {
 42985                return TryExtractZipArchiveSafely(archivePath, destinationDirectory, out error);
 2986            }
 2987
 22988            if (lowerPath.EndsWith(".tar", StringComparison.Ordinal))
 2989            {
 02990                return TryExtractTarArchiveSafely(File.OpenRead(archivePath), destinationDirectory, out error);
 2991            }
 2992
 22993            if (lowerPath.EndsWith(".tar.gz", StringComparison.Ordinal) || lowerPath.EndsWith(".tgz", StringComparison.O
 2994            {
 22995                using var archiveStream = File.OpenRead(archivePath);
 22996                using var gzipStream = new GZipStream(archiveStream, CompressionMode.Decompress);
 22997                return TryExtractTarArchiveSafely(gzipStream, destinationDirectory, out error);
 2998            }
 2999
 03000            error = $"Unsupported package format. Supported extension: {ServicePackageExtension} (zip payload).";
 03001            return false;
 3002        }
 03003        catch (Exception ex)
 3004        {
 03005            error = $"Failed to extract service content archive '{archivePath}': {ex.Message}";
 03006            return false;
 3007        }
 63008    }
 3009
 3010    /// <summary>
 3011    /// Extracts a zip archive while enforcing destination path boundaries.
 3012    /// </summary>
 3013    /// <param name="archivePath">Archive file path.</param>
 3014    /// <param name="destinationDirectory">Extraction destination directory.</param>
 3015    /// <param name="error">Error details when extraction fails.</param>
 3016    /// <returns>True when extraction succeeds.</returns>
 3017    private static bool TryExtractZipArchiveSafely(string archivePath, string destinationDirectory, out string error)
 3018    {
 43019        error = string.Empty;
 43020        using var archive = ZipFile.OpenRead(archivePath);
 263021        foreach (var entry in archive.Entries)
 3022        {
 93023            var fullDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, entry.FullName));
 93024            if (!IsPathWithinDirectory(fullDestinationPath, destinationDirectory))
 3025            {
 03026                error = $"Archive entry '{entry.FullName}' escapes extraction root.";
 03027                return false;
 3028            }
 3029
 93030            var isDirectory = string.IsNullOrEmpty(entry.Name)
 93031                || entry.FullName.EndsWith('/')
 93032                || entry.FullName.EndsWith('\\');
 93033            if (isDirectory)
 3034            {
 03035                _ = Directory.CreateDirectory(fullDestinationPath);
 03036                continue;
 3037            }
 3038
 93039            var parentDirectory = Path.GetDirectoryName(fullDestinationPath);
 93040            if (!string.IsNullOrWhiteSpace(parentDirectory))
 3041            {
 93042                _ = Directory.CreateDirectory(parentDirectory);
 3043            }
 3044
 93045            entry.ExtractToFile(fullDestinationPath, overwrite: true);
 3046        }
 3047
 43048        return true;
 43049    }
 3050
 3051    /// <summary>
 3052    /// Extracts a tar stream while enforcing destination path boundaries.
 3053    /// </summary>
 3054    /// <param name="archiveStream">Tar-formatted stream.</param>
 3055    /// <param name="destinationDirectory">Extraction destination directory.</param>
 3056    /// <param name="error">Error details when extraction fails.</param>
 3057    /// <returns>True when extraction succeeds.</returns>
 3058    private static bool TryExtractTarArchiveSafely(Stream archiveStream, string destinationDirectory, out string error)
 3059    {
 23060        error = string.Empty;
 23061        using var reader = new TarReader(archiveStream, leaveOpen: false);
 3062        TarEntry? entry;
 73063        while ((entry = reader.GetNextEntry()) is not null)
 3064        {
 53065            if (string.IsNullOrWhiteSpace(entry.Name))
 3066            {
 3067                continue;
 3068            }
 3069
 53070            var fullDestinationPath = Path.GetFullPath(Path.Combine(destinationDirectory, entry.Name));
 53071            if (!IsPathWithinDirectory(fullDestinationPath, destinationDirectory))
 3072            {
 03073                error = $"Archive entry '{entry.Name}' escapes extraction root.";
 03074                return false;
 3075            }
 3076
 53077            if (entry.EntryType is TarEntryType.Directory)
 3078            {
 03079                _ = Directory.CreateDirectory(fullDestinationPath);
 03080                continue;
 3081            }
 3082
 53083            if (entry.EntryType is TarEntryType.RegularFile or TarEntryType.V7RegularFile)
 3084            {
 53085                var parentDirectory = Path.GetDirectoryName(fullDestinationPath);
 53086                if (!string.IsNullOrWhiteSpace(parentDirectory))
 3087                {
 53088                    _ = Directory.CreateDirectory(parentDirectory);
 3089                }
 3090
 53091                if (entry.DataStream is null)
 3092                {
 03093                    using var emptyFile = File.Create(fullDestinationPath);
 03094                    continue;
 3095                }
 3096
 53097                using var output = File.Create(fullDestinationPath);
 53098                entry.DataStream.CopyTo(output);
 53099                continue;
 3100            }
 3101        }
 3102
 23103        return true;
 23104    }
 3105
 3106    /// <summary>
 3107    /// Checks whether a path is inside (or equal to) a given directory.
 3108    /// </summary>
 3109    /// <param name="candidatePath">Candidate absolute path.</param>
 3110    /// <param name="directoryPath">Directory absolute path.</param>
 3111    /// <returns>True when candidate is within the directory tree.</returns>
 3112    private static bool IsPathWithinDirectory(string candidatePath, string directoryPath)
 3113    {
 283114        var fullCandidate = Path.GetFullPath(candidatePath)
 283115            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 283116        var fullDirectory = Path.GetFullPath(directoryPath)
 283117            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 283118        var comparison = OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()
 283119            ? StringComparison.OrdinalIgnoreCase
 283120            : StringComparison.Ordinal;
 3121
 283122        return fullCandidate.Equals(fullDirectory, comparison)
 283123            || fullCandidate.StartsWith(fullDirectory + Path.DirectorySeparatorChar, comparison)
 283124            || fullCandidate.StartsWith(fullDirectory + Path.AltDirectorySeparatorChar, comparison);
 3125    }
 3126
 3127    /// <summary>
 3128    /// Resolves the deployment root path used for service bundles.
 3129    /// </summary>
 3130    /// <param name="deploymentRootOverride">Optional explicit root path.</param>
 3131    /// <param name="deploymentRoot">Resolved writable deployment root.</param>
 3132    /// <param name="error">Error details when no writable root is available.</param>
 3133    /// <returns>True when a writable deployment root is resolved.</returns>
 3134    private static bool TryResolveServiceDeploymentRoot(string? deploymentRootOverride, out string deploymentRoot, out s
 3135    {
 53136        deploymentRoot = string.Empty;
 53137        error = string.Empty;
 3138
 53139        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 3140        {
 53141            var overrideRoot = Path.GetFullPath(deploymentRootOverride);
 53142            if (!TryEnsureDirectoryWritable(overrideRoot, out var overrideError))
 3143            {
 13144                error = $"Unable to use deployment root '{deploymentRootOverride}': {overrideError}";
 13145                return false;
 3146            }
 3147
 43148            deploymentRoot = overrideRoot;
 43149            return true;
 3150        }
 3151
 03152        var failures = new List<string>();
 03153        foreach (var candidate in GetServiceDeploymentRootCandidates())
 3154        {
 03155            if (string.IsNullOrWhiteSpace(candidate))
 3156            {
 3157                continue;
 3158            }
 3159
 3160            try
 3161            {
 03162                var fullCandidate = Path.GetFullPath(candidate);
 03163                if (!TryEnsureDirectoryWritable(fullCandidate, out var candidateError))
 3164                {
 03165                    failures.Add($"{candidate} ({candidateError})");
 03166                    continue;
 3167                }
 3168
 03169                deploymentRoot = fullCandidate;
 03170                return true;
 3171            }
 03172            catch (Exception ex)
 3173            {
 03174                failures.Add($"{candidate} ({ex.Message})");
 03175            }
 3176        }
 3177
 03178        error = failures.Count == 0
 03179            ? "Unable to resolve a writable service deployment root."
 03180            : $"Unable to resolve a writable service deployment root. Attempted: {string.Join("; ", failures)}";
 03181        return false;
 03182    }
 3183
 3184    /// <summary>
 3185    /// Ensures a directory is writable by creating and deleting a short-lived probe file.
 3186    /// </summary>
 3187    /// <param name="directoryPath">Directory path to validate.</param>
 3188    /// <param name="error">Error details when the path is not writable.</param>
 3189    /// <returns>True when the directory can be created and written to.</returns>
 3190    private static bool TryEnsureDirectoryWritable(string directoryPath, out string error)
 3191    {
 53192        error = string.Empty;
 3193
 3194        try
 3195        {
 53196            _ = Directory.CreateDirectory(directoryPath);
 43197            var probePath = Path.Combine(directoryPath, $".kestrun-write-probe-{Guid.NewGuid():N}");
 43198            File.WriteAllText(probePath, "ok");
 43199            File.Delete(probePath);
 43200            return true;
 3201        }
 13202        catch (Exception ex)
 3203        {
 13204            error = ex.Message;
 13205            return false;
 3206        }
 53207    }
 3208
 3209    /// <summary>
 3210    /// Returns candidate deployment roots for service bundle storage.
 3211    /// </summary>
 3212    /// <returns>Candidate absolute or rooted paths in priority order.</returns>
 3213    private static IEnumerable<string> GetServiceDeploymentRootCandidates()
 3214    {
 103215        if (OperatingSystem.IsWindows())
 3216        {
 03217            yield return Path.Combine(
 03218                Environment.GetFolderPath(Environment.SpecialFolder.CommonApplicationData),
 03219                ServiceDeploymentProductFolderName,
 03220                ServiceDeploymentServicesFolderName);
 03221            yield break;
 3222        }
 3223
 103224        if (OperatingSystem.IsLinux())
 3225        {
 103226            yield return "/var/kestrun/services";
 103227            yield return "/usr/local/kestrun/services";
 3228
 103229            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 103230            if (!string.IsNullOrWhiteSpace(userProfile))
 3231            {
 103232                yield return Path.Combine(userProfile, ".local", "share", "kestrun", "services");
 3233            }
 3234
 103235            yield break;
 3236        }
 3237
 03238        if (OperatingSystem.IsMacOS())
 3239        {
 03240            yield return "/usr/local/kestrun/services";
 3241
 03242            var userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 03243            if (!string.IsNullOrWhiteSpace(userProfile))
 3244            {
 03245                yield return Path.Combine(userProfile, "Library", "Application Support", "Kestrun", "services");
 3246            }
 3247
 03248            yield break;
 3249        }
 3250
 03251        yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Kestrun", 
 03252    }
 3253
 3254    /// <summary>
 3255    /// Removes service bundle directories for a given service name from known deployment roots.
 3256    /// </summary>
 3257    /// <param name="serviceName">Service name.</param>
 3258    private static void TryRemoveServiceBundle(string serviceName, string? deploymentRootOverride = null)
 3259    {
 13260        var serviceDirectoryName = GetServiceDeploymentDirectoryName(serviceName);
 13261        var printedPermissionHint = false;
 3262
 13263        var candidateRoots = new List<string>();
 13264        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 3265        {
 13266            candidateRoots.Add(deploymentRootOverride);
 3267        }
 3268
 13269        candidateRoots.AddRange(GetServiceDeploymentRootCandidates());
 3270
 103271        foreach (var candidateRoot in candidateRoots.Distinct(StringComparer.OrdinalIgnoreCase))
 3272        {
 43273            if (string.IsNullOrWhiteSpace(candidateRoot))
 3274            {
 3275                continue;
 3276            }
 3277
 43278            var serviceRoot = Path.Combine(candidateRoot, serviceDirectoryName);
 3279            try
 3280            {
 43281                if (OperatingSystem.IsWindows())
 3282                {
 03283                    TryDeleteDirectoryWithRetry(serviceRoot, maxAttempts: 15, initialDelayMs: 250);
 3284                }
 3285                else
 3286                {
 43287                    TryDeleteDirectoryWithRetry(serviceRoot);
 3288                }
 43289            }
 03290            catch (Exception ex)
 3291            {
 03292                if (IsExpectedUnixProtectedRootCleanupFailure(candidateRoot, ex, deploymentRootOverride))
 3293                {
 03294                    if (!printedPermissionHint)
 3295                    {
 03296                        Console.Error.WriteLine("Info: Skipping cleanup of root-owned service bundle locations. Use sudo
 03297                        printedPermissionHint = true;
 3298                    }
 3299
 03300                    continue;
 3301                }
 3302
 03303                Console.Error.WriteLine($"Warning: Failed to remove service bundle '{serviceRoot}': {ex.Message}");
 03304            }
 3305        }
 13306    }
 3307
 3308    /// <summary>
 3309    /// Returns true when cleanup failures are expected for protected Unix roots owned by another user.
 3310    /// </summary>
 3311    /// <param name="candidateRoot">Deployment root candidate being cleaned.</param>
 3312    /// <param name="exception">Raised exception.</param>
 3313    /// <param name="deploymentRootOverride">Optional explicit deployment root override.</param>
 3314    /// <returns>True when the error can be downgraded to informational output.</returns>
 3315    private static bool IsExpectedUnixProtectedRootCleanupFailure(string candidateRoot, Exception exception, string? dep
 3316    {
 03317        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 3318        {
 03319            return false;
 3320        }
 3321
 03322        if (exception is not UnauthorizedAccessException)
 3323        {
 03324            return false;
 3325        }
 3326
 03327        if (!(OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()))
 3328        {
 03329            return false;
 3330        }
 3331
 03332        if (IsLikelyRunningAsRootOnUnix())
 3333        {
 03334            return false;
 3335        }
 3336
 3337        // Cleanup failures for protected system roots are expected when running as a non-root user on Unix, so downgrad
 03338        return IsProtectedUnixServiceRoot(candidateRoot);
 3339    }
 3340
 3341    /// <summary>
 3342    /// Returns true when the path is a protected system root used for service bundle fallback on Unix.
 3343    /// </summary>
 3344    /// <param name="candidateRoot">Deployment root candidate.</param>
 3345    /// <returns>True when path is a protected system root.</returns>
 3346    private static bool IsProtectedUnixServiceRoot(string candidateRoot)
 3347    {
 03348        if (string.IsNullOrWhiteSpace(candidateRoot))
 3349        {
 03350            return false;
 3351        }
 3352
 03353        var fullCandidate = Path.GetFullPath(candidateRoot)
 03354            .TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar);
 3355
 03356        return string.Equals(fullCandidate, "/var/kestrun/services", StringComparison.Ordinal)
 03357            || string.Equals(fullCandidate, "/usr/local/kestrun/services", StringComparison.Ordinal);
 3358    }
 3359
 3360    /// <summary>
 3361    /// Deletes a directory recursively with retry/backoff for transient file-lock scenarios.
 3362    /// </summary>
 3363    /// <param name="directoryPath">Directory path to delete.</param>
 3364    /// <param name="maxAttempts">Maximum number of attempts.</param>
 3365    /// <param name="initialDelayMs">Initial delay between attempts in milliseconds.</param>
 3366    private static void TryDeleteDirectoryWithRetry(string directoryPath, int maxAttempts = 5, int initialDelayMs = 200)
 3367    {
 573368        if (!Directory.Exists(directoryPath))
 3369        {
 43370            return;
 3371        }
 3372
 533373        var attempt = 0;
 533374        var delayMs = initialDelayMs;
 533375        Exception? lastError = null;
 3376
 533377        while (attempt < maxAttempts)
 3378        {
 3379            try
 3380            {
 533381                Directory.Delete(directoryPath, recursive: true);
 533382                return;
 3383            }
 03384            catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
 3385            {
 03386                lastError = ex;
 03387                attempt += 1;
 03388                if (attempt >= maxAttempts)
 3389                {
 03390                    break;
 3391                }
 3392
 03393                Thread.Sleep(delayMs);
 03394                delayMs = Math.Min(delayMs * 2, 2000);
 03395            }
 3396        }
 3397
 03398        if (lastError is not null)
 3399        {
 03400            throw lastError;
 3401        }
 533402    }
 3403
 3404    /// <summary>
 3405    /// Returns a filesystem-safe directory name for service deployment folders.
 3406    /// </summary>
 3407    /// <param name="serviceName">Service name.</param>
 3408    /// <returns>Sanitized directory name.</returns>
 3409    private static string GetServiceDeploymentDirectoryName(string serviceName)
 3410    {
 143411        var invalid = Path.GetInvalidFileNameChars();
 143412        var builder = new StringBuilder(serviceName.Length);
 4003413        foreach (var ch in serviceName)
 3414        {
 1863415            if (char.IsControl(ch) || ch == Path.DirectorySeparatorChar || ch == Path.AltDirectorySeparatorChar || inval
 3416            {
 03417                _ = builder.Append('-');
 03418                continue;
 3419            }
 3420
 1863421            _ = builder.Append(ch);
 3422        }
 3423
 143424        var sanitized = builder.ToString().Trim().Trim('.');
 143425        return string.IsNullOrWhiteSpace(sanitized) ? "service" : sanitized;
 3426    }
 3427
 3428    /// <summary>
 3429    /// Resolves runtime RID segment for service runtime payloads.
 3430    /// </summary>
 3431    /// <param name="runtimeRid">RID segment (for example win-x64).</param>
 3432    /// <param name="error">Error details when runtime architecture is unsupported.</param>
 3433    /// <returns>True when runtime RID can be resolved.</returns>
 3434    private static bool TryGetServiceRuntimeRid(out string runtimeRid, out string error)
 3435    {
 133436        runtimeRid = string.Empty;
 133437        error = string.Empty;
 3438
 133439        var osPrefix = OperatingSystem.IsWindows()
 133440            ? "win"
 133441            : OperatingSystem.IsLinux()
 133442                ? "linux"
 133443                : OperatingSystem.IsMacOS()
 133444                    ? "osx"
 133445                    : string.Empty;
 3446
 133447        if (string.IsNullOrWhiteSpace(osPrefix))
 3448        {
 03449            error = "Service runtime bundling is not supported on this operating system.";
 03450            return false;
 3451        }
 3452
 133453        var architecture = RuntimeInformation.ProcessArchitecture switch
 133454        {
 133455            Architecture.X64 => "x64",
 03456            Architecture.Arm64 => "arm64",
 03457            _ => string.Empty,
 133458        };
 3459
 133460        if (string.IsNullOrWhiteSpace(architecture))
 3461        {
 03462            error = $"Service runtime bundling does not support process architecture '{RuntimeInformation.ProcessArchite
 03463            return false;
 3464        }
 3465
 133466        runtimeRid = $"{osPrefix}-{architecture}";
 133467        return true;
 3468    }
 3469
 3470    /// <summary>
 3471    /// Ensures execute permissions are present for service runtime files on Unix platforms.
 3472    /// </summary>
 3473    /// <param name="runtimePath">Runtime executable file path.</param>
 3474    [SupportedOSPlatform("linux")]
 3475    [SupportedOSPlatform("macos")]
 3476    private static void TryEnsureServiceRuntimeExecutablePermissions(string runtimePath)
 3477    {
 3478        try
 3479        {
 63480            var mode = File.GetUnixFileMode(runtimePath);
 63481            mode |= UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute;
 63482            File.SetUnixFileMode(runtimePath, mode);
 63483        }
 03484        catch
 3485        {
 3486            // Ignore permission update failures and let service startup report execution errors if needed.
 03487        }
 63488    }
 3489
 3490    /// <summary>
 3491    /// Normalizes service log path input; directory input gets the default file name.
 3492    /// </summary>
 3493    /// <param name="inputPath">Configured path input.</param>
 3494    /// <param name="defaultFileName">Default file name for directory-only inputs.</param>
 3495    /// <returns>Absolute log file path.</returns>
 3496    private static string NormalizeServiceLogPath(string inputPath, string defaultFileName)
 3497    {
 63498        var fullPath = Path.GetFullPath(inputPath);
 63499        return Directory.Exists(fullPath)
 63500            || inputPath.EndsWith('\\')
 63501            || inputPath.EndsWith('/')
 63502            ? Path.Combine(fullPath, defaultFileName)
 63503            : fullPath;
 3504    }
 3505
 3506    /// <summary>
 3507    /// Escapes XML-sensitive characters.
 3508    /// </summary>
 3509    /// <param name="input">Raw input string.</param>
 3510    /// <returns>Escaped XML value.</returns>
 3511    private static string EscapeXml(string input)
 3512    {
 113513        return input
 113514            .Replace("&", "&amp;", StringComparison.Ordinal)
 113515            .Replace("<", "&lt;", StringComparison.Ordinal)
 113516            .Replace(">", "&gt;", StringComparison.Ordinal)
 113517            .Replace("\"", "&quot;", StringComparison.Ordinal)
 113518            .Replace("'", "&apos;", StringComparison.Ordinal);
 3519    }
 3520
 3521    /// <summary>
 3522    /// Escapes one token for systemd ExecStart parsing.
 3523    /// </summary>
 3524    /// <param name="input">Raw token.</param>
 3525    /// <returns>Escaped token.</returns>
 3526    private static string EscapeSystemdToken(string input)
 3527    {
 173528        if (string.IsNullOrEmpty(input))
 3529        {
 03530            return "\"\"";
 3531        }
 3532
 173533        var escaped = input
 173534            .Replace("\\", "\\\\", StringComparison.Ordinal)
 173535            .Replace(" ", "\\ ", StringComparison.Ordinal)
 173536            .Replace("\"", "\\\"", StringComparison.Ordinal)
 173537            .Replace("'", "\\'", StringComparison.Ordinal)
 173538            .Replace(";", "\\;", StringComparison.Ordinal)
 173539            .Replace("$", "\\$", StringComparison.Ordinal);
 3540
 173541        return escaped;
 3542    }
 3543
 3544    /// <summary>
 3545    /// Builds a Windows command-line string with proper escaping for each token.
 3546    /// </summary>
 3547    /// <param name="exePath">Executable path.</param>
 3548    /// <param name="args">Command-line arguments.</param>
 3549    /// <returns>Full command line string.</returns>
 3550    private static string BuildWindowsCommandLine(string exePath, IReadOnlyList<string> args)
 3551    {
 13552        var all = new List<string>(1 + args.Count) { exePath };
 13553        all.AddRange(args);
 13554        return string.Join(" ", all.Select(EscapeWindowsCommandLineArgument));
 3555    }
 3556
 3557    /// <summary>
 3558    /// Escapes one command-line argument using Windows CreateProcess rules.
 3559    /// </summary>
 3560    /// <param name="arg">Input argument.</param>
 3561    /// <returns>Escaped argument string.</returns>
 3562    private static string EscapeWindowsCommandLineArgument(string arg)
 3563    {
 43564        if (arg.Length == 0)
 3565        {
 03566            return "\"\"";
 3567        }
 3568
 323569        var requiresQuotes = arg.Any(c => char.IsWhiteSpace(c) || c == '"');
 43570        if (!requiresQuotes)
 3571        {
 13572            return arg;
 3573        }
 3574
 33575        var result = new StringBuilder(arg.Length + 2);
 33576        _ = result.Append('"');
 33577        var backslashes = 0;
 1243578        foreach (var c in arg)
 3579        {
 593580            if (c == '\\')
 3581            {
 03582                backslashes += 1;
 03583                continue;
 3584            }
 3585
 593586            if (c == '"')
 3587            {
 03588                _ = result.Append('\\', (backslashes * 2) + 1);
 03589                _ = result.Append('"');
 03590                backslashes = 0;
 03591                continue;
 3592            }
 3593
 593594            if (backslashes > 0)
 3595            {
 03596                _ = result.Append('\\', backslashes);
 03597                backslashes = 0;
 3598            }
 3599
 593600            _ = result.Append(c);
 3601        }
 3602
 33603        if (backslashes > 0)
 3604        {
 03605            _ = result.Append('\\', backslashes * 2);
 3606        }
 3607
 33608        _ = result.Append('"');
 33609        return result.ToString();
 3610    }
 3611
 3612    /// <summary>
 3613    /// Builds a normalized systemd unit name.
 3614    /// </summary>
 3615    /// <param name="serviceName">Input service name.</param>
 3616    /// <returns>Sanitized unit file name.</returns>
 3617    private static string GetLinuxUnitName(string serviceName)
 3618    {
 6613619        var safeName = new string([.. serviceName.Select(c => char.IsLetterOrDigit(c) || c is '-' or '_' or '.' ? c : '-
 3620
 233621        return safeName.EndsWith(".service", StringComparison.OrdinalIgnoreCase)
 233622            ? safeName
 233623            : $"{safeName}.service";
 3624    }
 3625
 3626    /// <summary>
 3627    /// Returns true when the current Linux process is likely running as root.
 3628    /// </summary>
 3629    /// <returns>True when username resolves to root on Linux.</returns>
 13630    private static bool IsLikelyRunningAsRootOnLinux() => OperatingSystem.IsLinux() && string.Equals(Environment.UserNam
 3631
 3632    /// <summary>
 3633    /// Returns true when the current Unix process is likely running as root.
 3634    /// </summary>
 3635    /// <returns>True when username resolves to root on Linux or macOS.</returns>
 03636    private static bool IsLikelyRunningAsRootOnUnix() => (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
 03637        && string.Equals(Environment.UserName, "root", StringComparison.Ordinal);
 3638
 3639    /// <summary>
 3640    /// Writes actionable guidance for common user-level systemd failures on Linux.
 3641    /// </summary>
 3642    /// <param name="result">Captured process result from a failed systemctl --user call.</param>
 3643    private static void WriteLinuxUserSystemdFailureHint(ProcessResult result)
 3644    {
 13645        if (!OperatingSystem.IsLinux())
 3646        {
 03647            return;
 3648        }
 3649
 13650        var diagnostics = string.IsNullOrWhiteSpace(result.Error) ? result.Output : result.Error;
 13651        if (string.IsNullOrWhiteSpace(diagnostics))
 3652        {
 03653            return;
 3654        }
 3655
 13656        if (!diagnostics.Contains("Failed to connect to bus", StringComparison.OrdinalIgnoreCase)
 13657            && !diagnostics.Contains("No medium found", StringComparison.OrdinalIgnoreCase)
 13658            && !diagnostics.Contains("Access denied", StringComparison.OrdinalIgnoreCase)
 13659            && !diagnostics.Contains("Permission denied", StringComparison.OrdinalIgnoreCase))
 3660        {
 03661            return;
 3662        }
 3663
 13664        Console.Error.WriteLine("Hint: Linux service commands use user-level systemd units (systemctl --user).");
 13665        Console.Error.WriteLine("Run install/start/stop/query/remove as the same non-root user that installed the unit."
 13666        Console.Error.WriteLine("If running over SSH or a headless session, enable linger: sudo loginctl enable-linger <
 13667    }
 3668
 3669    /// <summary>
 3670    /// Runs a process and captures output for diagnostics.
 3671    /// </summary>
 3672    /// <param name="fileName">Executable to run.</param>
 3673    /// <param name="arguments">Argument tokens.</param>
 3674    /// <returns>Process result data.</returns>
 3675    private static ProcessResult RunProcess(string fileName, IReadOnlyList<string> arguments, bool writeStandardOutput =
 3676    {
 113677        var startInfo = new ProcessStartInfo
 113678        {
 113679            FileName = fileName,
 113680            UseShellExecute = false,
 113681            RedirectStandardOutput = true,
 113682            RedirectStandardError = true,
 113683            CreateNoWindow = true,
 113684        };
 3685
 803686        foreach (var argument in arguments)
 3687        {
 293688            startInfo.ArgumentList.Add(argument);
 3689        }
 3690
 113691        using var process = Process.Start(startInfo);
 93692        if (process is null)
 3693        {
 03694            return new ProcessResult(1, string.Empty, $"Failed to start process: {fileName}");
 3695        }
 3696
 93697        var output = process.StandardOutput.ReadToEnd();
 93698        var error = process.StandardError.ReadToEnd();
 93699        process.WaitForExit();
 3700
 93701        if (writeStandardOutput && !string.IsNullOrWhiteSpace(output))
 3702        {
 13703            Console.WriteLine(output.TrimEnd());
 3704        }
 3705
 93706        return new ProcessResult(process.ExitCode, output, error);
 93707    }
 3708
 3709    /// <summary>
 3710    /// Captures child process execution results.
 3711    /// </summary>
 3712    /// <param name="ExitCode">Process exit code.</param>
 3713    /// <param name="Output">Captured standard output.</param>
 3714    /// <param name="Error">Captured standard error.</param>
 533715    private sealed record ProcessResult(int ExitCode, string Output, string Error);
 3716
 3717    /// <summary>
 3718    /// Resolves a module manifest path for run mode, preferring bundled service payload when no explicit path is provid
 3719    /// </summary>
 3720    /// <param name="kestrunManifestPath">Optional explicit manifest path.</param>
 3721    /// <param name="kestrunFolder">Optional module folder path.</param>
 3722    /// <returns>Absolute path to the resolved module manifest, or null when not found.</returns>
 3723    private static string? ResolveRunModuleManifestPath(string? kestrunManifestPath, string? kestrunFolder)
 3724    {
 03725        if (!string.IsNullOrWhiteSpace(kestrunManifestPath) || !string.IsNullOrWhiteSpace(kestrunFolder))
 3726        {
 03727            return LocateModuleManifest(kestrunManifestPath, kestrunFolder);
 3728        }
 3729
 03730        if (TryResolvePowerShellModulesPayloadFromToolDistribution(out var modulesPayloadPath))
 3731        {
 03732            var bundledManifestPath = Path.Combine(modulesPayloadPath, ModuleName, ModuleManifestFileName);
 03733            if (File.Exists(bundledManifestPath))
 3734            {
 03735                return Path.GetFullPath(bundledManifestPath);
 3736            }
 3737        }
 3738
 03739        return LocateModuleManifest(null, null);
 3740    }
 3741
 3742    /// <summary>
 3743    /// Builds arguments for direct foreground run mode on the dedicated service-host executable.
 3744    /// </summary>
 3745    /// <param name="runnerExecutablePath">Runner executable path.</param>
 3746    /// <param name="scriptPath">Absolute script path.</param>
 3747    /// <param name="moduleManifestPath">Absolute module manifest path.</param>
 3748    /// <param name="scriptArguments">Script arguments.</param>
 3749    /// <param name="discoverPowerShellHome">When true, pass --discover-pshome.</param>
 3750    /// <returns>Ordered argument list.</returns>
 3751    private static IReadOnlyList<string> BuildDedicatedServiceHostRunArguments(
 3752        string runnerExecutablePath,
 3753        string scriptPath,
 3754        string moduleManifestPath,
 3755        IReadOnlyList<string> scriptArguments,
 3756        bool discoverPowerShellHome)
 3757    {
 03758        var arguments = new List<string>(12 + scriptArguments.Count)
 03759        {
 03760            "--runner-exe",
 03761            Path.GetFullPath(runnerExecutablePath),
 03762            "--run",
 03763            Path.GetFullPath(scriptPath),
 03764            "--kestrun-manifest",
 03765            Path.GetFullPath(moduleManifestPath),
 03766        };
 3767
 03768        if (discoverPowerShellHome)
 3769        {
 03770            arguments.Add("--discover-pshome");
 3771        }
 3772
 03773        if (scriptArguments.Count > 0)
 3774        {
 03775            arguments.Add("--arguments");
 03776            arguments.AddRange(scriptArguments);
 3777        }
 3778
 03779        return arguments;
 3780    }
 3781
 3782    /// <summary>
 3783    /// Resolves whether service-host should auto-discover PSHOME for the selected manifest path.
 3784    /// </summary>
 3785    /// <param name="moduleManifestPath">Absolute path to Kestrun.psd1.</param>
 3786    /// <returns>True when --discover-pshome should be used.</returns>
 3787    private static bool ShouldDiscoverPowerShellHomeForManifest(string moduleManifestPath)
 3788    {
 03789        var fullManifestPath = Path.GetFullPath(moduleManifestPath);
 03790        var moduleDirectory = Path.GetDirectoryName(fullManifestPath);
 03791        if (string.IsNullOrWhiteSpace(moduleDirectory))
 3792        {
 03793            return true;
 3794        }
 3795
 03796        var moduleRoot = Directory.GetParent(moduleDirectory);
 03797        var serviceRoot = moduleRoot?.Parent?.FullName;
 03798        if (string.IsNullOrWhiteSpace(serviceRoot))
 3799        {
 03800            return true;
 3801        }
 3802
 03803        var modulesDirectory = Path.Combine(serviceRoot, "Modules");
 03804        return !Directory.Exists(modulesDirectory);
 3805    }
 3806
 3807    /// <summary>
 3808    /// Resolves the current executable path when available, otherwise falls back to the provided value.
 3809    /// </summary>
 3810    /// <param name="fallbackPath">Fallback path when current process path is unavailable.</param>
 3811    /// <returns>Absolute executable path.</returns>
 3812    private static string ResolveCurrentProcessPathOrFallback(string fallbackPath)
 03813        => !string.IsNullOrWhiteSpace(Environment.ProcessPath) && File.Exists(Environment.ProcessPath)
 03814            ? Path.GetFullPath(Environment.ProcessPath)
 03815            : Path.GetFullPath(fallbackPath);
 3816
 3817    /// <summary>
 3818    /// Parses runner-specific global options and returns arguments to pass into command parsing.
 3819    /// </summary>
 3820    /// <param name="args">Raw process arguments.</param>
 3821    /// <returns>Normalized argument set and global option flags.</returns>
 3822    private static GlobalOptions ParseGlobalOptions(string[] args)
 3823    {
 43824        var commandArgs = new List<string>(args.Length);
 43825        var skipGalleryCheck = false;
 43826        var passthroughArguments = false;
 3827
 283828        foreach (var arg in args)
 3829        {
 103830            if (!passthroughArguments && arg is "--arguments" or "--")
 3831            {
 13832                passthroughArguments = true;
 13833                commandArgs.Add(arg);
 13834                continue;
 3835            }
 3836
 93837            if (!passthroughArguments && IsNoCheckOption(arg))
 3838            {
 13839                skipGalleryCheck = true;
 13840                continue;
 3841            }
 3842
 83843            commandArgs.Add(arg);
 3844        }
 3845
 43846        return new GlobalOptions([.. commandArgs], skipGalleryCheck);
 3847    }
 3848
 3849    /// <summary>
 3850    /// Determines whether the token disables gallery version checks.
 3851    /// </summary>
 3852    /// <param name="token">Argument token.</param>
 3853    /// <returns>True when the token disables gallery checks.</returns>
 3854    private static bool IsNoCheckOption(string token)
 253855        => string.Equals(token, NoCheckOption, StringComparison.OrdinalIgnoreCase)
 253856            || string.Equals(token, NoCheckAliasOption, StringComparison.OrdinalIgnoreCase);
 3857
 3858    /// <summary>
 3859    /// Validates install preconditions for module install operations.
 3860    /// </summary>
 3861    /// <param name="moduleRoot">Root folder for module versions.</param>
 3862    /// <param name="scopeToken">Module scope token for messaging.</param>
 3863    /// <param name="errorText">Validation error details when install should not proceed.</param>
 3864    /// <returns>True when install can proceed.</returns>
 3865    private static bool TryValidateInstallAction(string moduleRoot, string scopeToken, out string errorText)
 3866    {
 13867        errorText = string.Empty;
 13868        if (GetInstalledModuleRecords(moduleRoot).Count == 0)
 3869        {
 03870            return true;
 3871        }
 3872
 13873        errorText = $"{ModuleName} module is already installed in {scopeToken} scope. Use '{ProductName} module update' 
 13874        return false;
 3875    }
 3876
 3877    /// <summary>
 3878    /// Validates update preconditions for module update operations.
 3879    /// </summary>
 3880    /// <param name="moduleRoot">Root folder for module versions.</param>
 3881    /// <param name="packageVersion">Resolved target package version.</param>
 3882    /// <param name="force">When true, overwrite is allowed.</param>
 3883    /// <param name="errorText">Validation error details when update should not proceed.</param>
 3884    /// <returns>True when update can proceed.</returns>
 3885    private static bool TryValidateUpdateAction(string moduleRoot, string packageVersion, bool force, out string errorTe
 3886    {
 23887        errorText = string.Empty;
 23888        if (force)
 3889        {
 13890            return true;
 3891        }
 3892
 13893        var destinationModuleDirectory = Path.Combine(moduleRoot, packageVersion);
 13894        if (!Directory.Exists(destinationModuleDirectory))
 3895        {
 03896            return true;
 3897        }
 3898
 13899        errorText = $"Module version '{packageVersion}' is already installed at '{destinationModuleDirectory}'. Use '{Pr
 13900        return false;
 3901    }
 3902
 3903    /// <summary>
 3904    /// Reads the package version from a nupkg file payload.
 3905    /// </summary>
 3906    /// <param name="packageBytes">Package bytes.</param>
 3907    /// <param name="packageVersion">Parsed package version.</param>
 3908    /// <returns>True when a version was discovered.</returns>
 3909    private static bool TryReadPackageVersion(byte[] packageBytes, out string packageVersion)
 3910    {
 23911        packageVersion = string.Empty;
 3912
 23913        using var stream = new MemoryStream(packageBytes, writable: false);
 23914        using var archive = new ZipArchive(stream, ZipArchiveMode.Read, leaveOpen: false);
 43915        var nuspecEntry = archive.Entries.FirstOrDefault(static entry => entry.FullName.EndsWith(".nuspec", StringCompar
 23916        if (nuspecEntry is null)
 3917        {
 13918            return false;
 3919        }
 3920
 13921        using var reader = new StreamReader(nuspecEntry.Open(), Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
 13922        var nuspecText = reader.ReadToEnd();
 13923        if (string.IsNullOrWhiteSpace(nuspecText))
 3924        {
 03925            return false;
 3926        }
 3927
 13928        var document = XDocument.Parse(nuspecText);
 13929        var versionElement = document.Descendants()
 53930            .FirstOrDefault(static element => string.Equals(element.Name.LocalName, "version", StringComparison.OrdinalI
 13931        if (versionElement is null)
 3932        {
 03933            return false;
 3934        }
 3935
 13936        packageVersion = versionElement.Value.Trim();
 13937        return TryNormalizeModuleVersion(packageVersion, out packageVersion);
 23938    }
 3939
 3940    /// <summary>
 3941    /// Maps package entry paths to relative module payload paths.
 3942    /// </summary>
 3943    /// <param name="entryPath">Original package entry path.</param>
 3944    /// <param name="relativePath">Mapped relative payload path.</param>
 3945    /// <returns>True when the entry belongs to module payload content.</returns>
 3946    private static bool TryGetPackagePayloadPath(string entryPath, out string relativePath)
 3947    {
 53948        relativePath = string.Empty;
 53949        if (string.IsNullOrWhiteSpace(entryPath))
 3950        {
 03951            return false;
 3952        }
 3953
 53954        var normalizedPath = entryPath.Replace('\\', '/').TrimStart('/');
 53955        if (string.IsNullOrWhiteSpace(normalizedPath) || normalizedPath.EndsWith('/'))
 3956        {
 03957            return false;
 3958        }
 3959
 53960        if (normalizedPath.Equals("[Content_Types].xml", StringComparison.OrdinalIgnoreCase)
 53961            || normalizedPath.StartsWith("_rels/", StringComparison.OrdinalIgnoreCase)
 53962            || normalizedPath.StartsWith("package/", StringComparison.OrdinalIgnoreCase)
 53963            || normalizedPath.EndsWith(".nuspec", StringComparison.OrdinalIgnoreCase))
 3964        {
 13965            return false;
 3966        }
 3967
 43968        if (normalizedPath.StartsWith("tools/", StringComparison.OrdinalIgnoreCase))
 3969        {
 43970            normalizedPath = normalizedPath["tools/".Length..];
 3971        }
 03972        else if (normalizedPath.StartsWith("content/", StringComparison.OrdinalIgnoreCase))
 3973        {
 03974            normalizedPath = normalizedPath["content/".Length..];
 3975        }
 03976        else if (normalizedPath.StartsWith("contentFiles/any/any/", StringComparison.OrdinalIgnoreCase))
 3977        {
 03978            normalizedPath = normalizedPath["contentFiles/any/any/".Length..];
 3979        }
 3980
 43981        relativePath = normalizedPath.TrimStart('/');
 43982        return !string.IsNullOrWhiteSpace(relativePath);
 3983    }
 3984
 3985    /// <summary>
 3986    /// Copies all files recursively from one directory to another.
 3987    /// </summary>
 3988    /// <param name="sourceDirectory">Source directory.</param>
 3989    /// <param name="destinationDirectory">Destination directory.</param>
 3990    /// <param name="showProgress">When true, writes interactive progress bars.</param>
 3991    private static void CopyDirectoryContents(string sourceDirectory, string destinationDirectory, bool showProgress)
 23992        => CopyDirectoryContents(sourceDirectory, destinationDirectory, showProgress, "Installing files", exclusionPatte
 3993
 3994    /// <summary>
 3995    /// Copies all files recursively from one directory to another.
 3996    /// </summary>
 3997    /// <param name="sourceDirectory">Source directory.</param>
 3998    /// <param name="destinationDirectory">Destination directory.</param>
 3999    /// <param name="showProgress">When true, writes interactive progress bars.</param>
 4000    /// <param name="progressLabel">Progress bar label for file copy operations.</param>
 4001    /// <param name="exclusionPatterns">Optional wildcard patterns (relative to <paramref name="sourceDirectory"/>) for 
 4002    private static void CopyDirectoryContents(
 4003        string sourceDirectory,
 4004        string destinationDirectory,
 4005        bool showProgress,
 4006        string progressLabel,
 4007        IReadOnlyList<string>? exclusionPatterns)
 4008    {
 234009        _ = Directory.CreateDirectory(destinationDirectory);
 4010
 234011        var exclusionRegexes = BuildCopyExclusionRegexes(exclusionPatterns);
 234012        var sourceFilePaths = Directory.EnumerateFiles(sourceDirectory, "*", SearchOption.AllDirectories)
 354013            .Where(sourceFilePath => !ShouldExcludeCopyFile(sourceDirectory, sourceFilePath, exclusionRegexes))
 234014            .ToList();
 234015        using var copyProgress = showProgress
 234016            ? new ConsoleProgressBar(progressLabel, sourceFilePaths.Count, FormatFileProgressDetail)
 234017            : null;
 234018        var copiedFiles = 0;
 234019        copyProgress?.Report(0);
 4020
 1164021        foreach (var sourceFilePath in sourceFilePaths)
 4022        {
 354023            var relativePath = Path.GetRelativePath(sourceDirectory, sourceFilePath);
 354024            var destinationFilePath = Path.Combine(destinationDirectory, relativePath);
 354025            var destinationFileDirectory = Path.GetDirectoryName(destinationFilePath);
 354026            if (!string.IsNullOrWhiteSpace(destinationFileDirectory))
 4027            {
 354028                _ = Directory.CreateDirectory(destinationFileDirectory);
 4029            }
 4030
 354031            File.Copy(sourceFilePath, destinationFilePath, overwrite: true);
 354032            copiedFiles++;
 354033            copyProgress?.Report(copiedFiles);
 4034        }
 4035
 234036        copyProgress?.Complete(copiedFiles);
 234037    }
 4038
 4039    /// <summary>
 4040    /// Determines whether a source file should be excluded from a directory copy operation.
 4041    /// </summary>
 4042    /// <param name="sourceDirectory">Source directory root.</param>
 4043    /// <param name="sourceFilePath">Absolute source file path.</param>
 4044    /// <param name="exclusionRegexes">Compiled exclusion regexes.</param>
 4045    /// <returns>True when the file should be excluded.</returns>
 4046    private static bool ShouldExcludeCopyFile(string sourceDirectory, string sourceFilePath, IReadOnlyList<Regex> exclus
 4047    {
 354048        if (exclusionRegexes.Count == 0)
 4049        {
 274050            return false;
 4051        }
 4052
 84053        var relativePath = NormalizeCopyPath(Path.GetRelativePath(sourceDirectory, sourceFilePath));
 324054        return exclusionRegexes.Any(regex => regex.IsMatch(relativePath));
 4055    }
 4056
 4057    /// <summary>
 4058    /// Compiles wildcard exclusion patterns used by directory copy operations.
 4059    /// </summary>
 4060    /// <param name="exclusionPatterns">Wildcard exclusion patterns.</param>
 4061    /// <returns>Compiled regex list for path matching.</returns>
 4062    private static List<Regex> BuildCopyExclusionRegexes(IReadOnlyList<string>? exclusionPatterns)
 4063    {
 234064        if (exclusionPatterns is null || exclusionPatterns.Count == 0)
 4065        {
 184066            return [];
 4067        }
 4068
 54069        var regexOptions = RegexOptions.Compiled | RegexOptions.CultureInvariant;
 54070        if (OperatingSystem.IsWindows())
 4071        {
 04072            regexOptions |= RegexOptions.IgnoreCase;
 4073        }
 4074
 54075        var regexes = new List<Regex>(exclusionPatterns.Count);
 404076        foreach (var exclusionPattern in exclusionPatterns)
 4077        {
 154078            var normalizedPattern = NormalizeCopyPath(exclusionPattern);
 154079            if (string.IsNullOrWhiteSpace(normalizedPattern))
 4080            {
 4081                continue;
 4082            }
 4083
 154084            var regexPattern = $"^{Regex.Escape(normalizedPattern).Replace(@"\*", ".*").Replace(@"\?", ".")}$";
 154085            regexes.Add(new Regex(regexPattern, regexOptions, TimeSpan.FromMilliseconds(250)));
 4086        }
 4087
 54088        return regexes;
 4089    }
 4090
 4091    /// <summary>
 4092    /// Normalizes a relative path for wildcard matching.
 4093    /// </summary>
 4094    /// <param name="relativePath">Relative path or wildcard pattern.</param>
 4095    /// <returns>Normalized slash-separated path without leading dot prefixes.</returns>
 4096    private static string NormalizeCopyPath(string relativePath)
 4097    {
 234098        if (string.IsNullOrWhiteSpace(relativePath))
 4099        {
 04100            return string.Empty;
 4101        }
 4102
 234103        var normalizedPath = relativePath.Trim().Replace('\\', '/');
 234104        while (normalizedPath.StartsWith("./", StringComparison.Ordinal))
 4105        {
 04106            normalizedPath = normalizedPath[2..];
 4107        }
 4108
 234109        return normalizedPath.TrimStart('/');
 4110    }
 4111
 4112    /// <summary>
 4113    /// Removes all installed module files and folders for the selected scope.
 4114    /// </summary>
 4115    /// <param name="moduleRoot">Module root directory to remove.</param>
 4116    /// <param name="showProgress">When true, writes interactive progress bars.</param>
 4117    /// <param name="errorText">Error details when removal fails.</param>
 4118    /// <returns>True when removal succeeds.</returns>
 4119    private static bool TryRemoveInstalledModule(string moduleRoot, bool showProgress, out string errorText)
 4120    {
 24121        errorText = string.Empty;
 4122
 24123        if (!Directory.Exists(moduleRoot))
 4124        {
 14125            return true;
 4126        }
 4127
 4128        try
 4129        {
 14130            var filePaths = Directory.EnumerateFiles(moduleRoot, "*", SearchOption.AllDirectories).ToList();
 14131            using var fileProgress = showProgress
 14132                ? new ConsoleProgressBar("Removing files", filePaths.Count, FormatFileProgressDetail)
 14133                : null;
 14134            var removedFiles = 0;
 14135            fileProgress?.Report(0);
 4136
 64137            foreach (var filePath in filePaths)
 4138            {
 4139                try
 4140                {
 24141                    File.SetAttributes(filePath, FileAttributes.Normal);
 24142                }
 04143                catch
 4144                {
 4145                    // Best-effort normalization; delete may still succeed without changing attributes.
 04146                }
 4147
 24148                File.Delete(filePath);
 24149                removedFiles++;
 24150                fileProgress?.Report(removedFiles);
 4151            }
 4152
 14153            fileProgress?.Complete(removedFiles);
 4154
 14155            var directoryPaths = Directory.EnumerateDirectories(moduleRoot, "*", SearchOption.AllDirectories)
 24156                .OrderByDescending(path => path.Length)
 14157                .ToList();
 4158
 14159            using var directoryProgress = showProgress
 14160                ? new ConsoleProgressBar("Removing folders", directoryPaths.Count + 1, FormatFileProgressDetail)
 14161                : null;
 14162            var removedDirectories = 0;
 14163            directoryProgress?.Report(0);
 4164
 64165            foreach (var directoryPath in directoryPaths)
 4166            {
 24167                Directory.Delete(directoryPath, recursive: false);
 24168                removedDirectories++;
 24169                directoryProgress?.Report(removedDirectories);
 4170            }
 4171
 14172            Directory.Delete(moduleRoot, recursive: false);
 14173            removedDirectories++;
 14174            directoryProgress?.Report(removedDirectories);
 14175            directoryProgress?.Complete(removedDirectories);
 4176
 14177            return true;
 4178        }
 04179        catch (Exception ex)
 4180        {
 04181            errorText = ex.Message;
 04182            return false;
 4183        }
 14184    }
 4185
 4186    /// <summary>
 4187    /// Copies one stream into another while reporting transfer progress.
 4188    /// </summary>
 4189    /// <param name="source">Source stream.</param>
 4190    /// <param name="destination">Destination stream.</param>
 4191    /// <param name="progress">Optional progress reporter.</param>
 4192    private static void CopyStreamWithProgress(Stream source, Stream destination, ConsoleProgressBar? progress)
 4193    {
 24194        var buffer = new byte[81920];
 24195        var totalCopied = 0L;
 24196        progress?.Report(0);
 4197
 04198        while (true)
 4199        {
 34200            var bytesRead = source.Read(buffer, 0, buffer.Length);
 34201            if (bytesRead <= 0)
 4202            {
 4203                break;
 4204            }
 4205
 14206            destination.Write(buffer, 0, bytesRead);
 14207            totalCopied += bytesRead;
 14208            progress?.Report(totalCopied);
 4209        }
 4210
 24211        progress?.Complete(totalCopied);
 24212    }
 4213
 4214    /// <summary>
 4215    /// Formats byte transfer progress details.
 4216    /// </summary>
 4217    /// <param name="current">Current transferred bytes.</param>
 4218    /// <param name="total">Total bytes when known.</param>
 4219    /// <returns>Formatted progress text.</returns>
 4220    private static string FormatByteProgressDetail(long current, long? total)
 14221        => total.HasValue
 14222            ? $"{FormatByteSize(current)} / {FormatByteSize(total.Value)}"
 14223            : FormatByteSize(current);
 4224
 4225    /// <summary>
 4226    /// Formats file count progress details.
 4227    /// </summary>
 4228    /// <param name="current">Current processed file count.</param>
 4229    /// <param name="total">Total file count when known.</param>
 4230    /// <returns>Formatted progress text.</returns>
 4231    private static string FormatFileProgressDetail(long current, long? total)
 14232        => total.HasValue
 14233            ? $"{current}/{total.Value} files"
 14234            : $"{current} files";
 4235
 4236    /// <summary>
 4237    /// Formats progress details for service bundle preparation steps.
 4238    /// </summary>
 4239    /// <param name="current">Current completed step number.</param>
 4240    /// <param name="total">Total step count.</param>
 4241    /// <returns>Formatted step progress detail.</returns>
 4242    private static string FormatServiceBundleStepProgressDetail(long current, long? total)
 4243    {
 14244        var stepLabel = current switch
 14245        {
 04246            <= 0 => "initializing",
 04247            1 => "creating folders",
 04248            2 => "copying runtime",
 14249            3 => "copying module",
 04250            _ => "copying script",
 14251        };
 4252
 14253        return total.HasValue
 14254            ? $"step {Math.Min(current, total.Value)}/{total.Value} ({stepLabel})"
 14255            : $"step {current} ({stepLabel})";
 4256    }
 4257
 4258    /// <summary>
 4259    /// Formats a byte value to a readable unit string.
 4260    /// </summary>
 4261    /// <param name="bytes">Byte count.</param>
 4262    /// <returns>Human-readable byte text.</returns>
 4263    private static string FormatByteSize(long bytes)
 4264    {
 34265        var unitIndex = 0;
 34266        var value = (double)Math.Max(0, bytes);
 34267        var units = new[] { "B", "KB", "MB", "GB", "TB" };
 4268
 64269        while (value >= 1024d && unitIndex < units.Length - 1)
 4270        {
 34271            value /= 1024d;
 34272            unitIndex++;
 4273        }
 4274
 34275        return unitIndex == 0
 34276            ? $"{bytes} {units[unitIndex]}"
 34277            : $"{value:0.##} {units[unitIndex]}";
 4278    }
 4279
 4280    /// <summary>
 4281    /// Prints an update warning when a newer PowerShell Gallery version exists.
 4282    /// </summary>
 4283    /// <param name="moduleManifestPath">Resolved local module manifest path.</param>
 4284    /// <param name="logPath">Optional log path for warning output; when omitted, warning is written to stderr.</param>
 4285    private static void WarnIfNewerGalleryVersionExists(string moduleManifestPath, string? logPath = null)
 4286    {
 04287        if (!TryGetLatestInstalledModuleVersionText(ModuleStorageScope.Local, out var installedVersion)
 04288            && !TryGetLatestInstalledModuleVersionText(ModuleStorageScope.Global, out installedVersion)
 04289            && !TryGetInstalledModuleVersionText(moduleManifestPath, out installedVersion))
 4290        {
 04291            return;
 4292        }
 4293
 04294        if (!TryGetLatestGalleryVersionString(out var galleryVersion, out _))
 4295        {
 04296            return;
 4297        }
 4298
 04299        if (CompareModuleVersionValues(galleryVersion, installedVersion) <= 0)
 4300        {
 04301            return;
 4302        }
 4303
 04304        var warningMessage =
 04305            $"WARNING: A newer {ModuleName} module is available on PowerShell Gallery ({galleryVersion}). "
 04306            + $"Current version: {installedVersion}. Use '{ProductName} module update' or {NoCheckOption} to suppress th
 4307
 04308        WriteWarningToLogOrConsole(warningMessage, logPath);
 04309    }
 4310
 4311    /// <summary>
 4312    /// Writes a warning to a configured log file when available; otherwise stderr.
 4313    /// </summary>
 4314    /// <param name="message">Warning message.</param>
 4315    /// <param name="logPath">Optional log path.</param>
 4316    private static void WriteWarningToLogOrConsole(string message, string? logPath)
 4317    {
 14318        switch (string.IsNullOrWhiteSpace(logPath))
 4319        {
 4320            case true:
 04321                Console.Error.WriteLine(message);
 04322                return;
 4323
 4324            case false:
 4325                try
 4326                {
 14327                    var resolvedPath = NormalizeServiceLogPath(logPath, defaultFileName: "kestrun-tool-service.log");
 14328                    var directory = Path.GetDirectoryName(resolvedPath);
 14329                    if (!string.IsNullOrWhiteSpace(directory))
 4330                    {
 14331                        _ = Directory.CreateDirectory(directory);
 4332                    }
 4333
 14334                    File.AppendAllText(resolvedPath, $"{DateTime.UtcNow:O} {message}{Environment.NewLine}", Encoding.UTF
 14335                    return;
 4336                }
 04337                catch
 4338                {
 04339                    Console.Error.WriteLine(message);
 04340                    return;
 4341                }
 4342        }
 14343    }
 4344
 4345    /// <summary>
 4346    /// Attempts to read the latest installed module version text from a selected scope.
 4347    /// </summary>
 4348    /// <param name="scope">Module storage scope.</param>
 4349    /// <param name="versionText">Installed semantic version text when available.</param>
 4350    /// <returns>True when an installed version was found in the scope.</returns>
 4351    private static bool TryGetLatestInstalledModuleVersionText(ModuleStorageScope scope, out string versionText)
 4352    {
 04353        var modulePath = GetPowerShellModulePath(scope);
 04354        var moduleRoot = Path.Combine(modulePath, ModuleName);
 04355        return TryGetLatestInstalledModuleVersionTextFromModuleRoot(moduleRoot, out versionText);
 4356    }
 4357
 4358    /// <summary>
 4359    /// Attempts to read the latest installed module version text from a specific module root path.
 4360    /// </summary>
 4361    /// <param name="moduleRoot">Root directory containing versioned module folders.</param>
 4362    /// <param name="versionText">Installed semantic version text when available.</param>
 4363    /// <returns>True when an installed version was found in the module root.</returns>
 4364    private static bool TryGetLatestInstalledModuleVersionTextFromModuleRoot(string moduleRoot, out string versionText)
 4365    {
 14366        versionText = string.Empty;
 14367        var records = GetInstalledModuleRecords(moduleRoot);
 14368        if (records.Count == 0)
 4369        {
 04370            return false;
 4371        }
 4372
 14373        versionText = records[0].Version;
 14374        return !string.IsNullOrWhiteSpace(versionText);
 4375    }
 4376
 4377    /// <summary>
 4378    /// Attempts to read the local Kestrun module semantic version from manifest metadata.
 4379    /// </summary>
 4380    /// <param name="moduleManifestPath">Path to Kestrun.psd1.</param>
 4381    /// <param name="versionText">Installed semantic version text when available.</param>
 4382    /// <returns>True when a version was read.</returns>
 4383    private static bool TryGetInstalledModuleVersionText(string moduleManifestPath, out string versionText)
 4384    {
 14385        versionText = string.Empty;
 4386
 14387        if (TryReadModuleSemanticVersionFromManifest(moduleManifestPath, out var manifestVersionText))
 4388        {
 04389            versionText = manifestVersionText;
 04390            return true;
 4391        }
 4392
 14393        var versionDirectory = Path.GetFileName(Path.GetDirectoryName(moduleManifestPath));
 14394        if (TryNormalizeModuleVersion(versionDirectory, out var normalizedVersionDirectory))
 4395        {
 14396            versionText = normalizedVersionDirectory;
 14397            return true;
 4398        }
 4399
 04400        return false;
 4401    }
 4402
 4403    /// <summary>
 4404    /// Attempts to read module semantic version (including prerelease) from a PowerShell module manifest file.
 4405    /// </summary>
 4406    /// <param name="manifestPath">Manifest path.</param>
 4407    /// <param name="versionText">Semantic version text when present.</param>
 4408    /// <returns>True when semantic version was discovered.</returns>
 4409    private static bool TryReadModuleSemanticVersionFromManifest(string manifestPath, out string versionText)
 4410    {
 94411        versionText = string.Empty;
 94412        if (!TryReadModuleVersionFromManifest(manifestPath, out var baseVersion))
 4413        {
 34414            return false;
 4415        }
 4416
 64417        var semanticVersion = baseVersion;
 4418        try
 4419        {
 64420            var content = File.ReadAllText(manifestPath);
 64421            var prereleaseMatch = ModulePrereleasePatternRegex.Match(content);
 64422            if (prereleaseMatch.Success)
 4423            {
 24424                var prereleaseValue = prereleaseMatch.Groups["value"].Value.Trim();
 24425                if (!string.IsNullOrWhiteSpace(prereleaseValue)
 24426                    && !baseVersion.Contains('-', StringComparison.Ordinal)
 24427                    && !baseVersion.Contains('+', StringComparison.Ordinal))
 4428                {
 24429                    semanticVersion = $"{baseVersion}-{prereleaseValue}";
 4430                }
 4431            }
 64432        }
 04433        catch
 4434        {
 4435            // Fall back to ModuleVersion when Prerelease inspection fails.
 04436        }
 4437
 64438        versionText = semanticVersion;
 64439        return !string.IsNullOrWhiteSpace(versionText);
 4440    }
 4441
 4442    /// <summary>
 4443    /// Attempts to query the latest Kestrun module version string from PowerShell Gallery.
 4444    /// </summary>
 4445    /// <param name="version">Latest gallery version string when available.</param>
 4446    /// <param name="errorText">Error details when discovery fails.</param>
 4447    /// <returns>True when latest gallery version was discovered.</returns>
 4448    private static bool TryGetLatestGalleryVersionString(out string version, out string errorText)
 04449        => TryGetLatestGalleryVersionStringFromClient(GalleryHttpClient, out version, out errorText);
 4450
 4451    /// <summary>
 4452    /// Attempts to query the latest Kestrun module version string from PowerShell Gallery using the specified HTTP clie
 4453    /// </summary>
 4454    /// <param name="httpClient">HTTP client used for the gallery request.</param>
 4455    /// <param name="version">Latest gallery version string when available.</param>
 4456    /// <param name="errorText">Error details when discovery fails.</param>
 4457    /// <returns>True when latest gallery version was discovered.</returns>
 4458    private static bool TryGetLatestGalleryVersionStringFromClient(HttpClient httpClient, out string version, out string
 4459    {
 14460        version = string.Empty;
 14461        if (!TryGetGalleryModuleVersionsFromClient(httpClient, out var versions, out errorText))
 4462        {
 04463            return false;
 4464        }
 4465
 14466        var latestVersion = versions[0];
 64467        for (var index = 1; index < versions.Count; index++)
 4468        {
 24469            if (CompareModuleVersionValues(versions[index], latestVersion) > 0)
 4470            {
 14471                latestVersion = versions[index];
 4472            }
 4473        }
 4474
 14475        version = latestVersion;
 14476        return !string.IsNullOrWhiteSpace(version);
 4477    }
 4478
 4479    /// <summary>
 4480    /// Queries all available Kestrun module versions from PowerShell Gallery.
 4481    /// </summary>
 4482    /// <param name="versions">Discovered gallery versions.</param>
 4483    /// <param name="errorText">Error details when discovery fails.</param>
 4484    /// <returns>True when at least one version was discovered.</returns>
 4485    private static bool TryGetGalleryModuleVersions(out List<string> versions, out string errorText)
 04486        => TryGetGalleryModuleVersionsFromClient(GalleryHttpClient, out versions, out errorText);
 4487
 4488    /// <summary>
 4489    /// Queries all available Kestrun module versions from PowerShell Gallery using the specified HTTP client.
 4490    /// </summary>
 4491    /// <param name="httpClient">HTTP client used for the gallery request.</param>
 4492    /// <param name="versions">Discovered gallery versions.</param>
 4493    /// <param name="errorText">Error details when discovery fails.</param>
 4494    /// <returns>True when at least one version was discovered.</returns>
 4495    private static bool TryGetGalleryModuleVersionsFromClient(HttpClient httpClient, out List<string> versions, out stri
 4496    {
 34497        versions = [];
 34498        errorText = string.Empty;
 4499
 4500        try
 4501        {
 34502            var requestUri = $"{PowerShellGalleryApiBaseUri}/FindPackagesById()?id='{Uri.EscapeDataString(ModuleName)}'"
 34503            using var response = httpClient.GetAsync(requestUri).GetAwaiter().GetResult();
 34504            if (!response.IsSuccessStatusCode)
 4505            {
 14506                var reason = string.IsNullOrWhiteSpace(response.ReasonPhrase)
 14507                    ? "Unknown error"
 14508                    : response.ReasonPhrase;
 14509                errorText = $"PowerShell Gallery request failed with HTTP {(int)response.StatusCode} ({reason}).";
 14510                return false;
 4511            }
 4512
 24513            var content = response.Content.ReadAsStringAsync().GetAwaiter().GetResult();
 24514            if (string.IsNullOrWhiteSpace(content))
 4515            {
 04516                errorText = "PowerShell Gallery response was empty.";
 04517                return false;
 4518            }
 4519
 24520            return TryParseGalleryModuleVersions(content, out versions, out errorText);
 4521        }
 04522        catch (Exception ex)
 4523        {
 04524            errorText = ex.Message;
 04525            return false;
 4526        }
 34527    }
 4528
 4529    /// <summary>
 4530    /// Parses gallery feed XML and extracts module versions.
 4531    /// </summary>
 4532    /// <param name="content">Gallery feed XML payload.</param>
 4533    /// <param name="versions">Discovered gallery versions.</param>
 4534    /// <param name="errorText">Error details when parsing fails.</param>
 4535    /// <returns>True when at least one version was discovered.</returns>
 4536    private static bool TryParseGalleryModuleVersions(string content, out List<string> versions, out string errorText)
 4537    {
 34538        versions = [];
 34539        errorText = string.Empty;
 4540
 4541        try
 4542        {
 34543            var document = XDocument.Parse(content);
 24544            var discoveredVersions = document.Descendants()
 164545                .Where(static element => string.Equals(element.Name.LocalName, "Version", StringComparison.OrdinalIgnore
 74546                .Select(static element => element.Value.Trim())
 74547                .Where(static versionText => !string.IsNullOrWhiteSpace(versionText))
 24548                .Distinct(StringComparer.OrdinalIgnoreCase)
 24549                .ToList();
 4550
 24551            if (discoveredVersions.Count == 0)
 4552            {
 04553                errorText = $"Module '{ModuleName}' was not found on PowerShell Gallery.";
 04554                return false;
 4555            }
 4556
 24557            versions = discoveredVersions;
 24558            return true;
 4559        }
 14560        catch (Exception ex)
 4561        {
 14562            errorText = ex.Message;
 14563            return false;
 4564        }
 34565    }
 4566
 4567    /// <summary>
 4568    /// Creates the shared HTTP client used for PowerShell Gallery requests.
 4569    /// </summary>
 4570    /// <returns>Configured HTTP client instance.</returns>
 4571    private static HttpClient CreateGalleryHttpClient()
 4572    {
 14573        var client = new HttpClient
 14574        {
 14575            Timeout = TimeSpan.FromSeconds(60),
 14576        };
 4577
 14578        client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0"));
 14579        return client;
 4580    }
 4581
 4582    /// <summary>
 4583    /// Creates the shared HTTP client used for service content-root archive downloads.
 4584    /// </summary>
 4585    /// <returns>Configured HTTP client instance.</returns>
 4586    private static HttpClient CreateServiceContentRootHttpClient()
 4587    {
 14588        var client = new HttpClient
 14589        {
 14590            Timeout = TimeSpan.FromMinutes(5),
 14591        };
 4592
 14593        client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue(ProductName, "1.0"));
 14594        return client;
 4595    }
 4596
 4597    /// <summary>
 4598    /// Parses a module version value into a comparable <see cref="Version"/> instance.
 4599    /// </summary>
 4600    /// <param name="rawValue">Raw version string.</param>
 4601    /// <param name="version">Parsed version.</param>
 4602    /// <returns>True when parsing succeeds.</returns>
 4603    private static bool TryParseVersionValue(string? rawValue, out Version version)
 4604    {
 164605        version = new Version(0, 0);
 164606        if (string.IsNullOrWhiteSpace(rawValue))
 4607        {
 04608            return false;
 4609        }
 4610
 164611        var normalized = rawValue.Trim();
 164612        var suffixIndex = normalized.IndexOfAny(['-', '+']);
 164613        if (suffixIndex >= 0)
 4614        {
 64615            normalized = normalized[..suffixIndex];
 4616        }
 4617
 164618        if (!Version.TryParse(normalized, out var parsedVersion) || parsedVersion is null)
 4619        {
 14620            return false;
 4621        }
 4622
 154623        version = parsedVersion;
 154624        return true;
 4625    }
 4626
 4627    /// <summary>
 4628    /// Normalizes a module version value to the stable numeric folder format used by PowerShell module installs.
 4629    /// </summary>
 4630    /// <param name="rawValue">Raw version token that may include prerelease/build suffixes.</param>
 4631    /// <param name="normalizedVersion">Normalized numeric version text.</param>
 4632    /// <returns>True when normalization succeeds.</returns>
 4633    private static bool TryNormalizeModuleVersion(string? rawValue, out string normalizedVersion)
 4634    {
 54635        normalizedVersion = string.Empty;
 54636        if (!TryParseVersionValue(rawValue, out var parsedVersion))
 4637        {
 04638            return false;
 4639        }
 4640
 54641        normalizedVersion = parsedVersion.ToString();
 54642        return true;
 4643    }
 4644
 4645    /// <summary>
 4646    /// Tries to parse a module storage scope token.
 4647    /// </summary>
 4648    /// <param name="scopeToken">Scope token.</param>
 4649    /// <param name="scope">Parsed scope value.</param>
 4650    /// <returns>True when parsing succeeds.</returns>
 4651    private static bool TryParseModuleScope(string? scopeToken, out ModuleStorageScope scope)
 4652    {
 34653        scope = ModuleStorageScope.Local;
 34654        if (string.IsNullOrWhiteSpace(scopeToken))
 4655        {
 04656            return false;
 4657        }
 4658
 34659        if (string.Equals(scopeToken, ModuleScopeLocalValue, StringComparison.OrdinalIgnoreCase))
 4660        {
 04661            scope = ModuleStorageScope.Local;
 04662            return true;
 4663        }
 4664
 34665        if (string.Equals(scopeToken, ModuleScopeGlobalValue, StringComparison.OrdinalIgnoreCase))
 4666        {
 24667            scope = ModuleStorageScope.Global;
 24668            return true;
 4669        }
 4670
 14671        return false;
 4672    }
 4673
 4674    /// <summary>
 4675    /// Gets a stable scope token for messages and help text.
 4676    /// </summary>
 4677    /// <param name="scope">Module storage scope.</param>
 4678    /// <returns>Normalized scope token.</returns>
 4679    private static string GetScopeToken(ModuleStorageScope scope)
 24680        => scope == ModuleStorageScope.Global ? ModuleScopeGlobalValue : ModuleScopeLocalValue;
 4681
 4682    /// <summary>
 4683    /// Compares two module version strings.
 4684    /// </summary>
 4685    /// <param name="leftVersion">Left version.</param>
 4686    /// <param name="rightVersion">Right version.</param>
 4687    /// <returns>Comparison result compatible with <see cref="IComparer{T}"/>.</returns>
 4688    private static int CompareModuleVersionValues(string? leftVersion, string? rightVersion)
 4689    {
 44690        if (ReferenceEquals(leftVersion, rightVersion))
 4691        {
 04692            return 0;
 4693        }
 4694
 44695        if (string.IsNullOrWhiteSpace(leftVersion))
 4696        {
 04697            return -1;
 4698        }
 4699
 44700        if (string.IsNullOrWhiteSpace(rightVersion))
 4701        {
 04702            return 1;
 4703        }
 4704
 44705        if (TryParseVersionValue(leftVersion, out var leftParsed)
 44706            && TryParseVersionValue(rightVersion, out var rightParsed))
 4707        {
 44708            var comparison = leftParsed.CompareTo(rightParsed);
 44709            if (comparison != 0)
 4710            {
 34711                return comparison;
 4712            }
 4713
 14714            var leftHasPrerelease = HasPrereleaseSuffix(leftVersion);
 14715            var rightHasPrerelease = HasPrereleaseSuffix(rightVersion);
 14716            if (leftHasPrerelease != rightHasPrerelease)
 4717            {
 04718                return leftHasPrerelease ? -1 : 1;
 4719            }
 4720        }
 4721
 14722        return string.Compare(leftVersion.Trim(), rightVersion.Trim(), StringComparison.OrdinalIgnoreCase);
 4723    }
 4724
 4725    /// <summary>
 4726    /// Determines whether a module version string includes prerelease suffix data.
 4727    /// </summary>
 4728    /// <param name="versionText">Version string to inspect.</param>
 4729    /// <returns>True when prerelease or build suffix exists.</returns>
 4730    private static bool HasPrereleaseSuffix(string versionText)
 24731        => versionText.Contains('-', StringComparison.Ordinal) || versionText.Contains('+', StringComparison.Ordinal);
 4732
 4733    /// <summary>
 4734    /// Reads ModuleVersion from a PowerShell module manifest file.
 4735    /// </summary>
 4736    /// <param name="manifestPath">Manifest path.</param>
 4737    /// <param name="versionText">ModuleVersion text when present.</param>
 4738    /// <returns>True when ModuleVersion was discovered.</returns>
 4739    private static bool TryReadModuleVersionFromManifest(string manifestPath, out string versionText)
 4740    {
 94741        versionText = string.Empty;
 4742
 4743        try
 4744        {
 94745            var content = File.ReadAllText(manifestPath);
 94746            var match = ModuleVersionPatternRegex.Match(content);
 94747            if (!match.Success)
 4748            {
 34749                return false;
 4750            }
 4751
 64752            versionText = match.Groups["value"].Value.Trim();
 64753            return !string.IsNullOrWhiteSpace(versionText);
 4754        }
 04755        catch
 4756        {
 04757            return false;
 4758        }
 94759    }
 4760
 4761    /// <summary>
 4762    /// Enumerates installed module manifest records from the user module root.
 4763    /// </summary>
 4764    /// <param name="moduleRoot">Module root path.</param>
 4765    /// <returns>Installed module records sorted by version descending.</returns>
 4766    private static List<InstalledModuleRecord> GetInstalledModuleRecords(string moduleRoot)
 4767    {
 44768        var records = new List<InstalledModuleRecord>();
 44769        if (!Directory.Exists(moduleRoot))
 4770        {
 04771            return records;
 4772        }
 4773
 44774        var seenManifestPaths = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 164775        foreach (var manifestPath in Directory.EnumerateFiles(moduleRoot, ModuleManifestFileName, SearchOption.AllDirect
 4776        {
 44777            if (!seenManifestPaths.Add(manifestPath))
 4778            {
 4779                continue;
 4780            }
 4781
 44782            var versionDirectory = Path.GetFileName(Path.GetDirectoryName(manifestPath));
 44783            string? versionText = null;
 4784
 44785            if (TryReadModuleSemanticVersionFromManifest(manifestPath, out var manifestSemanticVersion))
 4786            {
 24787                versionText = manifestSemanticVersion;
 4788            }
 4789
 44790            if (string.IsNullOrWhiteSpace(versionText)
 44791                && !string.IsNullOrWhiteSpace(versionDirectory)
 44792                && TryNormalizeModuleVersion(versionDirectory, out var normalizedVersionDirectory))
 4793            {
 24794                versionText = normalizedVersionDirectory;
 4795            }
 4796
 44797            if (string.IsNullOrWhiteSpace(versionText))
 4798            {
 04799                versionText = versionDirectory;
 4800            }
 4801
 44802            if (string.IsNullOrWhiteSpace(versionText))
 4803            {
 4804                continue;
 4805            }
 4806
 44807            records.Add(new InstalledModuleRecord(versionText, manifestPath));
 4808        }
 4809
 44810        records.Sort(static (left, right) => CompareModuleVersionValues(right.Version, left.Version));
 44811        return records;
 4812    }
 4813
 4814    /// <summary>
 4815    /// Gets the module storage path for a selected scope.
 4816    /// </summary>
 4817    /// <param name="scope">Module storage scope.</param>
 4818    /// <returns>Absolute module storage path.</returns>
 4819    private static string GetPowerShellModulePath(ModuleStorageScope scope)
 04820        => scope == ModuleStorageScope.Global
 04821            ? GetGlobalPowerShellModulePath()
 04822            : GetDefaultPowerShellModulePath();
 4823
 4824    /// <summary>
 4825    /// Gets the default all-users PowerShell module path for the active OS.
 4826    /// </summary>
 4827    /// <returns>Absolute all-users module path.</returns>
 4828    private static string GetGlobalPowerShellModulePath()
 4829    {
 04830        if (OperatingSystem.IsWindows())
 4831        {
 04832            var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
 04833            var root = string.IsNullOrWhiteSpace(programFiles) ? @"C:\Program Files" : programFiles;
 04834            return Path.Combine(root, "PowerShell", "Modules");
 4835        }
 4836
 04837        return "/usr/local/share/powershell/Modules";
 4838    }
 4839
 4840    /// <summary>
 4841    /// Gets the default current-user PowerShell module path for the active OS.
 4842    /// </summary>
 4843    /// <returns>Absolute module path.</returns>
 4844    private static string GetDefaultPowerShellModulePath()
 4845    {
 14846        var userHome = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
 14847        if (OperatingSystem.IsWindows())
 4848        {
 04849            var documents = Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments);
 04850            var root = string.IsNullOrWhiteSpace(documents) ? userHome : documents;
 04851            return Path.Combine(root, "PowerShell", "Modules");
 4852        }
 4853
 14854        return Path.Combine(userHome, ".local", "share", "powershell", "Modules");
 4855    }
 4856
 4857    /// <summary>
 4858    /// Writes a consistent module-not-found message with remediation guidance.
 4859    /// </summary>
 4860    /// <param name="kestrunManifestPath">Optional explicit manifest path argument.</param>
 4861    /// <param name="kestrunFolder">Optional explicit module folder argument.</param>
 4862    /// <param name="writeLine">Output writer callback.</param>
 4863    private static void WriteModuleNotFoundMessage(string? kestrunManifestPath, string? kestrunFolder, Action<string> wr
 4864    {
 94865        if (!string.IsNullOrWhiteSpace(kestrunManifestPath))
 4866        {
 64867            writeLine($"Unable to locate manifest file: {Path.GetFullPath(kestrunManifestPath)}");
 4868        }
 34869        else if (!string.IsNullOrWhiteSpace(kestrunFolder))
 4870        {
 14871            writeLine($"Unable to locate {ModuleManifestFileName} in folder: {Path.GetFullPath(kestrunFolder)}");
 4872        }
 4873        else
 4874        {
 24875            writeLine($"Unable to locate {ModuleManifestFileName} under the executable folder or PSModulePath.");
 4876        }
 4877
 94878        writeLine($"No {ModuleName} module was found. Use '{ProductName} module install' to install it from PowerShell G
 94879    }
 4880
 4881    /// <summary>
 4882    /// Tries to parse command-line arguments into a concrete command payload.
 4883    /// </summary>
 4884    /// <param name="args">Raw command-line arguments.</param>
 4885    /// <param name="parsedCommand">Parsed command payload.</param>
 4886    /// <param name="error">Error message when parsing fails.</param>
 4887    /// <returns>True when parsing succeeds.</returns>
 4888    private static bool TryParseArguments(string[] args, out ParsedCommand parsedCommand, out string error)
 4889    {
 834890        parsedCommand = new ParsedCommand(CommandMode.Run, string.Empty, false, [], null, null, null, false, null, null,
 834891        if (args.Length == 0)
 4892        {
 04893            error = $"No command provided. Use '{ProductName} help' to list commands.";
 04894            return false;
 4895        }
 4896
 834897        if (!TryParseLeadingKestrunOptions(args, out var commandTokenIndex, out var kestrunFolder, out var kestrunManife
 4898        {
 04899            return false;
 4900        }
 4901
 834902        if (commandTokenIndex >= args.Length)
 4903        {
 04904            error = $"No command provided. Use '{ProductName} help' to list commands.";
 04905            return false;
 4906        }
 4907
 834908        return TryParseCommandFromToken(args, commandTokenIndex, kestrunFolder, kestrunManifestPath, out parsedCommand, 
 4909    }
 4910
 4911    /// <summary>
 4912    /// Parses leading global Kestrun options that may appear before the command token.
 4913    /// </summary>
 4914    /// <param name="args">Raw command-line arguments.</param>
 4915    /// <param name="commandTokenIndex">Index of the command token after global options.</param>
 4916    /// <param name="kestrunFolder">Optional module folder path supplied via global option.</param>
 4917    /// <param name="kestrunManifestPath">Optional module manifest path supplied via global option.</param>
 4918    /// <param name="error">Error message when a required option value is missing.</param>
 4919    /// <returns>True when leading options are parsed successfully.</returns>
 4920    private static bool TryParseLeadingKestrunOptions(
 4921        string[] args,
 4922        out int commandTokenIndex,
 4923        out string? kestrunFolder,
 4924        out string? kestrunManifestPath,
 4925        out string error)
 4926    {
 854927        commandTokenIndex = 0;
 854928        kestrunFolder = null;
 854929        kestrunManifestPath = null;
 854930        error = string.Empty;
 4931
 884932        while (commandTokenIndex < args.Length)
 4933        {
 884934            var current = args[commandTokenIndex];
 884935            if (current is "--kestrun-folder" or "-k")
 4936            {
 24937                if (!TryConsumeLeadingOptionValue(args, ref commandTokenIndex, "--kestrun-folder", out var folderValue, 
 4938                {
 04939                    return false;
 4940                }
 4941
 24942                kestrunFolder = folderValue;
 24943                continue;
 4944            }
 4945
 864946            if (current is "--kestrun-manifest" or "-m")
 4947            {
 24948                if (!TryConsumeLeadingOptionValue(args, ref commandTokenIndex, "--kestrun-manifest", out var manifestVal
 4949                {
 14950                    return false;
 4951                }
 4952
 14953                kestrunManifestPath = manifestValue;
 4954                continue;
 4955            }
 4956
 4957            break;
 4958        }
 4959
 844960        return true;
 4961    }
 4962
 4963    /// <summary>
 4964    /// Consumes a global option value and advances the parse index.
 4965    /// </summary>
 4966    /// <param name="args">Raw command-line arguments.</param>
 4967    /// <param name="index">Current option index, advanced when consumption succeeds.</param>
 4968    /// <param name="optionName">Canonical option name used in diagnostics.</param>
 4969    /// <param name="value">Consumed option value.</param>
 4970    /// <param name="error">Error text when the value is missing.</param>
 4971    /// <returns>True when the option value is consumed.</returns>
 4972    private static bool TryConsumeLeadingOptionValue(string[] args, ref int index, string optionName, out string value, 
 4973    {
 44974        value = string.Empty;
 44975        if (index + 1 >= args.Length)
 4976        {
 14977            error = $"Missing value for {optionName}.";
 14978            return false;
 4979        }
 4980
 34981        value = args[index + 1];
 34982        index += 2;
 34983        error = string.Empty;
 34984        return true;
 4985    }
 4986
 4987    /// <summary>
 4988    /// Dispatches command parsing based on the selected command token.
 4989    /// </summary>
 4990    /// <param name="args">Raw command-line arguments.</param>
 4991    /// <param name="commandTokenIndex">Index of the command token.</param>
 4992    /// <param name="kestrunFolder">Optional module folder path.</param>
 4993    /// <param name="kestrunManifestPath">Optional module manifest path.</param>
 4994    /// <param name="parsedCommand">Parsed command payload.</param>
 4995    /// <param name="error">Error message when dispatch fails.</param>
 4996    /// <returns>True when command parsing succeeds.</returns>
 4997    private static bool TryParseCommandFromToken(
 4998        string[] args,
 4999        int commandTokenIndex,
 5000        string? kestrunFolder,
 5001        string? kestrunManifestPath,
 5002        out ParsedCommand parsedCommand,
 5003        out string error)
 5004    {
 845005        var commandToken = args[commandTokenIndex];
 845006        if (string.Equals(commandToken, "run", StringComparison.OrdinalIgnoreCase))
 5007        {
 85008            return TryParseRunArguments(args, commandTokenIndex + 1, kestrunFolder, kestrunManifestPath, out parsedComma
 5009        }
 5010
 765011        if (string.Equals(commandToken, "service", StringComparison.OrdinalIgnoreCase))
 5012        {
 645013            return TryParseServiceArguments(args, commandTokenIndex + 1, kestrunFolder, kestrunManifestPath, out parsedC
 5014        }
 5015
 125016        if (string.Equals(commandToken, "module", StringComparison.OrdinalIgnoreCase))
 5017        {
 105018            return TryParseModuleArguments(args, commandTokenIndex + 1, out parsedCommand, out error);
 5019        }
 5020
 25021        parsedCommand = new ParsedCommand(CommandMode.Run, string.Empty, false, [], null, null, null, false, null, null,
 25022        error = $"Unknown command: {commandToken}. Use '{ProductName} help' to list commands.";
 25023        return false;
 5024    }
 5025
 5026    /// <summary>
 5027    /// Handles help/info/version command routing before command parsing.
 5028    /// </summary>
 5029    /// <param name="args">Raw command-line arguments.</param>
 5030    /// <param name="exitCode">Exit code for the handled command.</param>
 5031    /// <returns>True when a meta command was handled.</returns>
 5032    private static bool TryHandleMetaCommands(string[] args, out int exitCode)
 5033    {
 105034        exitCode = 0;
 105035        var filtered = FilterGlobalOptions(args);
 105036        if (filtered.Count == 0)
 5037        {
 15038            PrintUsage();
 15039            return true;
 5040        }
 5041
 95042        if (IsHelpToken(filtered[0]) || string.Equals(filtered[0], "help", StringComparison.OrdinalIgnoreCase))
 5043        {
 45044            if (filtered.Count == 1)
 5045            {
 25046                PrintUsage();
 25047                return true;
 5048            }
 5049
 25050            if (filtered.Count == 2 && TryGetHelpTopic(filtered[1], out var topic))
 5051            {
 15052                PrintHelpForTopic(topic);
 15053                return true;
 5054            }
 5055
 15056            Console.Error.WriteLine("Unknown help topic. Use 'kestrun help' to list available topics.");
 15057            exitCode = 2;
 15058            return true;
 5059        }
 5060
 55061        if (filtered.Count == 2
 55062            && TryGetHelpTopic(filtered[0], out var commandTopic)
 55063            && (IsHelpToken(filtered[1]) || string.Equals(filtered[1], "help", StringComparison.OrdinalIgnoreCase)))
 5064        {
 15065            PrintHelpForTopic(commandTopic);
 15066            return true;
 5067        }
 5068
 45069        if (filtered.Count == 1 && string.Equals(filtered[0], "version", StringComparison.OrdinalIgnoreCase))
 5070        {
 15071            PrintVersion();
 15072            return true;
 5073        }
 5074
 35075        if (filtered.Count == 1 && string.Equals(filtered[0], "info", StringComparison.OrdinalIgnoreCase))
 5076        {
 15077            PrintInfo();
 15078            return true;
 5079        }
 5080
 25081        return false;
 5082    }
 5083
 5084    /// <summary>
 5085    /// Checks whether an argument token requests usage help.
 5086    /// </summary>
 5087    /// <param name="token">Command-line token to inspect.</param>
 5088    /// <returns>True when the token is a help switch.</returns>
 105089    private static bool IsHelpToken(string token) => token is "-h" or "--help" or "/?";
 5090
 5091    /// <summary>
 5092    /// Tries to map a help topic token to a known command topic.
 5093    /// </summary>
 5094    /// <param name="token">Input help topic token.</param>
 5095    /// <param name="topic">Normalized topic when recognized.</param>
 5096    /// <returns>True when the topic is recognized.</returns>
 5097    private static bool TryGetHelpTopic(string token, out string topic)
 5098    {
 35099        topic = token.ToLowerInvariant();
 35100        return topic is "run" or "service" or "module" or "info" or "version";
 5101    }
 5102
 5103    /// <summary>
 5104    /// Filters known global options injected by launchers from command-line args.
 5105    /// </summary>
 5106    /// <param name="args">Raw command-line arguments.</param>
 5107    /// <returns>Arguments without known global options and their values.</returns>
 5108    private static List<string> FilterGlobalOptions(string[] args)
 5109    {
 115110        var filtered = new List<string>(args.Length);
 505111        for (var index = 0; index < args.Length; index++)
 5112        {
 145113            if (args[index] is "--kestrun-folder" or "-k" or "--kestrun-manifest" or "-m")
 5114            {
 05115                index += 1;
 05116                continue;
 5117            }
 5118
 145119            if (IsNoCheckOption(args[index]))
 5120            {
 5121                continue;
 5122            }
 5123
 135124            filtered.Add(args[index]);
 5125        }
 5126
 115127        return filtered;
 5128    }
 5129
 5130    /// <summary>
 5131    /// Prints command usage and discovery hints.
 5132    /// </summary>
 5133    private static void PrintUsage()
 5134    {
 55135        Console.WriteLine("Usage:");
 55136        Console.WriteLine("  kestrun <command> [options]");
 55137        Console.WriteLine();
 55138        Console.WriteLine("Global options:");
 55139        Console.WriteLine($"  {NoCheckOption}          Skip PowerShell Gallery update check warnings.");
 55140        Console.WriteLine();
 55141        Console.WriteLine("Commands:");
 55142        Console.WriteLine("  run       Run a PowerShell script (default script: ./Service.ps1)");
 55143        Console.WriteLine("  module    Manage Kestrun module (install/update/remove/info)");
 55144        Console.WriteLine("  service   Manage service lifecycle (install/update/remove/start/stop/query/info)");
 55145        Console.WriteLine("  info      Show runtime/build diagnostics");
 55146        Console.WriteLine("  version   Show tool version");
 55147        Console.WriteLine();
 55148        Console.WriteLine("Help topics:");
 55149        Console.WriteLine("  kestrun run help");
 55150        Console.WriteLine("  kestrun module help");
 55151        Console.WriteLine("  kestrun service help");
 55152        Console.WriteLine("  kestrun info help");
 55153        Console.WriteLine("  kestrun version help");
 55154    }
 5155
 5156    /// <summary>
 5157    /// Prints detailed help for a specific topic.
 5158    /// </summary>
 5159    /// <param name="topic">Help topic.</param>
 5160    private static void PrintHelpForTopic(string topic)
 5161    {
 5162        switch (topic)
 5163        {
 5164            case "run":
 35165                Console.WriteLine("Usage:");
 35166                Console.WriteLine("  kestrun [--nocheck] [--kestrun-folder <folder>] [--kestrun-manifest <path-to-Kestru
 35167                Console.WriteLine();
 35168                Console.WriteLine("Options:");
 35169                Console.WriteLine("  --script <path>             Optional named script path (alternative to positional <
 35170                Console.WriteLine("  --kestrun-manifest <path>   Use an explicit Kestrun.psd1 manifest file.");
 35171                Console.WriteLine("  --arguments <args...>       Pass remaining values to the script as script arguments
 35172                Console.WriteLine();
 35173                Console.WriteLine("Notes:");
 35174                Console.WriteLine("  - If no script is provided, ./Service.ps1 is used.");
 35175                Console.WriteLine("  - Script arguments must be passed after --arguments (or --).");
 35176                Console.WriteLine("  - Use --kestrun-manifest to pin a specific Kestrun.psd1 file.");
 35177                Console.WriteLine($"  - If {ModuleName} is missing, run '{ProductName} module install'.");
 35178                break;
 5179
 5180            case "module":
 15181                Console.WriteLine("Usage:");
 15182                Console.WriteLine($"  {ProductName} module install [{ModuleVersionOption} <version>] [{ModuleScopeOption
 15183                Console.WriteLine($"  {ProductName} module update [{ModuleVersionOption} <version>] [{ModuleScopeOption}
 15184                Console.WriteLine($"  {ProductName} module remove [{ModuleScopeOption} <{ModuleScopeLocalValue}|{ModuleS
 15185                Console.WriteLine($"  {ProductName} module info [{ModuleScopeOption} <{ModuleScopeLocalValue}|{ModuleSco
 15186                Console.WriteLine();
 15187                Console.WriteLine("Options:");
 15188                Console.WriteLine($"  {ModuleVersionOption} <version>      Optional specific version for install/update.
 15189                Console.WriteLine($"  {ModuleScopeOption} <scope>         Module storage scope: '{ModuleScopeLocalValue}
 15190                Console.WriteLine($"  {ModuleForceOption}                 Overwrite existing target version folder for u
 15191                Console.WriteLine();
 15192                Console.WriteLine("Notes:");
 15193                Console.WriteLine($"  - install: fails when Kestrun is already installed; use '{ProductName} module upda
 15194                Console.WriteLine($"  - update: updates to latest when no --version is provided and fails if the target 
 15195                Console.WriteLine("  - remove: removes all installed versions from the selected scope and shows deletion
 15196                Console.WriteLine("  - info: shows installed module versions and latest Gallery version for the selected
 15197                Console.WriteLine("  - Windows global scope for install/update/remove prompts for elevation (UAC) when n
 15198                break;
 5199
 5200            case "service":
 15201                Console.WriteLine("Usage:");
 15202                Console.WriteLine("  kestrun [--nocheck] [--kestrun-manifest <path-to-Kestrun.psd1>] service install --p
 15203                Console.WriteLine("  kestrun [--nocheck] service update --name <service-name> [--package <path-or-url-to
 15204                Console.WriteLine("  kestrun service remove --name <service-name>");
 15205                Console.WriteLine("  kestrun service start --name <service-name> [--json | --raw]");
 15206                Console.WriteLine("  kestrun service stop --name <service-name> [--json | --raw]");
 15207                Console.WriteLine("  kestrun service query --name <service-name> [--json | --raw]");
 15208                Console.WriteLine("  kestrun service info [--name <service-name>] [--json]");
 15209                Console.WriteLine();
 15210                Console.WriteLine("Options (service install):");
 15211                Console.WriteLine("  --package <path-or-url>     Required .krpack (zip) package containing Service.psd1 
 15212                Console.WriteLine("  --content-root-checksum <h> Verify package checksum before extraction (hex string).
 15213                Console.WriteLine("  --content-root-checksum-algorithm <name>  Hash algorithm: md5, sha1, sha256, sha384
 15214                Console.WriteLine("  --content-root-bearer-token <token>  Add Authorization: Bearer <token> for HTTP(S) 
 15215                Console.WriteLine("  --content-root-header <name:value>  Add custom HTTP request header for HTTP(S) pack
 15216                Console.WriteLine("  --content-root-ignore-certificate  Ignore HTTPS certificate validation for package 
 15217                Console.WriteLine("  --deployment-root <folder>  Override where per-service bundles are created (default
 15218                Console.WriteLine("  --kestrun-manifest <path>   Use an explicit Kestrun.psd1 manifest for the service r
 15219                Console.WriteLine("  --service-log-path <path>   Set service bootstrap/operation log file path.");
 15220                Console.WriteLine("  --service-user <account>    Run installed service/daemon under a specific OS accoun
 15221                Console.WriteLine("  --service-password <secret> Password for --service-user on Windows service accounts
 15222                Console.WriteLine("  --arguments <args...>       Pass remaining values to the installed script.");
 15223                Console.WriteLine("  --kestrun                   For service update: use repository module at src/PowerS
 15224                Console.WriteLine("  --kestrun-module <path>     For service update: module manifest path or folder to r
 15225                Console.WriteLine("  --failback                  For service update: restore application/module from lat
 15226                Console.WriteLine("  --json                      For service start/stop/query/info: output JSON instead 
 15227                Console.WriteLine("  --raw                       For service start/stop/query: output native OS command 
 15228                Console.WriteLine();
 15229                Console.WriteLine("Notes:");
 15230                Console.WriteLine("  - install registers the service/daemon but does not auto-start it.");
 15231                Console.WriteLine("  - update fails when the service is running; stop it first.");
 15232                Console.WriteLine("  - update requires at least one of --package or --kestrun-module/--kestrun-manifest 
 15233                Console.WriteLine("  - --kestrun updates bundled module only when repository module version is newer; ot
 15234                Console.WriteLine("  - --failback restores from latest backup and fails when no backup is available.");
 15235                Console.WriteLine("  - info without --name lists installed Kestrun services.");
 15236                Console.WriteLine("  - Service name and entry point are read from Service.psd1 in the package.");
 15237                Console.WriteLine("  - Service.psd1 requires FormatVersion='1.0', Name, EntryPoint, and Description.");
 15238                Console.WriteLine("  - Package file must use .krpack extension and contain zip content.");
 15239                Console.WriteLine("  - --content-root-checksum is validated against the package file before extraction."
 15240                Console.WriteLine("  - --content-root-bearer-token is only used for HTTP(S) package URLs.");
 15241                Console.WriteLine("  - --content-root-header is only used for HTTP(S) package URLs and can be supplied m
 15242                Console.WriteLine("  - --content-root-ignore-certificate applies only to HTTPS package URLs and is insec
 15243                Console.WriteLine("  - --deployment-root overrides the OS default bundle root used during install and re
 15244                Console.WriteLine("  - --service-user enables platform account mapping: Windows service account, Linux s
 15245                Console.WriteLine("  - install snapshots runtime/module/script plus dedicated service-host from Kestrun.
 15246                Console.WriteLine("  - install shows progress bars during bundle staging in interactive terminals.");
 15247                Console.WriteLine("  - bundle roots: Windows %ProgramData%\\Kestrun\\services; Linux /var/kestrun/servic
 15248                Console.WriteLine("  - remove/start/stop/query require --name and do not accept script paths.");
 15249                Console.WriteLine($"  - Use '{ProductName} module install' before service install when {ModuleName} is n
 15250                break;
 5251
 5252            case "info":
 15253                Console.WriteLine("Usage:");
 15254                Console.WriteLine("  kestrun info");
 15255                Console.WriteLine();
 15256                Console.WriteLine("Shows runtime and build diagnostics (framework, OS, architecture, and binary paths)."
 15257                break;
 5258
 5259            case "version":
 15260                Console.WriteLine("Usage:");
 15261                Console.WriteLine("  kestrun version");
 15262                Console.WriteLine();
 15263                Console.WriteLine("Shows the kestrun tool version.");
 5264                break;
 5265        }
 15266    }
 5267
 5268    /// <summary>
 5269    /// Prints the KestrunTool version.
 5270    /// </summary>
 5271    private static void PrintVersion()
 5272    {
 25273        var version = GetProductVersion();
 25274        Console.WriteLine($"{ProductName} {version}");
 25275    }
 5276
 5277    /// <summary>
 5278    /// Prints diagnostic information about the KestrunTool build and runtime.
 5279    /// </summary>
 5280    private static void PrintInfo()
 5281    {
 25282        var version = GetProductVersion();
 25283        var assembly = typeof(Program).Assembly;
 25284        var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVe
 5285
 25286        Console.WriteLine($"Product: {ProductName}");
 25287        Console.WriteLine($"Version: {version}");
 25288        Console.WriteLine($"InformationalVersion: {informationalVersion}");
 25289        Console.WriteLine($"Framework: {RuntimeInformation.FrameworkDescription}");
 25290        Console.WriteLine($"OS: {RuntimeInformation.OSDescription}");
 25291        Console.WriteLine($"OSArchitecture: {RuntimeInformation.OSArchitecture}");
 25292        Console.WriteLine($"ProcessArchitecture: {RuntimeInformation.ProcessArchitecture}");
 25293        Console.WriteLine($"ExecutableDirectory: {GetExecutableDirectory()}");
 25294        Console.WriteLine($"BaseDirectory: {Path.GetFullPath(AppContext.BaseDirectory)}");
 25295    }
 5296
 5297    /// <summary>
 5298    /// Gets the product version from assembly metadata.
 5299    /// </summary>
 5300    /// <returns>Product version string.</returns>
 5301    private static string GetProductVersion()
 5302    {
 45303        var assembly = typeof(Program).Assembly;
 45304        var informationalVersion = assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVe
 45305        return !string.IsNullOrWhiteSpace(informationalVersion) ?
 45306        informationalVersion : assembly.GetName().Version?.ToString() ?? "0.0.0";
 5307    }
 5308
 5309    /// <summary>
 5310    /// Locates Kestrun.psd1 without launching an external pwsh process.
 5311    /// </summary>
 5312    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 5313    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 5314    /// <returns>Absolute manifest path when found; otherwise null.</returns>
 5315    private static string? LocateModuleManifest(string? kestrunManifestPath, string? kestrunFolder)
 5316    {
 95317        if (!string.IsNullOrWhiteSpace(kestrunManifestPath))
 5318        {
 85319            var explicitPath = Path.GetFullPath(kestrunManifestPath);
 85320            var explicitManifest = Directory.Exists(explicitPath)
 85321                ? Path.Combine(explicitPath, ModuleManifestFileName)
 85322                : explicitPath;
 85323            return File.Exists(explicitManifest) ? explicitManifest : null;
 5324        }
 5325
 15326        if (!string.IsNullOrWhiteSpace(kestrunFolder))
 5327        {
 15328            var explicitFolder = Path.GetFullPath(kestrunFolder);
 15329            var explicitCandidate = Path.Combine(explicitFolder, ModuleManifestFileName);
 15330            return File.Exists(explicitCandidate) ? explicitCandidate : null;
 5331        }
 5332
 05333        foreach (var candidate in EnumerateExecutableManifestCandidates())
 5334        {
 05335            if (File.Exists(candidate))
 5336            {
 05337                return Path.GetFullPath(candidate);
 5338            }
 5339        }
 5340
 05341        foreach (var candidate in EnumerateModulePathManifestCandidates())
 5342        {
 05343            if (File.Exists(candidate))
 5344            {
 05345                return Path.GetFullPath(candidate);
 5346            }
 5347        }
 5348
 05349        return null;
 05350    }
 5351
 5352    /// <summary>
 5353    /// Enumerates candidate locations for Kestrun.psd1 under the executable folder.
 5354    /// </summary>
 5355    /// <returns>Absolute manifest path when found; otherwise null.</returns>
 5356    private static IEnumerable<string> EnumerateExecutableManifestCandidates()
 5357    {
 05358        var executableDirectory = GetExecutableDirectory();
 5359
 05360        yield return Path.Combine(executableDirectory, ModuleManifestFileName);
 05361        yield return Path.Combine(executableDirectory, ModuleName, ModuleManifestFileName);
 5362
 05363        var baseDirectory = Path.GetFullPath(AppContext.BaseDirectory);
 05364        if (!string.Equals(baseDirectory, executableDirectory, StringComparison.OrdinalIgnoreCase))
 5365        {
 05366            yield return Path.Combine(baseDirectory, ModuleManifestFileName);
 05367            yield return Path.Combine(baseDirectory, ModuleName, ModuleManifestFileName);
 5368        }
 05369    }
 5370
 5371    /// <summary>
 5372    /// Gets the directory where the executable file is located.
 5373    /// </summary>
 5374    /// <returns>Absolute executable directory path.</returns>
 5375    private static string GetExecutableDirectory()
 5376    {
 205377        var processPath = Environment.ProcessPath;
 205378        if (!string.IsNullOrWhiteSpace(processPath))
 5379        {
 205380            var processDirectory = Path.GetDirectoryName(processPath);
 205381            if (!string.IsNullOrWhiteSpace(processDirectory))
 5382            {
 205383                return Path.GetFullPath(processDirectory);
 5384            }
 5385        }
 5386
 05387        return Path.GetFullPath(AppContext.BaseDirectory);
 5388    }
 5389
 5390    /// <summary>
 5391    /// Enumerates candidate manifest paths under PSModulePath entries.
 5392    /// </summary>
 5393    /// <returns>Potential manifest file paths from PSModulePath.</returns>
 5394    private static IEnumerable<string> EnumerateModulePathManifestCandidates()
 5395    {
 05396        var moduleRoots = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
 5397
 05398        var modulePathRaw = Environment.GetEnvironmentVariable("PSModulePath");
 05399        if (!string.IsNullOrWhiteSpace(modulePathRaw))
 5400        {
 05401            foreach (var root in modulePathRaw.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries | StringS
 5402            {
 05403                if (!string.IsNullOrWhiteSpace(root))
 5404                {
 05405                    _ = moduleRoots.Add(root);
 5406                }
 5407            }
 5408        }
 5409
 5410        // Service and elevated contexts can have an incomplete PSModulePath.
 5411        // Always include the conventional user/global module roots as discovery fallbacks.
 05412        _ = moduleRoots.Add(GetDefaultPowerShellModulePath());
 05413        _ = moduleRoots.Add(GetGlobalPowerShellModulePath());
 5414
 05415        foreach (var root in moduleRoots)
 5416        {
 05417            var moduleDirectory = Path.Combine(root, ModuleName);
 05418            yield return Path.Combine(moduleDirectory, ModuleManifestFileName);
 5419
 05420            if (!Directory.Exists(moduleDirectory))
 5421            {
 5422                continue;
 5423            }
 5424
 05425            var versionDirectories = Directory.EnumerateDirectories(moduleDirectory)
 5426                .OrderByDescending(path => path, StringComparer.OrdinalIgnoreCase);
 5427
 05428            foreach (var versionDirectory in versionDirectories)
 5429            {
 05430                yield return Path.Combine(versionDirectory, ModuleManifestFileName);
 5431            }
 05432        }
 05433    }
 5434}

/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 ModuleName = "Kestrun";
 11    private const string RunDefaultScriptFileName = "Service.ps1";
 12    private const string ServiceDefaultScriptFileName = "Service.ps1";
 13    private const string ProductName = "kestrun";
 14    private const string ServiceDeploymentProductFolderName = "Kestrun";
 15    private const string ServiceDeploymentServicesFolderName = "Services";
 16    private const string ServiceBundleRuntimeDirectoryName = "Runtime";
 17    private const string ServiceBundleModulesDirectoryName = "Modules";
 18    private const string ServiceBundleScriptDirectoryName = "Application";
 19    private const string WindowsServiceRuntimeBinaryName = "kestrun.exe";
 20    private const string UnixServiceRuntimeBinaryName = "kestrun";
 21    private const string ModuleVersionOption = "--version";
 22    private const string ModuleScopeOption = "--scope";
 23    private const string ModuleForceOption = "--force";
 24    private const string ModuleScopeLocalValue = "local";
 25    private const string ModuleScopeGlobalValue = "global";
 26    private const string NoCheckOption = "--nocheck";
 27    private const string NoCheckAliasOption = "--no-check";
 28    private const string RawOption = "--raw";
 29    private const string PowerShellGalleryApiBaseUri = "https://www.powershellgallery.com/api/v2";
 130    private static readonly Regex ModuleVersionPatternRegex = ModuleVersionRegex();
 131    private static readonly Regex ModulePrereleasePatternRegex = ModulePrereleaseRegex();
 132    private static readonly HttpClient GalleryHttpClient = CreateGalleryHttpClient();
 133    private static readonly HttpClient ServiceContentRootHttpClient = CreateServiceContentRootHttpClient();
 134    private static readonly string[] ServiceBundleModuleExclusionPatterns =
 135    [
 136        "lib/runtimes/*",
 137        "lib/net8.0/*",
 138        "lib/Microsoft.CodeAnalysis/4*/*",
 139    ];
 40    private enum CommandMode
 41    {
 42        Run,
 43        ModuleInstall,
 44        ModuleUpdate,
 45        ModuleRemove,
 46        ModuleInfo,
 47        ServiceInstall,
 48        ServiceUpdate,
 49        ServiceRemove,
 50        ServiceStart,
 51        ServiceStop,
 52        ServiceQuery,
 53        ServiceInfo,
 54    }
 55
 56    private enum ModuleCommandAction
 57    {
 58        Install,
 59        Update,
 60        Remove,
 61    }
 62
 63    private enum ModuleStorageScope
 64    {
 65        Local,
 66        Global,
 67    }
 68
 24369    private sealed record ParsedCommand(
 4170        CommandMode Mode,
 2371        string ScriptPath,
 2072        bool ScriptPathProvided,
 273        string[] ScriptArguments,
 1274        string? KestrunFolder,
 1975        string? KestrunManifestPath,
 3376        string? ServiceName,
 1977        bool ServiceNameProvided,
 878        string? ServiceLogPath,
 279        string? ServiceUser,
 180        string? ServicePassword,
 481        string? ModuleVersion,
 482        ModuleStorageScope ModuleScope,
 283        bool ModuleForce,
 2784        string? ServiceContentRoot,
 585        string? ServiceDeploymentRoot,
 3586        string? ServiceContentRootChecksum,
 687        string? ServiceContentRootChecksumAlgorithm,
 2788        string? ServiceContentRootBearerToken,
 2789        bool ServiceContentRootIgnoreCertificate,
 2790        string[] ServiceContentRootHeaders,
 791        bool ServiceFailback = false,
 1392        bool ServiceUseRepositoryKestrun = false,
 993        bool JsonOutput = false,
 25394        bool RawOutput = false);
 95
 096    private sealed record ServiceRegisterOptions(
 097        string ServiceName,
 098        string ServiceHostExecutablePath,
 099        string RunnerExecutablePath,
 0100        string ScriptPath,
 0101        string ModuleManifestPath,
 0102        string[] ScriptArguments,
 0103        string? ServiceLogPath,
 0104        string? ServiceUser,
 0105        string? ServicePassword);
 106
 5107    private sealed record GlobalOptions(
 4108        string[] CommandArgs,
 7109        bool SkipGalleryCheck);
 110
 4111    private sealed record InstalledModuleRecord(
 3112        string Version,
 4113        string ManifestPath);
 114
 4115    private sealed record ServiceBundleLayout(
 3116        string RootPath,
 4117        string RuntimeExecutablePath,
 2118        string ServiceHostExecutablePath,
 5119        string ScriptPath,
 8120        string ModuleManifestPath);
 121
 56122    private sealed record ResolvedServiceScriptSource(
 29123        string FullScriptPath,
 28124        string? FullContentRoot,
 29125        string RelativeScriptPath,
 17126        string? TemporaryContentRootPath,
 8127        string? DescriptorServiceName,
 0128        string? DescriptorServiceDescription,
 5129        string? DescriptorServiceVersion,
 3130        string? DescriptorServiceLogPath,
 57131        IReadOnlyList<string> DescriptorPreservePaths);
 132
 37133    private sealed record ServiceInstallDescriptor(
 5134        string FormatVersion,
 16135        string Name,
 15136        string EntryPoint,
 14137        string Description,
 17138        string? Version,
 15139        string? ServiceLogPath,
 48140        IReadOnlyList<string> PreservePaths);
 141
 142    [GeneratedRegex("--service-log-path\\s+(\\\"(?<quoted>[^\\\"]+)\\\"|(?<plain>\\S+))", RegexOptions.IgnoreCase | Rege
 143    private static partial Regex ServiceLogPathRegex();
 144    [GeneratedRegex("^\\s*ModuleVersion\\s*=\\s*['\\\"](?<value>[^'\\\"]+)['\\\"]", RegexOptions.IgnoreCase | RegexOptio
 145    private static partial Regex ModuleVersionRegex();
 146    [GeneratedRegex("^\\s*Prerelease\\s*=\\s*['\\\"](?<value>[^'\\\"]+)['\\\"]", RegexOptions.IgnoreCase | RegexOptions.
 147    private static partial Regex ModulePrereleaseRegex();
 148}

/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 (!TryResolveDedicatedServiceHostExecutableFromToolDistribution(out var serviceHostExecutablePath))
 17        {
 018            Console.Error.WriteLine("Unable to locate dedicated service host for current RID in Kestrun.Tool distributio
 019            Console.Error.WriteLine("Expected 'kestrun-service/<rid>/(kestrun-service-host|kestrun-service-host.exe)'. R
 020            return 1;
 21        }
 22
 023        if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
 24        {
 025            TryEnsureServiceRuntimeExecutablePermissions(serviceHostExecutablePath);
 26        }
 27
 028        var runnerExecutablePath = ResolveCurrentProcessPathOrFallback(serviceHostExecutablePath);
 029        var hostArguments = BuildDedicatedServiceHostRunArguments(
 030            runnerExecutablePath,
 031            scriptPath,
 032            moduleManifestPath,
 033            scriptArguments,
 034            ShouldDiscoverPowerShellHomeForManifest(moduleManifestPath));
 35
 036        return RunForegroundProcess(serviceHostExecutablePath, hostArguments);
 37    }
 38
 39    /// <summary>
 40    /// Runs a child process in foreground mode, inheriting the current console handles.
 41    /// </summary>
 42    /// <param name="fileName">Executable to run.</param>
 43    /// <param name="arguments">Argument tokens.</param>
 44    /// <returns>Process exit code.</returns>
 45    private static int RunForegroundProcess(string fileName, IReadOnlyList<string> arguments)
 46    {
 47        try
 48        {
 149            var startInfo = new ProcessStartInfo
 150            {
 151                FileName = fileName,
 152                UseShellExecute = false,
 153                RedirectStandardOutput = false,
 154                RedirectStandardError = false,
 155                CreateNoWindow = false,
 156            };
 57
 658            foreach (var argument in arguments)
 59            {
 260                startInfo.ArgumentList.Add(argument);
 61            }
 62
 163            using var process = Process.Start(startInfo);
 164            if (process is null)
 65            {
 066                Console.Error.WriteLine($"Failed to start process: {fileName}");
 067                return 1;
 68            }
 69
 170            process.WaitForExit();
 171            return process.ExitCode;
 72        }
 073        catch (Exception ex)
 74        {
 075            Console.Error.WriteLine($"Failed to start process '{fileName}': {ex.Message}");
 076            return 1;
 77        }
 178    }
 79
 80    /// <summary>
 81    /// Parses arguments for the run command.
 82    /// </summary>
 83    /// <param name="args">Raw command-line arguments.</param>
 84    /// <param name="startIndex">Index after command token.</param>
 85    /// <param name="kestrunFolder">Optional folder containing 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 TryParseRunArguments(string[] args, int startIndex, string? kestrunFolder, string? kestrunManife
 90    {
 891        parsedCommand = new ParsedCommand(CommandMode.Run, string.Empty, false, [], kestrunFolder, kestrunManifestPath, 
 892        error = string.Empty;
 93
 894        var state = new RunParseState(startIndex, kestrunFolder, kestrunManifestPath);
 1395        while (state.Index < args.Length)
 96        {
 1297            var current = args[state.Index];
 1298            if (TryCaptureRunScriptArguments(args, current, ref state))
 99            {
 100                break;
 101            }
 102
 11103            if (TryConsumeRunOption(args, current, ref state, out error))
 104            {
 8105                if (!string.IsNullOrEmpty(error))
 106                {
 4107                    return false;
 108                }
 109
 110                continue;
 111            }
 112
 3113            if (current.StartsWith("--", StringComparison.Ordinal))
 114            {
 1115                error = $"Unknown option: {current}";
 1116                return false;
 117            }
 118
 2119            if (state.ScriptPathSet)
 120            {
 1121                error = "Script arguments must be preceded by --arguments (or --).";
 1122                return false;
 123            }
 124
 1125            state.ScriptPath = current;
 1126            state.ScriptPathSet = true;
 1127            state.Index += 1;
 128        }
 129
 2130        if (!state.ScriptPathSet)
 131        {
 132            // Default to ./Service.ps1 when a script path is not explicitly provided.
 1133            state.ScriptPath = RunDefaultScriptFileName;
 134        }
 135
 2136        parsedCommand = new ParsedCommand(CommandMode.Run, state.ScriptPath, state.ScriptPathSet, state.ScriptArguments,
 137
 2138        return true;
 139    }
 140
 141    /// <summary>
 142    /// Captures script arguments when the explicit script argument separator is encountered.
 143    /// </summary>
 144    /// <param name="args">Raw command-line arguments.</param>
 145    /// <param name="current">Current token being processed.</param>
 146    /// <param name="state">Mutable run command parse state.</param>
 147    /// <returns>True when the parser should stop consuming additional tokens.</returns>
 148    private static bool TryCaptureRunScriptArguments(string[] args, string current, ref RunParseState state)
 149    {
 12150        if (current is not "--arguments" and not "--")
 151        {
 11152            return false;
 153        }
 154
 1155        state.ScriptArguments = [.. args.Skip(state.Index + 1)];
 1156        return true;
 157    }
 158
 159    /// <summary>
 160    /// Consumes a supported run command option and updates the parse state.
 161    /// </summary>
 162    /// <param name="args">Raw command-line arguments.</param>
 163    /// <param name="current">Current token being processed.</param>
 164    /// <param name="state">Mutable run command parse state.</param>
 165    /// <param name="error">Error message when option parsing fails.</param>
 166    /// <returns>True when the current token was handled as a supported option.</returns>
 167    private static bool TryConsumeRunOption(string[] args, string current, ref RunParseState state, out string error)
 168    {
 11169        error = string.Empty;
 11170        if (current is "--script")
 171        {
 4172            return TryConsumeRunScriptOption(args, ref state, out error);
 173        }
 174
 7175        if (current is "--kestrun-folder" or "-k")
 176        {
 2177            return TryConsumeRunKestrunFolderOption(args, ref state, out error);
 178        }
 179
 5180        if (current is "--kestrun-manifest" or "-m")
 181        {
 2182            return TryConsumeRunKestrunManifestOption(args, ref state, out error);
 183        }
 184        // Add additional options here as else-if branches.
 3185        return false;
 186    }
 187
 188    /// <summary>
 189    /// Consumes the run command script path option.
 190    /// </summary>
 191    /// <param name="args">Raw command-line arguments.</param>
 192    /// <param name="state">Mutable run command parse state.</param>
 193    /// <param name="error">Error message when parsing fails.</param>
 194    /// <returns>True when the option was consumed.</returns>
 195    private static bool TryConsumeRunScriptOption(string[] args, ref RunParseState state, out string error)
 196    {
 4197        error = string.Empty;
 4198        if (state.ScriptPathSet)
 199        {
 1200            error = "Script path was provided multiple times. Use either positional script path or --script once.";
 1201            return true;
 202        }
 203
 3204        if (state.Index + 1 >= args.Length)
 205        {
 1206            error = "Missing value for --script.";
 1207            return true;
 208        }
 209
 2210        state.ScriptPath = args[state.Index + 1];
 2211        state.ScriptPathSet = true;
 2212        state.Index += 2;
 2213        return true;
 214    }
 215
 216    /// <summary>
 217    /// Consumes the run command Kestrun folder option.
 218    /// </summary>
 219    /// <param name="args">Raw command-line arguments.</param>
 220    /// <param name="state">Mutable run command parse state.</param>
 221    /// <param name="error">Error message when parsing fails.</param>
 222    /// <returns>True when the option was consumed.</returns>
 223    private static bool TryConsumeRunKestrunFolderOption(string[] args, ref RunParseState state, out string error)
 224    {
 2225        if (state.Index + 1 >= args.Length)
 226        {
 1227            error = "Missing value for --kestrun-folder.";
 1228            return true;
 229        }
 230
 1231        state.KestrunFolder = args[state.Index + 1];
 1232        state.Index += 2;
 1233        error = string.Empty;
 1234        return true;
 235    }
 236
 237    /// <summary>
 238    /// Consumes the run command Kestrun manifest option.
 239    /// </summary>
 240    /// <param name="args">Raw command-line arguments.</param>
 241    /// <param name="state">Mutable run command parse state.</param>
 242    /// <param name="error">Error message when parsing fails.</param>
 243    /// <returns>True when the option was consumed.</returns>
 244    private static bool TryConsumeRunKestrunManifestOption(string[] args, ref RunParseState state, out string error)
 245    {
 2246        if (state.Index + 1 >= args.Length)
 247        {
 1248            error = "Missing value for --kestrun-manifest.";
 1249            return true;
 250        }
 251
 1252        state.KestrunManifestPath = args[state.Index + 1];
 1253        state.Index += 2;
 1254        error = string.Empty;
 1255        return true;
 256    }
 257
 258    /// <summary>
 259    /// Holds mutable state while parsing run command arguments.
 260    /// </summary>
 261    /// <remarks>
 262    /// Initializes a new parse-state instance.
 263    /// </remarks>
 264    /// <param name="index">Current parser index.</param>
 265    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 266    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 8267    private sealed class RunParseState(int index, string? kestrunFolder, string? kestrunManifestPath)
 268    {
 269        /// <summary>
 270        /// Gets or sets the current parser index.
 271        /// </summary>
 55272        public int Index { get; set; } = index;
 273
 274        /// <summary>
 275        /// Gets or sets the optional folder containing Kestrun.psd1.
 276        /// </summary>
 11277        public string? KestrunFolder { get; set; } = kestrunFolder;
 278
 279        /// <summary>
 280        /// Gets or sets the optional explicit path to Kestrun.psd1.
 281        /// </summary>
 11282        public string? KestrunManifestPath { get; set; } = kestrunManifestPath;
 283
 284        /// <summary>
 285        /// Gets or sets the resolved script path token.
 286        /// </summary>
 14287        public string ScriptPath { get; set; } = string.Empty;
 288
 289        /// <summary>
 290        /// Gets or sets a value indicating whether a script path was provided explicitly.
 291        /// </summary>
 13292        public bool ScriptPathSet { get; set; }
 293
 294        /// <summary>
 295        /// Gets or sets script arguments while ensuring a non-null array.
 296        /// </summary>
 11297        public string[] ScriptArguments { get; set; } = [];
 298    }
 299}

/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    {
 015        if (!TryResolveInstallServiceInputs(
 016                command,
 017                out var serviceName,
 018                out var serviceVersion,
 019                out var effectiveServiceLogPath,
 020                out var scriptSource,
 021                out var moduleManifestPath,
 022                out var inputExitCode))
 23        {
 024            return inputExitCode;
 25        }
 26
 27        try
 28        {
 029            if (!TryRunInstallServicePreflight(command, serviceName, moduleManifestPath, effectiveServiceLogPath, skipGa
 30            {
 031                return preflightExitCode;
 32            }
 33
 034            if (!TryPrepareInstallServiceBundle(command, serviceName, serviceVersion, scriptSource, moduleManifestPath, 
 35            {
 036                return bundleExitCode;
 37            }
 38            // Service bundle preparation should not fail silently, but check for null just in case.
 039            return InstallPreparedServiceForCurrentPlatform(command, serviceName, effectiveServiceLogPath, serviceBundle
 40        }
 41        finally
 42        {
 043            TryCleanupTemporaryServiceContentRoot(scriptSource.TemporaryContentRootPath);
 044        }
 045    }
 46
 47    /// <summary>
 48    /// Resolves and validates install-service inputs that are independent of operating-system install mechanics.
 49    /// </summary>
 50    /// <param name="command">Parsed command information.</param>
 51    /// <param name="serviceName">Resolved service name.</param>
 52    /// <param name="serviceVersion">Resolved service descriptor version when provided.</param>
 53    /// <param name="effectiveServiceLogPath">Effective service log path (CLI override or descriptor value).</param>
 54    /// <param name="scriptSource">Resolved service script source.</param>
 55    /// <param name="moduleManifestPath">Resolved module manifest path.</param>
 56    /// <param name="exitCode">Exit code when validation fails.</param>
 57    /// <returns>True when inputs are valid and resolved.</returns>
 58    private static bool TryResolveInstallServiceInputs(
 59        ParsedCommand command,
 60        out string serviceName,
 61        out string? serviceVersion,
 62        out string? effectiveServiceLogPath,
 63        out ResolvedServiceScriptSource scriptSource,
 64        out string moduleManifestPath,
 65        out int exitCode)
 66    {
 467        serviceName = string.Empty;
 468        serviceVersion = null;
 469        effectiveServiceLogPath = null;
 470        scriptSource = CreateEmptyResolvedServiceScriptSource();
 471        moduleManifestPath = string.Empty;
 472        exitCode = 0;
 73
 474        if (!TryResolveServiceScriptSource(command, out scriptSource, out var scriptError))
 75        {
 076            Console.Error.WriteLine(scriptError);
 077            exitCode = 2;
 078            return false;
 79        }
 80
 481        var resolvedServiceName = string.IsNullOrWhiteSpace(scriptSource.DescriptorServiceName)
 482            ? command.ServiceName
 483            : scriptSource.DescriptorServiceName;
 84
 485        if (string.IsNullOrWhiteSpace(resolvedServiceName))
 86        {
 087            Console.Error.WriteLine("Service name is required in Service.psd1 (Name) when using --package.");
 088            exitCode = 2;
 089            return false;
 90        }
 91
 492        serviceName = resolvedServiceName;
 493        serviceVersion = scriptSource.DescriptorServiceVersion;
 494        effectiveServiceLogPath = !string.IsNullOrWhiteSpace(command.ServiceLogPath)
 495            ? command.ServiceLogPath
 496            : scriptSource.DescriptorServiceLogPath;
 97
 498        var cleanupScriptSourceOnFailure = true;
 99        try
 100        {
 4101            var locatedModuleManifestPath = LocateModuleManifest(command.KestrunManifestPath, command.KestrunFolder);
 4102            if (locatedModuleManifestPath is null)
 103            {
 4104                WriteModuleNotFoundMessage(command.KestrunManifestPath, command.KestrunFolder, Console.Error.WriteLine);
 4105                exitCode = 3;
 4106                return false;
 107            }
 108
 0109            moduleManifestPath = locatedModuleManifestPath;
 0110            cleanupScriptSourceOnFailure = false;
 0111            return true;
 112        }
 113        finally
 114        {
 4115            if (cleanupScriptSourceOnFailure)
 116            {
 4117                TryCleanupTemporaryServiceContentRoot(scriptSource.TemporaryContentRootPath);
 118            }
 4119        }
 4120    }
 121
 122    /// <summary>
 123    /// Removes a temporary service content root directory when archive extraction mode was used.
 124    /// </summary>
 125    /// <param name="temporaryContentRootPath">Temporary extraction path.</param>
 126    private static void TryCleanupTemporaryServiceContentRoot(string? temporaryContentRootPath)
 127    {
 4128        if (string.IsNullOrWhiteSpace(temporaryContentRootPath) || !Directory.Exists(temporaryContentRootPath))
 129        {
 2130            return;
 131        }
 132
 133        try
 134        {
 2135            TryDeleteDirectoryWithRetry(temporaryContentRootPath, maxAttempts: 5, initialDelayMs: 50);
 2136        }
 0137        catch
 138        {
 139            // Best-effort cleanup; do not fail install/remove flow on temp directory cleanup errors.
 0140        }
 2141    }
 142
 143    /// <summary>
 144    /// Performs install-service preflight checks such as Windows elevation checks, gallery warnings, and privileged-use
 145    /// </summary>
 146    /// <param name="command">Parsed command information.</param>
 147    /// <param name="serviceName">Resolved service name.</param>
 148    /// <param name="moduleManifestPath">Resolved module manifest path.</param>
 149    /// <param name="serviceLogPath">Effective service log path.</param>
 150    /// <param name="skipGalleryCheck">True when gallery checks should be skipped.</param>
 151    /// <param name="exitCode">Exit code when a preflight check fails.</param>
 152    /// <returns>True when preflight checks pass.</returns>
 153    private static bool TryRunInstallServicePreflight(
 154        ParsedCommand command,
 155        string serviceName,
 156        string moduleManifestPath,
 157        string? serviceLogPath,
 158        bool skipGalleryCheck,
 159        out int exitCode)
 160    {
 0161        exitCode = 0;
 162
 0163        if (OperatingSystem.IsWindows() && !TryPreflightWindowsServiceInstall(command, serviceName, out var preflightExi
 164        {
 0165            exitCode = preflightExitCode;
 0166            return false;
 167        }
 168
 0169        if (!skipGalleryCheck)
 170        {
 0171            WarnIfNewerGalleryVersionExists(moduleManifestPath, serviceLogPath);
 172        }
 173
 0174        if (OperatingSystem.IsLinux() && !string.IsNullOrWhiteSpace(command.ServiceUser) && !IsLikelyRunningAsRootOnLinu
 175        {
 0176            Console.Error.WriteLine("Linux system service install with --service-user requires root privileges.");
 0177            exitCode = 1;
 0178            return false;
 179        }
 180
 0181        if (OperatingSystem.IsMacOS() && !string.IsNullOrWhiteSpace(command.ServiceUser) && !IsLikelyRunningAsRootOnUnix
 182        {
 0183            Console.Error.WriteLine("macOS system daemon install with --service-user requires root privileges.");
 0184            exitCode = 1;
 0185            return false;
 186        }
 187
 0188        return true;
 189    }
 190
 191    /// <summary>
 192    /// Creates the service deployment bundle required by platform-specific service registration.
 193    /// </summary>
 194    /// <param name="command">Parsed command information.</param>
 195    /// <param name="serviceName">Resolved service name.</param>
 196    /// <param name="serviceVersion">Optional resolved service version.</param>
 197    /// <param name="scriptSource">Resolved service script source.</param>
 198    /// <param name="moduleManifestPath">Resolved module manifest path.</param>
 199    /// <param name="serviceBundle">Prepared service bundle.</param>
 200    /// <param name="exitCode">Exit code when bundle preparation fails.</param>
 201    /// <returns>True when bundle preparation succeeds.</returns>
 202    private static bool TryPrepareInstallServiceBundle(
 203        ParsedCommand command,
 204        string serviceName,
 205        string? serviceVersion,
 206        ResolvedServiceScriptSource scriptSource,
 207        string moduleManifestPath,
 208        out ServiceBundleLayout serviceBundle,
 209        out int exitCode)
 210    {
 0211        serviceBundle = default!;
 0212        exitCode = 0;
 213
 0214        if (!TryPrepareServiceBundle(
 0215                serviceName,
 0216                scriptSource.FullScriptPath,
 0217                moduleManifestPath,
 0218                scriptSource.FullContentRoot,
 0219                scriptSource.RelativeScriptPath,
 0220                out var preparedServiceBundle,
 0221                out var bundleError,
 0222                command.ServiceDeploymentRoot,
 0223                serviceVersion))
 224        {
 0225            Console.Error.WriteLine(bundleError);
 0226            exitCode = 1;
 0227            return false;
 228        }
 229
 0230        if (preparedServiceBundle is null)
 231        {
 0232            Console.Error.WriteLine("Service bundle preparation failed.");
 0233            exitCode = 1;
 0234            return false;
 235        }
 236
 0237        serviceBundle = preparedServiceBundle;
 0238        return true;
 239    }
 240
 241    /// <summary>
 242    /// Installs a prepared service bundle using the platform-specific daemon/service mechanism.
 243    /// </summary>
 244    /// <param name="command">Parsed command information.</param>
 245    /// <param name="serviceName">Resolved service name.</param>
 246    /// <param name="serviceLogPath">Effective service log path.</param>
 247    /// <param name="serviceBundle">Prepared service bundle.</param>
 248    /// <returns>Process exit code.</returns>
 249    private static int InstallPreparedServiceForCurrentPlatform(ParsedCommand command, string serviceName, string? servi
 250    {
 1251        var daemonArgs = BuildDaemonHostArgumentsForService(
 1252            serviceName,
 1253            serviceBundle.ServiceHostExecutablePath,
 1254            serviceBundle.RuntimeExecutablePath,
 1255            serviceBundle.ScriptPath,
 1256            serviceBundle.ModuleManifestPath,
 1257            command.ScriptArguments,
 1258            serviceLogPath);
 1259        var workingDirectory = Path.GetDirectoryName(serviceBundle.ScriptPath) ?? Environment.CurrentDirectory;
 260
 1261        if (OperatingSystem.IsWindows())
 262        {
 0263            return InstallWindowsService(
 0264                command,
 0265                serviceName,
 0266                serviceLogPath,
 0267                serviceBundle.ServiceHostExecutablePath,
 0268                serviceBundle.RuntimeExecutablePath,
 0269                serviceBundle.ScriptPath,
 0270                serviceBundle.ModuleManifestPath);
 271        }
 272
 1273        if (OperatingSystem.IsLinux())
 274        {
 1275            var result = InstallLinuxUserDaemon(serviceName, serviceBundle.ServiceHostExecutablePath, daemonArgs, workin
 1276            WriteServiceOperationResult("install", "linux", serviceName, result, serviceLogPath);
 1277            return result;
 278        }
 279
 0280        if (OperatingSystem.IsMacOS())
 281        {
 0282            var result = InstallMacLaunchAgent(serviceName, serviceBundle.ServiceHostExecutablePath, daemonArgs, working
 0283            WriteServiceOperationResult("install", "macos", serviceName, result, serviceLogPath);
 0284            return result;
 285        }
 286
 0287        Console.Error.WriteLine("Service installation is not supported on this OS.");
 0288        return 1;
 289    }
 290
 291    /// <summary>
 292    /// Removes a previously installed service/daemon entry.
 293    /// </summary>
 294    /// <param name="command">Parsed command information.</param>
 295    /// <returns>Process exit code.</returns>
 296    private static int RemoveService(ParsedCommand command)
 297    {
 2298        if (string.IsNullOrWhiteSpace(command.ServiceName))
 299        {
 2300            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 2301            return 2;
 302        }
 303
 0304        var serviceName = command.ServiceName;
 305
 306        int result;
 0307        if (OperatingSystem.IsWindows())
 308        {
 0309            result = RemoveWindowsService(command);
 0310            if (result == 0)
 311            {
 0312                TryRemoveServiceBundle(serviceName, command.ServiceDeploymentRoot);
 313            }
 314
 0315            return result;
 316        }
 317
 0318        if (OperatingSystem.IsLinux())
 319        {
 0320            result = RemoveLinuxUserDaemon(serviceName);
 0321            if (result == 0)
 322            {
 0323                TryRemoveServiceBundle(serviceName, command.ServiceDeploymentRoot);
 324            }
 325
 0326            WriteServiceOperationResult("remove", "linux", serviceName, result, command.ServiceLogPath);
 327
 0328            return result;
 329        }
 330
 0331        if (OperatingSystem.IsMacOS())
 332        {
 0333            result = RemoveMacLaunchAgent(serviceName);
 0334            if (result == 0)
 335            {
 0336                TryRemoveServiceBundle(serviceName, command.ServiceDeploymentRoot);
 337            }
 338
 0339            WriteServiceOperationResult("remove", "macos", serviceName, result, command.ServiceLogPath);
 340
 0341            return result;
 342        }
 343
 0344        Console.Error.WriteLine("Service removal is not supported on this OS.");
 0345        return 1;
 346    }
 347
 348    /// <summary>
 349    /// Starts a previously installed service/daemon entry.
 350    /// </summary>
 351    /// <param name="command">Parsed command information.</param>
 352    /// <returns>Process exit code.</returns>
 353    private static int StartService(ParsedCommand command)
 354    {
 3355        if (string.IsNullOrWhiteSpace(command.ServiceName))
 356        {
 2357            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 2358            return 2;
 359        }
 360
 1361        var serviceName = command.ServiceName;
 362        ServiceControlResult result;
 363
 1364        if (OperatingSystem.IsWindows())
 365        {
 0366            result = StartWindowsService(serviceName, command.ServiceLogPath, command.RawOutput);
 0367            return WriteServiceControlResult(command, result);
 368        }
 369
 1370        if (OperatingSystem.IsLinux())
 371        {
 1372            result = StartLinuxUserDaemon(serviceName, command.ServiceLogPath, command.RawOutput);
 1373            return WriteServiceControlResult(command, result);
 374        }
 375
 0376        if (OperatingSystem.IsMacOS())
 377        {
 0378            result = StartMacLaunchAgent(serviceName, command.ServiceLogPath, command.RawOutput);
 0379            return WriteServiceControlResult(command, result);
 380        }
 381
 0382        Console.Error.WriteLine("Service start is not supported on this OS.");
 0383        return 1;
 384    }
 385
 386    /// <summary>
 387    /// Stops a previously installed service/daemon entry.
 388    /// </summary>
 389    /// <param name="command">Parsed command information.</param>
 390    /// <returns>Process exit code.</returns>
 391    private static int StopService(ParsedCommand command)
 392    {
 3393        if (string.IsNullOrWhiteSpace(command.ServiceName))
 394        {
 2395            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 2396            return 2;
 397        }
 398
 1399        var serviceName = command.ServiceName;
 400        ServiceControlResult result;
 401
 1402        if (OperatingSystem.IsWindows())
 403        {
 0404            result = StopWindowsService(serviceName, command.ServiceLogPath, command.RawOutput);
 0405            return WriteServiceControlResult(command, result);
 406        }
 407
 1408        if (OperatingSystem.IsLinux())
 409        {
 1410            result = StopLinuxUserDaemon(serviceName, command.ServiceLogPath, command.RawOutput);
 1411            return WriteServiceControlResult(command, result);
 412        }
 413
 0414        if (OperatingSystem.IsMacOS())
 415        {
 0416            result = StopMacLaunchAgent(serviceName, command.ServiceLogPath, command.RawOutput);
 0417            return WriteServiceControlResult(command, result);
 418        }
 419
 0420        Console.Error.WriteLine("Service stop is not supported on this OS.");
 0421        return 1;
 422    }
 423
 424    /// <summary>
 425    /// Queries a previously installed service/daemon entry.
 426    /// </summary>
 427    /// <param name="command">Parsed command information.</param>
 428    /// <returns>Process exit code.</returns>
 429    private static int QueryService(ParsedCommand command)
 430    {
 2431        if (string.IsNullOrWhiteSpace(command.ServiceName))
 432        {
 1433            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 1434            return 2;
 435        }
 436
 1437        var serviceName = command.ServiceName;
 438        ServiceControlResult result;
 439
 1440        if (OperatingSystem.IsWindows())
 441        {
 0442            result = QueryWindowsService(serviceName, command.ServiceLogPath, command.RawOutput);
 0443            return WriteServiceControlResult(command, result);
 444        }
 445
 1446        if (OperatingSystem.IsLinux())
 447        {
 1448            result = QueryLinuxUserDaemon(serviceName, command.ServiceLogPath, command.RawOutput);
 1449            return WriteServiceControlResult(command, result);
 450        }
 451
 0452        if (OperatingSystem.IsMacOS())
 453        {
 0454            result = QueryMacLaunchAgent(serviceName, command.ServiceLogPath, command.RawOutput);
 0455            return WriteServiceControlResult(command, result);
 456        }
 457
 0458        Console.Error.WriteLine("Service query is not supported on this OS.");
 0459        return 1;
 460    }
 461
 462    /// <summary>
 463    /// Represents normalized start/stop/query operation output.
 464    /// </summary>
 465    /// <param name="Operation">Operation token (start/stop/query).</param>
 466    /// <param name="ServiceName">Service identifier.</param>
 467    /// <param name="Platform">Platform token (windows/linux/macos).</param>
 468    /// <param name="State">Normalized service state.</param>
 469    /// <param name="Pid">Service process id when available.</param>
 470    /// <param name="ExitCode">Command exit code.</param>
 471    /// <param name="Message">Human-readable status message.</param>
 472    /// <param name="RawOutput">Raw standard output from the OS command when available.</param>
 473    /// <param name="RawError">Raw standard error from the OS command when available.</param>
 9474    private sealed record ServiceControlResult(
 7475        string Operation,
 4476        string ServiceName,
 9477        string Platform,
 4478        string State,
 4479        int? Pid,
 19480        int ExitCode,
 4481        string Message,
 4482        string RawOutput,
 12483        string RawError)
 484    {
 485        /// <summary>
 486        /// Returns true when the operation succeeded.
 487        /// </summary>
 4488        public bool Success => ExitCode == 0;
 489    }
 490
 491    /// <summary>
 492    /// Writes a service control result using table/json/raw output selection.
 493    /// </summary>
 494    /// <param name="command">Parsed command containing output switches.</param>
 495    /// <param name="result">Normalized service operation result.</param>
 496    /// <returns>Operation exit code.</returns>
 497    private static int WriteServiceControlResult(ParsedCommand command, ServiceControlResult result)
 498    {
 6499        if (command.RawOutput)
 500        {
 2501            if (!string.IsNullOrWhiteSpace(result.RawOutput))
 502            {
 2503                Console.WriteLine(result.RawOutput.TrimEnd());
 504            }
 505
 2506            if (!string.IsNullOrWhiteSpace(result.RawError))
 507            {
 1508                Console.Error.WriteLine(result.RawError.TrimEnd());
 509            }
 510
 2511            return result.ExitCode;
 512        }
 513
 4514        if (command.JsonOutput)
 515        {
 1516            var payload = new
 1517            {
 1518                result.Operation,
 1519                result.ServiceName,
 1520                result.Platform,
 1521                Status = result.Success ? "success" : "failed",
 1522                result.State,
 1523                PID = result.Pid,
 1524                result.ExitCode,
 1525                result.Message,
 1526            };
 527
 1528            Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializerOpti
 1529            {
 1530                WriteIndented = true,
 1531            }));
 1532            return result.ExitCode;
 533        }
 534
 3535        var columns = new[] { "Operation", "Service", "Platform", "Status", "State", "PID", "ExitCode", "Message" };
 3536        var values = new[]
 3537        {
 3538            result.Operation,
 3539            result.ServiceName,
 3540            result.Platform,
 3541            result.Success ? "success" : "failed",
 3542            result.State,
 3543            result.Pid?.ToString(CultureInfo.InvariantCulture) ?? "-",
 3544            result.ExitCode.ToString(CultureInfo.InvariantCulture),
 3545            result.Message,
 3546        };
 547
 3548        var widths = columns
 24549            .Select((header, index) => Math.Max(header.Length, values[index].Length))
 3550            .ToArray();
 551
 27552        Console.WriteLine(string.Join(" | ", columns.Select((header, index) => header.PadRight(widths[index]))));
 27553        Console.WriteLine(string.Join("-+-", widths.Select(static width => new string('-', width))));
 27554        Console.WriteLine(string.Join(" | ", values.Select((value, index) => value.PadRight(widths[index]))));
 3555        return result.ExitCode;
 556    }
 557
 558    /// <summary>
 559    /// Returns installed service descriptor metadata plus the resolved service bundle path.
 560    /// </summary>
 561    /// <param name="command">Parsed command information.</param>
 562    /// <returns>Process exit code.</returns>
 563    private static int InfoService(ParsedCommand command)
 564    {
 4565        if (!string.IsNullOrWhiteSpace(command.ServiceName))
 566        {
 2567            if (!TryResolveInstalledServiceBundleRoot(command.ServiceName, command.ServiceDeploymentRoot, out var servic
 568            {
 1569                Console.Error.WriteLine(resolutionError);
 1570                return 1;
 571            }
 572
 1573            var scriptRoot = Path.Combine(serviceRootPath, ServiceBundleScriptDirectoryName);
 1574            if (!TryResolveServiceInstallDescriptor(scriptRoot, out var descriptor, out var descriptorError))
 575            {
 0576                Console.Error.WriteLine(descriptorError);
 0577                return 1;
 578            }
 579
 1580            var descriptorPath = Path.Combine(scriptRoot, ServiceDescriptorFileName);
 1581            var backups = GetServiceBackupSnapshots(serviceRootPath);
 1582            var payload = new
 1583            {
 1584                command.ServiceName,
 1585                ServicePath = serviceRootPath,
 1586                DescriptorPath = descriptorPath,
 1587                Descriptor = new
 1588                {
 1589                    descriptor.FormatVersion,
 1590                    descriptor.Name,
 1591                    descriptor.EntryPoint,
 1592                    descriptor.Description,
 1593                    descriptor.Version,
 1594                    descriptor.ServiceLogPath,
 1595                },
 1596                Backups = backups.Select(static backup => new
 1597                {
 1598                    backup.Version,
 1599                    UpdatedAtUtc = backup.UpdatedAtUtc?.ToString("o"),
 1600                    backup.Path,
 1601                }),
 1602            };
 603
 1604            if (command.JsonOutput)
 605            {
 1606                Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(payload, new System.Text.Json.JsonSerializer
 1607                {
 1608                    WriteIndented = true,
 1609                }));
 610            }
 611            else
 612            {
 0613                WriteServiceInfoHumanReadable(payload.ServiceName, payload.ServicePath, payload.DescriptorPath, descript
 614            }
 615
 1616            return 0;
 617        }
 618
 2619        if (!TryEnumerateInstalledServiceBundleRoots(command.ServiceDeploymentRoot, out var bundleRoots, out var enumera
 620        {
 0621            Console.Error.WriteLine(enumerateError);
 0622            return 1;
 623        }
 624
 2625        var services = new List<(string ServiceName, string ServicePath, string DescriptorPath, ServiceInstallDescriptor
 10626        foreach (var bundleRoot in bundleRoots)
 627        {
 3628            var scriptRoot = Path.Combine(bundleRoot, ServiceBundleScriptDirectoryName);
 3629            if (!TryResolveServiceInstallDescriptor(scriptRoot, out var descriptor, out _))
 630            {
 631                continue;
 632            }
 633
 3634            var descriptorPath = Path.Combine(scriptRoot, ServiceDescriptorFileName);
 3635            var backups = GetServiceBackupSnapshots(bundleRoot);
 3636            services.Add((descriptor.Name, bundleRoot, descriptorPath, descriptor, backups));
 637        }
 638
 2639        if (services.Count == 0)
 640        {
 0641            Console.Error.WriteLine("No installed Kestrun services were found.");
 0642            return 1;
 643        }
 644
 2645        if (command.JsonOutput)
 646        {
 2647            Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(new
 2648            {
 3649                Services = services.Select(static service => new
 3650                {
 3651                    service.ServiceName,
 3652                    service.ServicePath,
 3653                    service.DescriptorPath,
 3654                    Descriptor = new
 3655                    {
 3656                        service.Descriptor.FormatVersion,
 3657                        service.Descriptor.Name,
 3658                        service.Descriptor.EntryPoint,
 3659                        service.Descriptor.Description,
 3660                        service.Descriptor.Version,
 3661                        service.Descriptor.ServiceLogPath,
 3662                    },
 2663                    Backups = service.Backups.Select(static backup => new
 2664                    {
 2665                        backup.Version,
 2666                        UpdatedAtUtc = backup.UpdatedAtUtc?.ToString("o"),
 2667                        backup.Path,
 2668                    }),
 3669                }),
 2670            }, new System.Text.Json.JsonSerializerOptions
 2671            {
 2672                WriteIndented = true,
 2673            }));
 674
 2675            return 0;
 676        }
 677
 0678        foreach (var (ServiceName, ServicePath, DescriptorPath, Descriptor, Backups) in services)
 679        {
 0680            WriteServiceInfoHumanReadable(ServiceName, ServicePath, DescriptorPath, Descriptor, Backups);
 0681            Console.WriteLine();
 682        }
 683
 0684        return 0;
 685    }
 686
 687    /// <summary>
 688    /// Writes service information using a human-readable text format.
 689    /// </summary>
 690    /// <param name="serviceName">Service name.</param>
 691    /// <param name="servicePath">Service bundle path.</param>
 692    /// <param name="descriptorPath">Service descriptor file path.</param>
 693    /// <param name="descriptor">Parsed descriptor payload.</param>
 694    /// <param name="backups">Available backup snapshots for the service.</param>
 695    private static void WriteServiceInfoHumanReadable(
 696        string serviceName,
 697        string servicePath,
 698        string descriptorPath,
 699        ServiceInstallDescriptor descriptor,
 700        IReadOnlyList<ServiceBackupSnapshot> backups)
 701    {
 1702        Console.WriteLine($"Name: {serviceName}");
 1703        Console.WriteLine($"Path: {servicePath}");
 1704        Console.WriteLine($"Descriptor: {descriptorPath}");
 1705        Console.WriteLine($"FormatVersion: {descriptor.FormatVersion}");
 1706        Console.WriteLine($"EntryPoint: {descriptor.EntryPoint}");
 1707        Console.WriteLine($"Description: {descriptor.Description}");
 1708        Console.WriteLine($"Version: {(string.IsNullOrWhiteSpace(descriptor.Version) ? "(not set)" : descriptor.Version)
 1709        Console.WriteLine($"ServiceLogPath: {(string.IsNullOrWhiteSpace(descriptor.ServiceLogPath) ? "(not set)" : descr
 710
 1711        if (backups.Count == 0)
 712        {
 0713            Console.WriteLine("Backups: (none)");
 0714            return;
 715        }
 716
 1717        Console.WriteLine($"Backups: {backups.Count}");
 4718        foreach (var backup in backups)
 719        {
 1720            var updatedAt = backup.UpdatedAtUtc?.ToString("yyyy-MM-dd HH:mm:ss 'UTC'") ?? "(unknown)";
 1721            Console.WriteLine($"  {backup.Version} | {updatedAt} | {backup.Path}");
 722        }
 1723    }
 724
 725    /// <summary>
 726    /// Represents one service backup snapshot.
 727    /// </summary>
 728    /// <param name="Version">Backup snapshot version token (folder name).</param>
 729    /// <param name="UpdatedAtUtc">Parsed update timestamp in UTC when available.</param>
 730    /// <param name="Path">Backup directory path.</param>
 28731    private sealed record ServiceBackupSnapshot(string Version, DateTimeOffset? UpdatedAtUtc, string Path);
 732
 733    /// <summary>
 734    /// Enumerates service backup snapshots from the backup root ordered from newest to oldest.
 735    /// </summary>
 736    /// <param name="serviceRootPath">Service root path.</param>
 737    /// <returns>Ordered backup snapshot list.</returns>
 738    private static List<ServiceBackupSnapshot> GetServiceBackupSnapshots(string serviceRootPath)
 739    {
 6740        var backupRoot = Path.Combine(serviceRootPath, "backup");
 6741        return !Directory.Exists(backupRoot)
 6742            ? []
 6743            : [.. Directory
 6744            .GetDirectories(backupRoot)
 6745            .Select(static directoryPath =>
 6746            {
 5747                var versionToken = Path.GetFileName(directoryPath);
 5748                DateTimeOffset? updatedAtUtc = null;
 5749                if (DateTime.TryParseExact(
 5750                        versionToken,
 5751                        "yyyyMMddHHmmss",
 5752                        CultureInfo.InvariantCulture,
 5753                        DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
 5754                        out var parsedUtc))
 6755                {
 5756                    updatedAtUtc = new DateTimeOffset(parsedUtc, TimeSpan.Zero);
 6757                }
 6758
 5759                return new ServiceBackupSnapshot(versionToken, updatedAtUtc, Path.GetFullPath(directoryPath));
 6760            })
 2761            .OrderByDescending(static backup => backup.UpdatedAtUtc)
 2762            .ThenByDescending(static backup => backup.Version, StringComparer.OrdinalIgnoreCase)
 6763            .ToList()];
 764    }
 765
 766    /// <summary>
 767    /// Enumerates installed service bundle roots across deployment roots.
 768    /// </summary>
 769    /// <param name="deploymentRootOverride">Optional deployment root override.</param>
 770    /// <param name="bundleRoots">Resolved service bundle roots.</param>
 771    /// <param name="error">Enumeration error details.</param>
 772    /// <returns>True when at least one service bundle root is found.</returns>
 773    private static bool TryEnumerateInstalledServiceBundleRoots(string? deploymentRootOverride, out List<string> bundleR
 774    {
 4775        bundleRoots = [];
 4776        error = string.Empty;
 777
 4778        var candidateRoots = new List<string>();
 4779        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 780        {
 4781            candidateRoots.Add(deploymentRootOverride);
 782        }
 783
 4784        candidateRoots.AddRange(GetServiceDeploymentRootCandidates());
 785
 40786        foreach (var root in candidateRoots.Distinct(StringComparer.OrdinalIgnoreCase))
 787        {
 16788            if (string.IsNullOrWhiteSpace(root) || !Directory.Exists(root))
 789            {
 790                continue;
 791            }
 792
 20793            foreach (var serviceBaseRoot in Directory.GetDirectories(root))
 794            {
 6795                var directDescriptorPath = Path.Combine(serviceBaseRoot, ServiceBundleScriptDirectoryName, ServiceDescri
 6796                if (File.Exists(directDescriptorPath))
 797                {
 6798                    bundleRoots.Add(Path.GetFullPath(serviceBaseRoot));
 799                }
 800            }
 801        }
 802
 4803        bundleRoots = [.. bundleRoots
 4804            .Distinct(StringComparer.OrdinalIgnoreCase)
 9805            .OrderBy(static path => path, StringComparer.OrdinalIgnoreCase)];
 806
 4807        if (bundleRoots.Count == 0)
 808        {
 1809            error = "No installed Kestrun services were found.";
 1810            return false;
 811        }
 812
 3813        return true;
 814    }
 815
 816    /// <summary>
 817    /// Updates an installed service bundle from a package and/or module manifest source.
 818    /// </summary>
 819    /// <param name="command">Parsed command information.</param>
 820    /// <returns>Process exit code.</returns>
 821    private static int UpdateService(ParsedCommand command)
 822    {
 0823        if (!TryValidateUpdateServiceCommand(command, out var hasPackageUpdate, out var hasModuleUpdate, out var validat
 824        {
 0825            return validationExitCode;
 826        }
 827
 0828        if (!TryResolveUpdateServiceIdentity(command, hasPackageUpdate, out var serviceName, out var scriptSource, out v
 829        {
 0830            return identityExitCode;
 831        }
 832
 833        try
 834        {
 0835            return ExecuteServiceUpdateFlow(
 0836                command,
 0837                serviceName,
 0838                hasPackageUpdate,
 0839                hasModuleUpdate,
 0840                ref scriptSource,
 0841                ref packageSourceResolved);
 842        }
 843        finally
 844        {
 0845            TryCleanupTemporaryServiceContentRoot(scriptSource.TemporaryContentRootPath);
 0846        }
 0847    }
 848
 849    /// <summary>
 850    /// Executes the resolved service update workflow including failback and update operations.
 851    /// </summary>
 852    /// <param name="command">Parsed command information.</param>
 853    /// <param name="serviceName">Resolved service name.</param>
 854    /// <param name="hasPackageUpdate">True when package/content-root update was requested.</param>
 855    /// <param name="hasModuleUpdate">True when module update was requested.</param>
 856    /// <param name="scriptSource">Resolved package script source; may be populated lazily.</param>
 857    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> is already resolved.</param>
 858    /// <returns>Process exit code.</returns>
 859    private static int ExecuteServiceUpdateFlow(
 860        ParsedCommand command,
 861        string serviceName,
 862        bool hasPackageUpdate,
 863        bool hasModuleUpdate,
 864        ref ResolvedServiceScriptSource scriptSource,
 865        ref bool packageSourceResolved)
 866    {
 0867        if (!TryPrepareServiceUpdateExecution(serviceName, command.ServiceDeploymentRoot, out var paths, out var prepare
 868        {
 0869            return prepareExitCode;
 870        }
 871
 0872        if (command.ServiceFailback)
 873        {
 0874            return TryExecuteServiceFailback(paths, out var failbackExitCode)
 0875                ? 0
 0876                : failbackExitCode;
 877        }
 878
 0879        if (!TryRunServiceUpdateOperations(
 0880                command,
 0881                hasPackageUpdate,
 0882                hasModuleUpdate,
 0883                paths,
 0884                ref scriptSource,
 0885                ref packageSourceResolved,
 0886                out var applicationUpdated,
 0887                out var moduleUpdated,
 0888                out var serviceHostUpdated,
 0889                out var updateExitCode))
 890        {
 0891            return updateExitCode;
 892        }
 893
 0894        WriteServiceUpdateSummary(serviceName, paths, applicationUpdated, moduleUpdated, serviceHostUpdated);
 0895        return 0;
 896    }
 897
 898    /// <summary>
 899    /// Validates service run state and resolves installed bundle paths for update execution.
 900    /// </summary>
 901    /// <param name="serviceName">Resolved service name.</param>
 902    /// <param name="deploymentRootOverride">Optional deployment root override.</param>
 903    /// <param name="paths">Resolved service update path set.</param>
 904    /// <param name="exitCode">Exit code when validation or path resolution fails.</param>
 905    /// <returns>True when update execution prerequisites are satisfied.</returns>
 906    private static bool TryPrepareServiceUpdateExecution(
 907        string serviceName,
 908        string? deploymentRootOverride,
 909        out ServiceUpdatePaths paths,
 910        out int exitCode)
 911    {
 1912        paths = default;
 1913        exitCode = 0;
 914
 1915        if (!TryEnsureServiceIsStopped(serviceName, out var runningError))
 916        {
 0917            Console.Error.WriteLine(runningError);
 0918            exitCode = 1;
 0919            return false;
 920        }
 921
 1922        if (!TryResolveServiceUpdatePaths(serviceName, deploymentRootOverride, out paths, out var pathExitCode))
 923        {
 1924            exitCode = pathExitCode;
 1925            return false;
 926        }
 927
 0928        return true;
 929    }
 930
 931    /// <summary>
 932    /// Applies package, module, and service-host updates for a resolved service bundle.
 933    /// </summary>
 934    /// <param name="command">Parsed command information.</param>
 935    /// <param name="hasPackageUpdate">True when package/content-root update was requested.</param>
 936    /// <param name="hasModuleUpdate">True when module update was requested.</param>
 937    /// <param name="paths">Resolved service update path set.</param>
 938    /// <param name="scriptSource">Resolved package script source; may be populated when required.</param>
 939    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> is already resolved.</param>
 940    /// <param name="applicationUpdated">True when application files were updated.</param>
 941    /// <param name="moduleUpdated">True when module files were updated.</param>
 942    /// <param name="serviceHostUpdated">True when service host binaries were updated.</param>
 943    /// <param name="exitCode">Exit code when any update stage fails.</param>
 944    /// <returns>True when all requested update stages succeed.</returns>
 945    private static bool TryRunServiceUpdateOperations(
 946        ParsedCommand command,
 947        bool hasPackageUpdate,
 948        bool hasModuleUpdate,
 949        ServiceUpdatePaths paths,
 950        ref ResolvedServiceScriptSource scriptSource,
 951        ref bool packageSourceResolved,
 952        out bool applicationUpdated,
 953        out bool moduleUpdated,
 954        out bool serviceHostUpdated,
 955        out int exitCode)
 956    {
 1957        moduleUpdated = false;
 1958        serviceHostUpdated = false;
 1959        exitCode = 0;
 960
 1961        if (!TryApplyServicePackageUpdate(command, hasPackageUpdate, paths, ref scriptSource, ref packageSourceResolved,
 962        {
 0963            exitCode = packageExitCode;
 0964            return false;
 965        }
 966
 1967        if (!TryApplyServiceModuleUpdate(command, hasModuleUpdate, paths, out moduleUpdated, out var moduleExitCode))
 968        {
 0969            exitCode = moduleExitCode;
 0970            return false;
 971        }
 972
 1973        if (!TryApplyServiceHostUpdate(paths, out serviceHostUpdated, out var hostExitCode))
 974        {
 1975            exitCode = hostExitCode;
 1976            return false;
 977        }
 978
 0979        return true;
 980    }
 981
 982    /// <summary>
 983    /// Validates the supported option combinations for service update.
 984    /// </summary>
 985    /// <param name="command">Parsed command information.</param>
 986    /// <param name="hasPackageUpdate">True when package/content-root update was requested.</param>
 987    /// <param name="hasModuleUpdate">True when module update was requested.</param>
 988    /// <param name="exitCode">Validation exit code when invalid options are supplied.</param>
 989    /// <returns>True when option combinations are valid.</returns>
 990    private static bool TryValidateUpdateServiceCommand(
 991        ParsedCommand command,
 992        out bool hasPackageUpdate,
 993        out bool hasModuleUpdate,
 994        out int exitCode)
 995    {
 4996        hasPackageUpdate = !string.IsNullOrWhiteSpace(command.ServiceContentRoot);
 4997        hasModuleUpdate = !string.IsNullOrWhiteSpace(command.KestrunManifestPath) || command.ServiceUseRepositoryKestrun
 4998        exitCode = 0;
 999
 41000        if (command.ServiceFailback && (!string.IsNullOrWhiteSpace(command.KestrunManifestPath) || command.ServiceUseRep
 1001        {
 11002            Console.Error.WriteLine("--failback cannot be combined with --kestrun, --kestrun-module, or --kestrun-manife
 11003            exitCode = 2;
 11004            return false;
 1005        }
 1006
 31007        if (command.ServiceUseRepositoryKestrun && !string.IsNullOrWhiteSpace(command.KestrunManifestPath))
 1008        {
 11009            Console.Error.WriteLine("--kestrun cannot be combined with --kestrun-module or --kestrun-manifest.");
 11010            exitCode = 2;
 11011            return false;
 1012        }
 1013
 21014        if (!command.ServiceFailback && !hasPackageUpdate && !hasModuleUpdate)
 1015        {
 11016            Console.Error.WriteLine("Service update requires --package and/or --kestrun-module/--kestrun-manifest, or us
 11017            exitCode = 2;
 11018            return false;
 1019        }
 1020
 11021        return true;
 1022    }
 1023
 1024    /// <summary>
 1025    /// Resolves service identity and initial package metadata required by update and failback flows.
 1026    /// </summary>
 1027    /// <param name="command">Parsed command information.</param>
 1028    /// <param name="hasPackageUpdate">True when package/content-root update was requested.</param>
 1029    /// <param name="serviceName">Resolved service name.</param>
 1030    /// <param name="scriptSource">Resolved package script source when required.</param>
 1031    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> was resolved.</param>
 1032    /// <param name="exitCode">Exit code when identity resolution fails.</param>
 1033    /// <returns>True when identity resolution succeeds.</returns>
 1034    private static bool TryResolveUpdateServiceIdentity(
 1035        ParsedCommand command,
 1036        bool hasPackageUpdate,
 1037        out string serviceName,
 1038        out ResolvedServiceScriptSource scriptSource,
 1039        out bool packageSourceResolved,
 1040        out int exitCode)
 1041    {
 01042        serviceName = command.ServiceName ?? string.Empty;
 01043        scriptSource = CreateEmptyResolvedServiceScriptSource();
 01044        packageSourceResolved = false;
 01045        exitCode = 0;
 1046
 01047        if (!string.IsNullOrWhiteSpace(serviceName))
 1048        {
 01049            return true;
 1050        }
 1051
 01052        if (!hasPackageUpdate)
 1053        {
 01054            Console.Error.WriteLine("Service name is required. Use --name <value>.");
 01055            exitCode = 2;
 01056            return false;
 1057        }
 1058
 01059        if (!TryResolveServiceScriptSource(command, out scriptSource, out var scriptError))
 1060        {
 01061            Console.Error.WriteLine(scriptError);
 01062            exitCode = 2;
 01063            return false;
 1064        }
 1065
 01066        packageSourceResolved = true;
 01067        if (string.IsNullOrWhiteSpace(scriptSource.DescriptorServiceName))
 1068        {
 01069            Console.Error.WriteLine("Service name is required in Service.psd1 (Name) when using --package.");
 01070            exitCode = 2;
 01071            return false;
 1072        }
 1073
 01074        serviceName = scriptSource.DescriptorServiceName;
 01075        return true;
 1076    }
 1077
 1078    /// <summary>
 1079    /// Resolves the installed service bundle paths required by update operations.
 1080    /// </summary>
 1081    /// <param name="serviceName">Target service name.</param>
 1082    /// <param name="deploymentRootOverride">Optional deployment root override.</param>
 1083    /// <param name="paths">Resolved service update path set.</param>
 1084    /// <param name="exitCode">Exit code when path resolution fails.</param>
 1085    /// <returns>True when service bundle paths are resolved.</returns>
 1086    private static bool TryResolveServiceUpdatePaths(
 1087        string serviceName,
 1088        string? deploymentRootOverride,
 1089        out ServiceUpdatePaths paths,
 1090        out int exitCode)
 1091    {
 21092        paths = default;
 21093        exitCode = 0;
 1094
 21095        if (!TryResolveInstalledServiceBundleRoot(serviceName, deploymentRootOverride, out var serviceRootPath, out var 
 1096        {
 11097            Console.Error.WriteLine(resolutionError);
 11098            exitCode = 1;
 11099            return false;
 1100        }
 1101
 11102        paths = new ServiceUpdatePaths(
 11103            serviceRootPath,
 11104            Path.Combine(serviceRootPath, ServiceBundleScriptDirectoryName),
 11105            Path.Combine(serviceRootPath, ServiceBundleModulesDirectoryName, ModuleName),
 11106            Path.Combine(serviceRootPath, "backup", DateTime.UtcNow.ToString("yyyyMMddHHmmss")));
 11107        return true;
 1108    }
 1109
 1110    /// <summary>
 1111    /// Executes service failback and writes a JSON summary payload.
 1112    /// </summary>
 1113    /// <param name="paths">Resolved service update path set.</param>
 1114    /// <param name="exitCode">Exit code when failback fails.</param>
 1115    /// <returns>True when failback succeeds.</returns>
 1116    private static bool TryExecuteServiceFailback(ServiceUpdatePaths paths, out int exitCode)
 1117    {
 11118        exitCode = 0;
 1119
 11120        if (!TryFailbackServiceFromBackup(paths.ServiceRootPath, paths.ScriptRoot, paths.ModuleRoot, out var failbackSum
 1121        {
 01122            Console.Error.WriteLine(failbackError);
 01123            exitCode = 1;
 01124            return false;
 1125        }
 1126
 11127        Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(failbackSummary, new System.Text.Json.JsonSerializer
 11128        {
 11129            WriteIndented = true,
 11130        }));
 1131
 11132        return true;
 1133    }
 1134
 1135    /// <summary>
 1136    /// Applies package/application update to the installed service bundle.
 1137    /// </summary>
 1138    /// <param name="command">Parsed command information.</param>
 1139    /// <param name="hasPackageUpdate">True when package update was requested.</param>
 1140    /// <param name="paths">Resolved service update path set.</param>
 1141    /// <param name="scriptSource">Current resolved script source; may be populated when needed.</param>
 1142    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> is already resolved.</param>
 1143    /// <param name="applicationUpdated">True when application files were updated.</param>
 1144    /// <param name="exitCode">Exit code when update fails.</param>
 1145    /// <returns>True when package update succeeds or is not requested.</returns>
 1146    private static bool TryApplyServicePackageUpdate(
 1147        ParsedCommand command,
 1148        bool hasPackageUpdate,
 1149        ServiceUpdatePaths paths,
 1150        ref ResolvedServiceScriptSource scriptSource,
 1151        ref bool packageSourceResolved,
 1152        out bool applicationUpdated,
 1153        out int exitCode)
 1154    {
 11155        applicationUpdated = false;
 11156        exitCode = 0;
 1157
 11158        if (!hasPackageUpdate)
 1159        {
 01160            return true;
 1161        }
 1162
 11163        if (!TryEnsureServicePackageSourceResolved(command, ref scriptSource, ref packageSourceResolved, out exitCode))
 1164        {
 01165            return false;
 1166        }
 1167
 11168        if (!TryValidateServicePackageUpdateContext(paths.ScriptRoot, scriptSource, out var contentRoot, out exitCode))
 1169        {
 01170            return false;
 1171        }
 1172
 11173        if (!TryApplyServiceApplicationReplacement(paths, contentRoot, scriptSource.DescriptorPreservePaths, out exitCod
 1174        {
 01175            return false;
 1176        }
 1177
 11178        applicationUpdated = true;
 11179        return true;
 1180    }
 1181
 1182    /// <summary>
 1183    /// Ensures package script metadata is resolved for service package update operations.
 1184    /// </summary>
 1185    /// <param name="command">Parsed command information.</param>
 1186    /// <param name="scriptSource">Current resolved script source; populated when resolution is required.</param>
 1187    /// <param name="packageSourceResolved">True when <paramref name="scriptSource"/> is already resolved.</param>
 1188    /// <param name="exitCode">Exit code when source resolution fails.</param>
 1189    /// <returns>True when package script source is available.</returns>
 1190    private static bool TryEnsureServicePackageSourceResolved(
 1191        ParsedCommand command,
 1192        ref ResolvedServiceScriptSource scriptSource,
 1193        ref bool packageSourceResolved,
 1194        out int exitCode)
 1195    {
 11196        exitCode = 0;
 11197        if (packageSourceResolved)
 1198        {
 11199            return true;
 1200        }
 1201
 01202        if (!TryResolveServiceScriptSource(command, out scriptSource, out var scriptError))
 1203        {
 01204            Console.Error.WriteLine(scriptError);
 01205            exitCode = 2;
 01206            return false;
 1207        }
 1208
 01209        packageSourceResolved = true;
 01210        return true;
 1211    }
 1212
 1213    /// <summary>
 1214    /// Validates package update preconditions against the installed service descriptor and content root.
 1215    /// </summary>
 1216    /// <param name="scriptRoot">Installed service script root.</param>
 1217    /// <param name="scriptSource">Resolved incoming package script source.</param>
 1218    /// <param name="contentRoot">Validated package content root path.</param>
 1219    /// <param name="exitCode">Exit code when validation fails.</param>
 1220    /// <returns>True when package update preconditions are satisfied.</returns>
 1221    private static bool TryValidateServicePackageUpdateContext(
 1222        string scriptRoot,
 1223        ResolvedServiceScriptSource scriptSource,
 1224        out string contentRoot,
 1225        out int exitCode)
 1226    {
 11227        contentRoot = string.Empty;
 11228        exitCode = 0;
 1229
 11230        if (!TryResolveServiceInstallDescriptor(scriptRoot, out var runningDescriptor, out var currentDescriptorError))
 1231        {
 01232            Console.Error.WriteLine(currentDescriptorError);
 01233            exitCode = 1;
 01234            return false;
 1235        }
 1236
 11237        if (!TryValidateServicePackageVersionUpdate(
 11238                runningDescriptor.Version,
 11239                scriptSource.DescriptorServiceVersion,
 11240                out _,
 11241                out var versionWarning,
 11242                out var versionValidationError))
 1243        {
 01244            Console.Error.WriteLine(versionValidationError);
 01245            exitCode = 1;
 01246            return false;
 1247        }
 1248
 11249        if (!string.IsNullOrWhiteSpace(versionWarning))
 1250        {
 01251            Console.WriteLine(versionWarning);
 1252        }
 1253
 11254        if (string.IsNullOrWhiteSpace(scriptSource.FullContentRoot) || !Directory.Exists(scriptSource.FullContentRoot))
 1255        {
 01256            Console.Error.WriteLine("Resolved package content root is not available for update.");
 01257            exitCode = 1;
 01258            return false;
 1259        }
 1260
 11261        contentRoot = scriptSource.FullContentRoot;
 1262
 11263        return true;
 1264    }
 1265
 1266    /// <summary>
 1267    /// Backs up and replaces the installed service application directory from package content.
 1268    /// </summary>
 1269    /// <param name="paths">Resolved service update path set.</param>
 1270    /// <param name="contentRoot">Validated source content root path.</param>
 1271    /// <param name="preserveRelativePaths">Optional preserve-path entries from the service descriptor.</param>
 1272    /// <param name="exitCode">Exit code when replacement fails.</param>
 1273    /// <returns>True when backup and replacement succeed.</returns>
 1274    private static bool TryApplyServiceApplicationReplacement(
 1275        ServiceUpdatePaths paths,
 1276        string contentRoot,
 1277        IReadOnlyList<string>? preserveRelativePaths,
 1278        out int exitCode)
 1279    {
 21280        exitCode = 0;
 1281
 21282        if (!TryBackupDirectory(paths.ScriptRoot, Path.Combine(paths.BackupRoot, "application"), out var backupAppError)
 1283        {
 01284            Console.Error.WriteLine(backupAppError);
 01285            exitCode = 1;
 01286            return false;
 1287        }
 1288
 21289        if (!TryReplaceDirectoryFromSource(
 21290            contentRoot,
 21291                paths.ScriptRoot,
 21292                "Updating service application",
 21293                out var appReplaceError,
 21294                exclusionPatterns: null,
 21295            preserveRelativePaths: preserveRelativePaths))
 1296        {
 01297            Console.Error.WriteLine(appReplaceError);
 01298            exitCode = 1;
 01299            return false;
 1300        }
 1301
 21302        return true;
 1303    }
 1304
 1305    /// <summary>
 1306    /// Applies module update logic for either repository module replacement or explicit manifest replacement.
 1307    /// </summary>
 1308    /// <param name="command">Parsed command information.</param>
 1309    /// <param name="hasModuleUpdate">True when module update was requested.</param>
 1310    /// <param name="paths">Resolved service update path set.</param>
 1311    /// <param name="moduleUpdated">True when bundled module files were replaced.</param>
 1312    /// <param name="exitCode">Exit code when module update fails.</param>
 1313    /// <returns>True when module update succeeds or is not requested.</returns>
 1314    private static bool TryApplyServiceModuleUpdate(
 1315        ParsedCommand command,
 1316        bool hasModuleUpdate,
 1317        ServiceUpdatePaths paths,
 1318        out bool moduleUpdated,
 1319        out int exitCode)
 1320    {
 11321        moduleUpdated = false;
 11322        exitCode = 0;
 1323
 11324        if (!hasModuleUpdate)
 1325        {
 01326            return true;
 1327        }
 1328
 11329        if (!TryResolveUpdateManifestPath(command, out var manifestPath, out exitCode))
 1330        {
 01331            return false;
 1332        }
 1333
 11334        if (!TryResolveSourceModuleRoot(manifestPath, out var sourceModuleRoot, out var sourceModuleRootError))
 1335        {
 01336            Console.Error.WriteLine(sourceModuleRootError);
 01337            exitCode = 1;
 01338            return false;
 1339        }
 1340
 11341        if (!command.ServiceUseRepositoryKestrun)
 1342        {
 11343            return TryApplyDirectModuleReplacement(sourceModuleRoot, paths, out moduleUpdated, out exitCode);
 1344        }
 1345
 01346        var bundledManifestPath = Path.Combine(paths.ModuleRoot, ModuleManifestFileName);
 01347        if (!TryEvaluateRepositoryModuleUpdateNeeded(manifestPath, bundledManifestPath, out var shouldUpdateBundledModul
 1348        {
 01349            Console.Error.WriteLine(moduleDecisionError);
 01350            exitCode = 1;
 01351            return false;
 1352        }
 1353
 01354        if (!shouldUpdateBundledModule)
 1355        {
 01356            Console.WriteLine(moduleDecisionMessage);
 01357            return true;
 1358        }
 1359
 01360        return TryApplyDirectModuleReplacement(sourceModuleRoot, paths, out moduleUpdated, out exitCode);
 1361    }
 1362
 1363    /// <summary>
 1364    /// Replaces the bundled module directory from the provided source module root with backup creation.
 1365    /// </summary>
 1366    /// <param name="sourceModuleRoot">Source module root directory.</param>
 1367    /// <param name="paths">Resolved service update path set.</param>
 1368    /// <param name="moduleUpdated">True when module replacement succeeds.</param>
 1369    /// <param name="exitCode">Exit code when replacement fails.</param>
 1370    /// <returns>True when module replacement succeeds.</returns>
 1371    private static bool TryApplyDirectModuleReplacement(
 1372        string sourceModuleRoot,
 1373        ServiceUpdatePaths paths,
 1374        out bool moduleUpdated,
 1375        out int exitCode)
 1376    {
 21377        moduleUpdated = false;
 21378        exitCode = 0;
 1379
 21380        if (!TryBackupDirectory(paths.ModuleRoot, Path.Combine(paths.BackupRoot, "module"), out var backupModuleError))
 1381        {
 01382            Console.Error.WriteLine(backupModuleError);
 01383            exitCode = 1;
 01384            return false;
 1385        }
 1386
 21387        if (!TryReplaceDirectoryFromSource(sourceModuleRoot, paths.ModuleRoot, "Updating bundled Kestrun module", out va
 1388        {
 01389            Console.Error.WriteLine(moduleReplaceError);
 01390            exitCode = 1;
 01391            return false;
 1392        }
 1393
 21394        moduleUpdated = true;
 21395        return true;
 1396    }
 1397
 1398    /// <summary>
 1399    /// Resolves the manifest path to use for service module update.
 1400    /// </summary>
 1401    /// <param name="command">Parsed command information.</param>
 1402    /// <param name="manifestPath">Resolved manifest path.</param>
 1403    /// <param name="exitCode">Exit code when resolution fails.</param>
 1404    /// <returns>True when manifest resolution succeeds.</returns>
 21405    private static bool TryResolveUpdateManifestPath(ParsedCommand command, out string manifestPath, out int exitCode) =
 1406
 1407    /// <summary>
 1408    /// Resolves the manifest path to use for service module update, using an explicit repository search root when reque
 1409    /// </summary>
 1410    /// <param name="command">Parsed command information.</param>
 1411    /// <param name="repositorySearchRoot">Directory used to discover repository-local module manifests.</param>
 1412    /// <param name="manifestPath">Resolved manifest path.</param>
 1413    /// <param name="exitCode">Exit code when resolution fails.</param>
 1414    /// <returns>True when manifest resolution succeeds.</returns>
 1415    private static bool TryResolveUpdateManifestPath(ParsedCommand command, string repositorySearchRoot, out string mani
 1416    {
 31417        manifestPath = string.Empty;
 31418        exitCode = 0;
 1419
 31420        var resolvedManifestPath = command.ServiceUseRepositoryKestrun
 31421            ? ResolveRepositoryModuleManifestPath(repositorySearchRoot)
 31422            : LocateModuleManifest(command.KestrunManifestPath, command.KestrunFolder);
 1423
 31424        if (resolvedManifestPath is null)
 1425        {
 11426            if (command.ServiceUseRepositoryKestrun)
 1427            {
 01428                Console.Error.WriteLine("Unable to locate repository module manifest at 'src/PowerShell/Kestrun/Kestrun.
 01429                exitCode = 1;
 01430                return false;
 1431            }
 1432
 11433            WriteModuleNotFoundMessage(command.KestrunManifestPath, command.KestrunFolder, Console.Error.WriteLine);
 11434            exitCode = 3;
 11435            return false;
 1436        }
 1437
 21438        manifestPath = resolvedManifestPath;
 21439        return true;
 1440    }
 1441
 1442    /// <summary>
 1443    /// Resolves and validates the source module root directory from a manifest path.
 1444    /// </summary>
 1445    /// <param name="manifestPath">Module manifest path.</param>
 1446    /// <param name="sourceModuleRoot">Resolved module root directory.</param>
 1447    /// <param name="error">Validation error details.</param>
 1448    /// <returns>True when the source module root is valid.</returns>
 1449    private static bool TryResolveSourceModuleRoot(string manifestPath, out string sourceModuleRoot, out string error)
 1450    {
 31451        sourceModuleRoot = Path.GetDirectoryName(Path.GetFullPath(manifestPath)) ?? string.Empty;
 31452        error = string.Empty;
 1453
 31454        if (!string.IsNullOrWhiteSpace(sourceModuleRoot) && Directory.Exists(sourceModuleRoot))
 1455        {
 21456            return true;
 1457        }
 1458
 11459        error = $"Unable to resolve module root from manifest path: {manifestPath}";
 11460        return false;
 1461    }
 1462
 1463    /// <summary>
 1464    /// Applies service-host runtime update when a newer host binary is available.
 1465    /// </summary>
 1466    /// <param name="paths">Resolved service update path set.</param>
 1467    /// <param name="serviceHostUpdated">True when service host binaries were updated.</param>
 1468    /// <param name="exitCode">Exit code when update fails.</param>
 1469    /// <returns>True when service host update succeeds.</returns>
 1470    private static bool TryApplyServiceHostUpdate(ServiceUpdatePaths paths, out bool serviceHostUpdated, out int exitCod
 1471    {
 11472        exitCode = 0;
 1473
 11474        var runtimeDirectory = Path.Combine(paths.ServiceRootPath, ServiceBundleRuntimeDirectoryName);
 11475        if (TryUpdateBundledServiceHostIfNewer(runtimeDirectory, Path.Combine(paths.BackupRoot, "servicehost"), out var 
 1476        {
 01477            return true;
 1478        }
 1479
 11480        Console.Error.WriteLine(hostUpdateError);
 11481        exitCode = 1;
 11482        return false;
 1483    }
 1484
 1485    /// <summary>
 1486    /// Writes service update results as indented JSON.
 1487    /// </summary>
 1488    /// <param name="serviceName">Resolved service name.</param>
 1489    /// <param name="paths">Resolved service update path set.</param>
 1490    /// <param name="applicationUpdated">True when application files were updated.</param>
 1491    /// <param name="moduleUpdated">True when module files were updated.</param>
 1492    /// <param name="serviceHostUpdated">True when service host was updated.</param>
 1493    private static void WriteServiceUpdateSummary(
 1494        string serviceName,
 1495        ServiceUpdatePaths paths,
 1496        bool applicationUpdated,
 1497        bool moduleUpdated,
 1498        bool serviceHostUpdated)
 1499    {
 11500        var summary = new
 11501        {
 11502            ServiceName = serviceName,
 11503            ServicePath = paths.ServiceRootPath,
 11504            ApplicationUpdated = applicationUpdated,
 11505            ModuleUpdated = moduleUpdated,
 11506            ServiceHostUpdated = serviceHostUpdated,
 11507            BackupPath = Directory.Exists(paths.BackupRoot) ? paths.BackupRoot : null,
 11508        };
 1509
 11510        Console.WriteLine(System.Text.Json.JsonSerializer.Serialize(summary, new System.Text.Json.JsonSerializerOptions
 11511        {
 11512            WriteIndented = true,
 11513        }));
 11514    }
 1515
 1516    /// <summary>
 1517    /// Contains resolved directory paths used by service update and failback operations.
 1518    /// </summary>
 1519    /// <param name="ServiceRootPath">Installed service bundle root path.</param>
 1520    /// <param name="ScriptRoot">Installed service script directory path.</param>
 1521    /// <param name="ModuleRoot">Installed bundled module root path.</param>
 1522    /// <param name="BackupRoot">Backup snapshot root path for the current update operation.</param>
 1523    private readonly record struct ServiceUpdatePaths(
 41524        string ServiceRootPath,
 71525        string ScriptRoot,
 51526        string ModuleRoot,
 81527        string BackupRoot);
 1528
 1529    /// <summary>
 1530    /// Resolves the repository-local Kestrun manifest path by scanning current directory ancestors.
 1531    /// </summary>
 1532    /// <returns>Absolute manifest path when found; otherwise null.</returns>
 01533    private static string? ResolveRepositoryModuleManifestPath() => ResolveRepositoryModuleManifestPath(Environment.Curr
 1534
 1535    /// <summary>
 1536    /// Resolves the repository-local Kestrun manifest path by scanning ancestors starting from the specified directory.
 1537    /// </summary>
 1538    /// <param name="startDirectory">Directory to begin repository ancestor scanning from.</param>
 1539    /// <returns>Absolute manifest path when found; otherwise null.</returns>
 1540    private static string? ResolveRepositoryModuleManifestPath(string startDirectory)
 1541    {
 61542        foreach (var parent in EnumerateDirectoryAndParents(startDirectory))
 1543        {
 21544            var candidate = Path.Combine(parent, "src", "PowerShell", ModuleName, ModuleManifestFileName);
 21545            if (File.Exists(candidate))
 1546            {
 21547                return Path.GetFullPath(candidate);
 1548            }
 1549        }
 1550
 01551        return null;
 21552    }
 1553
 1554    /// <summary>
 1555    /// Determines whether repository module content should replace the bundled module based on semantic version compari
 1556    /// </summary>
 1557    /// <param name="repositoryManifestPath">Repository Kestrun manifest path.</param>
 1558    /// <param name="bundledManifestPath">Bundled service module manifest path.</param>
 1559    /// <param name="shouldUpdate">True when the bundled module should be replaced.</param>
 1560    /// <param name="message">Decision summary message when no update is required.</param>
 1561    /// <param name="error">Validation error details.</param>
 1562    /// <returns>True when comparison succeeds.</returns>
 1563    private static bool TryEvaluateRepositoryModuleUpdateNeeded(
 1564        string repositoryManifestPath,
 1565        string bundledManifestPath,
 1566        out bool shouldUpdate,
 1567        out string message,
 1568        out string error)
 1569    {
 21570        shouldUpdate = false;
 21571        message = string.Empty;
 21572        error = string.Empty;
 1573
 21574        if (!TryReadModuleSemanticVersionFromManifest(repositoryManifestPath, out var repositoryVersion))
 1575        {
 01576            error = $"Unable to read ModuleVersion from repository manifest '{repositoryManifestPath}'.";
 01577            return false;
 1578        }
 1579
 21580        if (!File.Exists(bundledManifestPath))
 1581        {
 11582            shouldUpdate = true;
 11583            return true;
 1584        }
 1585
 11586        if (!TryReadModuleSemanticVersionFromManifest(bundledManifestPath, out var bundledVersion))
 1587        {
 01588            error = $"Unable to read ModuleVersion from bundled manifest '{bundledManifestPath}'.";
 01589            return false;
 1590        }
 1591
 11592        var comparison = CompareModuleVersionValues(repositoryVersion, bundledVersion);
 11593        if (comparison > 0)
 1594        {
 01595            shouldUpdate = true;
 01596            return true;
 1597        }
 1598
 11599        message = $"Bundled Kestrun module version '{bundledVersion}' is current or newer than repository version '{repo
 11600        return true;
 1601    }
 1602
 1603    /// <summary>
 1604    /// Restores service application/module directories from the latest backup folder and removes the consumed backup.
 1605    /// </summary>
 1606    /// <param name="serviceRootPath">Resolved service bundle root.</param>
 1607    /// <param name="scriptRoot">Target script root directory.</param>
 1608    /// <param name="moduleRoot">Target bundled module root directory.</param>
 1609    /// <param name="summary">Serialized summary payload.</param>
 1610    /// <param name="error">Failback error details.</param>
 1611    /// <returns>True when failback succeeds.</returns>
 1612    private static bool TryFailbackServiceFromBackup(
 1613        string serviceRootPath,
 1614        string scriptRoot,
 1615        string moduleRoot,
 1616        out object summary,
 1617        out string error)
 1618    {
 21619        summary = new { };
 1620
 21621        if (!TryResolveLatestServiceBackupDirectory(serviceRootPath, out var latestBackupPath, out error))
 1622        {
 01623            return false;
 1624        }
 1625
 21626        var backupApplicationPath = Path.Combine(latestBackupPath, "application");
 21627        var backupModulePath = Path.Combine(latestBackupPath, "module");
 21628        var hasApplicationBackup = Directory.Exists(backupApplicationPath);
 21629        var hasModuleBackup = Directory.Exists(backupModulePath);
 1630
 21631        if (!hasApplicationBackup && !hasModuleBackup)
 1632        {
 01633            error = $"Backup '{latestBackupPath}' does not contain application or module content.";
 01634            return false;
 1635        }
 1636
 21637        if (hasApplicationBackup
 21638            && !TryReplaceDirectoryFromSource(backupApplicationPath, scriptRoot, "Failback service application", out var
 1639        {
 01640            error = applicationRestoreError;
 01641            return false;
 1642        }
 1643
 21644        if (hasModuleBackup
 21645            && !TryReplaceDirectoryFromSource(backupModulePath, moduleRoot, "Failback bundled Kestrun module", out var m
 1646        {
 01647            error = moduleRestoreError;
 01648            return false;
 1649        }
 1650
 1651        try
 1652        {
 21653            TryDeleteDirectoryWithRetry(latestBackupPath, maxAttempts: 5, initialDelayMs: 50);
 21654        }
 01655        catch (Exception ex)
 1656        {
 01657            error = $"Failback succeeded but backup folder '{latestBackupPath}' could not be removed: {ex.Message}";
 01658            return false;
 1659        }
 1660
 21661        summary = new
 21662        {
 21663            ServicePath = serviceRootPath,
 21664            ApplicationReverted = hasApplicationBackup,
 21665            ModuleReverted = hasModuleBackup,
 21666            ConsumedBackupPath = latestBackupPath,
 21667            BackupRemoved = true,
 21668        };
 1669
 21670        return true;
 01671    }
 1672
 1673    /// <summary>
 1674    /// Resolves the latest service backup directory from the service backup root.
 1675    /// </summary>
 1676    /// <param name="serviceRootPath">Resolved service bundle root.</param>
 1677    /// <param name="backupDirectoryPath">Resolved latest backup path.</param>
 1678    /// <param name="error">Resolution error details.</param>
 1679    /// <returns>True when a backup directory exists.</returns>
 1680    private static bool TryResolveLatestServiceBackupDirectory(string serviceRootPath, out string backupDirectoryPath, o
 1681    {
 41682        backupDirectoryPath = string.Empty;
 41683        error = string.Empty;
 1684
 41685        var backupRoot = Path.Combine(serviceRootPath, "backup");
 41686        if (!Directory.Exists(backupRoot))
 1687        {
 11688            error = $"No backup folder found under '{backupRoot}'.";
 11689            return false;
 1690        }
 1691
 31692        var candidates = Directory
 31693            .GetDirectories(backupRoot)
 41694            .Select(static path => new
 41695            {
 41696                Path = path,
 41697                Name = Path.GetFileName(path),
 41698                LastWriteUtc = Directory.GetLastWriteTimeUtc(path),
 41699            })
 21700            .OrderByDescending(static candidate => candidate.Name)
 21701            .ThenByDescending(static candidate => candidate.LastWriteUtc)
 31702            .ToList();
 1703
 31704        if (candidates.Count == 0)
 1705        {
 01706            error = $"No backup folder found under '{backupRoot}'.";
 01707            return false;
 1708        }
 1709
 31710        backupDirectoryPath = candidates[0].Path;
 31711        return true;
 1712    }
 1713
 1714    /// <summary>
 1715    /// Validates that a descriptor version string is present and compatible with System.Version.
 1716    /// </summary>
 1717    /// <param name="descriptorVersion">Descriptor version string.</param>
 1718    /// <param name="version">Parsed version.</param>
 1719    /// <param name="error">Validation error details.</param>
 1720    /// <returns>True when version parsing succeeds.</returns>
 1721    private static bool TryParseServiceDescriptorVersion(string? descriptorVersion, out Version version, out string erro
 1722    {
 51723        version = new Version(0, 0);
 51724        error = string.Empty;
 1725
 51726        if (string.IsNullOrWhiteSpace(descriptorVersion))
 1727        {
 01728            error = "Service descriptor Version is required for update comparison.";
 01729            return false;
 1730        }
 1731
 51732        if (!Version.TryParse(descriptorVersion.Trim(), out var parsedVersion) || parsedVersion is null)
 1733        {
 01734            error = $"Service descriptor Version '{descriptorVersion}' is not compatible with System.Version.";
 01735            return false;
 1736        }
 1737
 51738        version = parsedVersion;
 51739        return true;
 1740    }
 1741
 1742    /// <summary>
 1743    /// Validates package-version progression for service updates.
 1744    /// </summary>
 1745    /// <param name="installedDescriptorVersion">Installed descriptor version.</param>
 1746    /// <param name="packageDescriptorVersion">Incoming package descriptor version.</param>
 1747    /// <param name="packageVersion">Parsed incoming package version.</param>
 1748    /// <param name="warning">Optional warning when installed version metadata is missing.</param>
 1749    /// <param name="error">Validation error details.</param>
 1750    /// <returns>True when package update version checks pass.</returns>
 1751    private static bool TryValidateServicePackageVersionUpdate(
 1752        string? installedDescriptorVersion,
 1753        string? packageDescriptorVersion,
 1754        out Version packageVersion,
 1755        out string? warning,
 1756        out string error)
 1757    {
 31758        packageVersion = new Version(0, 0);
 31759        warning = null;
 31760        error = string.Empty;
 1761
 31762        if (!TryParseServiceDescriptorVersion(packageDescriptorVersion, out var parsedPackageVersion, out var packageVer
 1763        {
 01764            error = $"Unable to compare package version: {packageVersionError}";
 01765            return false;
 1766        }
 1767
 31768        packageVersion = parsedPackageVersion;
 1769
 31770        if (string.IsNullOrWhiteSpace(installedDescriptorVersion))
 1771        {
 11772            warning = "Installed service descriptor Version is missing. Skipping installed-version comparison for this u
 11773            return true;
 1774        }
 1775
 21776        if (!TryParseServiceDescriptorVersion(installedDescriptorVersion, out var installedVersion, out var installedVer
 1777        {
 01778            error = $"Unable to compare installed version: {installedVersionError}";
 01779            return false;
 1780        }
 1781
 21782        if (packageVersion <= installedVersion)
 1783        {
 11784            error = $"Package version '{packageVersion}' must be greater than installed version '{installedVersion}'.";
 11785            return false;
 1786        }
 1787
 11788        return true;
 1789    }
 1790
 1791    /// <summary>
 1792    /// Returns false when the service is currently running.
 1793    /// </summary>
 1794    /// <param name="serviceName">Service name.</param>
 1795    /// <param name="error">State validation details.</param>
 1796    /// <returns>True when service is stopped or inactive.</returns>
 1797    private static bool TryEnsureServiceIsStopped(string serviceName, out string error)
 1798    {
 11799        if (OperatingSystem.IsWindows())
 1800        {
 01801            return TryEnsureWindowsServiceIsStopped(serviceName, out error);
 1802        }
 1803
 11804        if (OperatingSystem.IsLinux())
 1805        {
 11806            return TryEnsureLinuxServiceIsStopped(serviceName, out error);
 1807        }
 1808
 01809        if (OperatingSystem.IsMacOS())
 1810        {
 01811            return TryEnsureMacServiceIsStopped(serviceName, out error);
 1812        }
 1813
 01814        error = "Service update is not supported on this OS.";
 01815        return false;
 1816    }
 1817
 1818    /// <summary>
 1819    /// Returns false when a Windows service is currently running.
 1820    /// </summary>
 1821    /// <param name="serviceName">Service name.</param>
 1822    /// <param name="error">State validation details.</param>
 1823    /// <returns>True when service is stopped or inactive.</returns>
 1824    private static bool TryEnsureWindowsServiceIsStopped(string serviceName, out string error)
 1825    {
 01826        var queryResult = RunProcess("sc.exe", ["query", serviceName], writeStandardOutput: false);
 01827        if (queryResult.ExitCode != 0)
 1828        {
 01829            error = string.IsNullOrWhiteSpace(queryResult.Error)
 01830                ? $"Unable to query service '{serviceName}'."
 01831                : queryResult.Error.Trim();
 01832            return false;
 1833        }
 1834
 01835        var stateLine = queryResult.Output
 01836            .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 01837            .FirstOrDefault(static line => line.Contains("STATE", StringComparison.OrdinalIgnoreCase));
 1838
 01839        if (!string.IsNullOrWhiteSpace(stateLine) && stateLine.Contains("RUNNING", StringComparison.OrdinalIgnoreCase))
 1840        {
 01841            error = $"Service '{serviceName}' is running. Stop it before update.";
 01842            return false;
 1843        }
 1844
 01845        error = string.Empty;
 01846        return true;
 1847    }
 1848
 1849    /// <summary>
 1850    /// Returns false when a Linux service unit is currently active.
 1851    /// </summary>
 1852    /// <param name="serviceName">Service name.</param>
 1853    /// <param name="error">State validation details.</param>
 1854    /// <returns>True when service is stopped or inactive.</returns>
 1855    private static bool TryEnsureLinuxServiceIsStopped(string serviceName, out string error)
 1856    {
 11857        var useSystemScope = IsLinuxSystemUnitInstalled(serviceName);
 11858        var unitName = GetLinuxUnitName(serviceName);
 11859        var activeResult = RunLinuxSystemctl(useSystemScope, ["is-active", unitName]);
 11860        if (activeResult.ExitCode == 0)
 1861        {
 01862            error = $"Service '{serviceName}' is running. Stop it before update.";
 01863            return false;
 1864        }
 1865
 11866        error = string.Empty;
 11867        return true;
 1868    }
 1869
 1870    /// <summary>
 1871    /// Returns false when a macOS launchd service is currently running.
 1872    /// </summary>
 1873    /// <param name="serviceName">Service name.</param>
 1874    /// <param name="error">State validation details.</param>
 1875    /// <returns>True when service is stopped or inactive.</returns>
 1876    private static bool TryEnsureMacServiceIsStopped(string serviceName, out string error)
 1877    {
 01878        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 01879        var result = useSystemScope
 01880            ? RunProcess("launchctl", ["print", $"system/{serviceName}"])
 01881            : RunProcess("launchctl", ["list", serviceName]);
 1882
 01883        if (result.ExitCode != 0)
 1884        {
 01885            error = string.Empty;
 01886            return true;
 1887        }
 1888
 01889        if (useSystemScope)
 1890        {
 01891            var running = result.Output
 01892                .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 01893                .Any(static line => line.Contains("state = running", StringComparison.OrdinalIgnoreCase));
 1894
 01895            if (running)
 1896            {
 01897                error = $"Service '{serviceName}' is running. Stop it before update.";
 01898                return false;
 1899            }
 1900
 01901            error = string.Empty;
 01902            return true;
 1903        }
 1904
 01905        var columns = result.Output.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEn
 01906        if (columns.Length > 0 && int.TryParse(columns[0], out var pid) && pid > 0)
 1907        {
 01908            error = $"Service '{serviceName}' is running. Stop it before update.";
 01909            return false;
 1910        }
 1911
 01912        error = string.Empty;
 01913        return true;
 1914    }
 1915
 1916    /// <summary>
 1917    /// Creates a backup copy of a directory when it exists.
 1918    /// </summary>
 1919    /// <param name="sourceDirectory">Directory to back up.</param>
 1920    /// <param name="backupDirectory">Backup destination directory.</param>
 1921    /// <param name="error">Backup error details.</param>
 1922    /// <returns>True when backup succeeds or source does not exist.</returns>
 1923    private static bool TryBackupDirectory(string sourceDirectory, string backupDirectory, out string error)
 1924    {
 41925        error = string.Empty;
 41926        if (!Directory.Exists(sourceDirectory))
 1927        {
 01928            return true;
 1929        }
 1930
 1931        try
 1932        {
 41933            _ = Directory.CreateDirectory(backupDirectory);
 41934            CopyDirectoryContents(sourceDirectory, backupDirectory, showProgress: false, "Creating backup", exclusionPat
 41935            return true;
 1936        }
 01937        catch (Exception ex)
 1938        {
 01939            error = $"Failed to back up '{sourceDirectory}' to '{backupDirectory}': {ex.Message}";
 01940            return false;
 1941        }
 41942    }
 1943
 1944    /// <summary>
 1945    /// Replaces a target directory from a source directory.
 1946    /// </summary>
 1947    /// <param name="sourceDirectory">Source directory.</param>
 1948    /// <param name="targetDirectory">Target directory.</param>
 1949    /// <param name="operationName">Operation label for progress output.</param>
 1950    /// <param name="error">Replacement error details.</param>
 1951    /// <param name="exclusionPatterns">Optional exclusion patterns.</param>
 1952    /// <returns>True when replacement succeeds.</returns>
 1953    private static bool TryReplaceDirectoryFromSource(
 1954        string sourceDirectory,
 1955        string targetDirectory,
 1956        string operationName,
 1957        out string error,
 1958        IReadOnlyList<string>? exclusionPatterns = null,
 1959        IReadOnlyList<string>? preserveRelativePaths = null)
 1960    {
 91961        error = string.Empty;
 91962        string? preserveStagingRoot = null;
 1963        try
 1964        {
 91965            if (preserveRelativePaths is not null
 91966                && preserveRelativePaths.Count > 0
 91967                && Directory.Exists(targetDirectory)
 91968                && !TryStagePreservedPaths(targetDirectory, preserveRelativePaths, out preserveStagingRoot, out error))
 1969            {
 01970                return false;
 1971            }
 1972
 91973            if (Directory.Exists(targetDirectory))
 1974            {
 91975                Directory.Delete(targetDirectory, recursive: true);
 1976            }
 1977
 91978            _ = Directory.CreateDirectory(targetDirectory);
 91979            CopyDirectoryContents(sourceDirectory, targetDirectory, showProgress: !Console.IsOutputRedirected, operation
 1980
 91981            return string.IsNullOrWhiteSpace(preserveStagingRoot)
 91982                || TryRestorePreservedPaths(preserveStagingRoot, targetDirectory, out error);
 1983        }
 01984        catch (Exception ex)
 1985        {
 01986            error = $"Failed to replace '{targetDirectory}' from '{sourceDirectory}': {ex.Message}";
 01987            return false;
 1988        }
 1989        finally
 1990        {
 91991            if (!string.IsNullOrWhiteSpace(preserveStagingRoot) && Directory.Exists(preserveStagingRoot))
 1992            {
 1993                try
 1994                {
 41995                    TryDeleteDirectoryWithRetry(preserveStagingRoot, maxAttempts: 5, initialDelayMs: 50);
 41996                }
 01997                catch
 1998                {
 1999                    // Best-effort cleanup for preserve staging directory.
 02000                }
 2001            }
 92002        }
 92003    }
 2004
 2005    /// <summary>
 2006    /// Stages preserve-path files/directories from an existing target directory into a temporary folder.
 2007    /// </summary>
 2008    /// <param name="targetDirectory">Existing target directory whose content is being replaced.</param>
 2009    /// <param name="preserveRelativePaths">Relative preserve paths declared in the package descriptor.</param>
 2010    /// <param name="preserveStagingRoot">Temporary preserve staging root path.</param>
 2011    /// <param name="error">Staging error details.</param>
 2012    /// <returns>True when staging succeeds.</returns>
 2013    private static bool TryStagePreservedPaths(
 2014        string targetDirectory,
 2015        IReadOnlyList<string> preserveRelativePaths,
 2016        out string preserveStagingRoot,
 2017        out string error)
 2018    {
 42019        preserveStagingRoot = Path.Combine(Path.GetTempPath(), $"kestrun-preserve-{Guid.NewGuid():N}");
 42020        error = string.Empty;
 2021
 42022        var targetRootFullPath = Path.GetFullPath(targetDirectory);
 42023        var preservePathComparer = OperatingSystem.IsWindows() ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordin
 42024        var normalizedPreservePaths = new HashSet<string>(preservePathComparer);
 182025        foreach (var preservePath in preserveRelativePaths)
 2026        {
 52027            if (!TryNormalizePreservePath(preservePath, out var normalizedPath, out error))
 2028            {
 02029                return false;
 2030            }
 2031
 52032            _ = normalizedPreservePaths.Add(normalizedPath);
 2033        }
 2034
 42035        _ = Directory.CreateDirectory(preserveStagingRoot);
 182036        foreach (var normalizedPath in normalizedPreservePaths)
 2037        {
 52038            var sourcePath = Path.GetFullPath(Path.Combine(targetRootFullPath, normalizedPath));
 52039            if (!IsPathWithinDirectory(sourcePath, targetRootFullPath))
 2040            {
 02041                error = $"PreservePaths entry '{normalizedPath}' escapes the service application root.";
 02042                return false;
 2043            }
 2044
 52045            var stagedPath = Path.Combine(preserveStagingRoot, normalizedPath);
 52046            var stagedDirectory = Path.GetDirectoryName(stagedPath);
 52047            if (!string.IsNullOrWhiteSpace(stagedDirectory))
 2048            {
 52049                _ = Directory.CreateDirectory(stagedDirectory);
 2050            }
 2051
 52052            if (File.Exists(sourcePath))
 2053            {
 42054                File.Copy(sourcePath, stagedPath, overwrite: true);
 42055                continue;
 2056            }
 2057
 12058            if (Directory.Exists(sourcePath))
 2059            {
 12060                _ = Directory.CreateDirectory(stagedPath);
 12061                CopyDirectoryContents(sourcePath, stagedPath, showProgress: false, "Staging preserved paths", exclusionP
 2062            }
 2063        }
 2064
 42065        return true;
 02066    }
 2067
 2068    /// <summary>
 2069    /// Restores staged preserve-path files/directories into the replaced target directory.
 2070    /// </summary>
 2071    /// <param name="preserveStagingRoot">Preserve staging root path.</param>
 2072    /// <param name="targetDirectory">Replacement target directory.</param>
 2073    /// <param name="error">Restore error details.</param>
 2074    /// <returns>True when restore succeeds.</returns>
 2075    private static bool TryRestorePreservedPaths(string preserveStagingRoot, string targetDirectory, out string error)
 2076    {
 42077        error = string.Empty;
 2078        try
 2079        {
 162080            foreach (var directoryPath in Directory.GetDirectories(preserveStagingRoot, "*", SearchOption.AllDirectories
 2081            {
 42082                var relativePath = Path.GetRelativePath(preserveStagingRoot, directoryPath);
 42083                var destinationDirectory = Path.Combine(targetDirectory, relativePath);
 42084                _ = Directory.CreateDirectory(destinationDirectory);
 2085            }
 2086
 182087            foreach (var filePath in Directory.GetFiles(preserveStagingRoot, "*", SearchOption.AllDirectories))
 2088            {
 52089                var relativePath = Path.GetRelativePath(preserveStagingRoot, filePath);
 52090                var destinationPath = Path.Combine(targetDirectory, relativePath);
 52091                var destinationDirectory = Path.GetDirectoryName(destinationPath);
 52092                if (!string.IsNullOrWhiteSpace(destinationDirectory))
 2093                {
 52094                    _ = Directory.CreateDirectory(destinationDirectory);
 2095                }
 2096
 52097                File.Copy(filePath, destinationPath, overwrite: true);
 2098            }
 2099
 42100            return true;
 2101        }
 02102        catch (Exception ex)
 2103        {
 02104            error = $"Failed to restore preserved paths into '{targetDirectory}': {ex.Message}";
 02105            return false;
 2106        }
 42107    }
 2108
 2109    /// <summary>
 2110    /// Validates and normalizes one PreservePaths entry.
 2111    /// </summary>
 2112    /// <param name="rawPath">Raw path value from the descriptor.</param>
 2113    /// <param name="normalizedPath">Normalized relative path.</param>
 2114    /// <param name="error">Validation error details.</param>
 2115    /// <returns>True when the preserve path is valid.</returns>
 2116    private static bool TryNormalizePreservePath(string rawPath, out string normalizedPath, out string error)
 2117    {
 52118        normalizedPath = string.Empty;
 52119        if (string.IsNullOrWhiteSpace(rawPath))
 2120        {
 02121            error = $"Service descriptor '{ServiceDescriptorFileName}' contains an empty PreservePaths entry.";
 02122            return false;
 2123        }
 2124
 52125        var candidate = rawPath.Replace('/', Path.DirectorySeparatorChar).Replace('\\', Path.DirectorySeparatorChar).Tri
 52126        if (Path.IsPathRooted(candidate))
 2127        {
 02128            error = $"Service descriptor '{ServiceDescriptorFileName}' PreservePaths entry '{rawPath}' must be relative.
 02129            return false;
 2130        }
 2131
 52132        var candidatePath = candidate.TrimEnd(Path.DirectorySeparatorChar);
 52133        if (string.IsNullOrWhiteSpace(candidatePath)
 52134            || string.Equals(candidatePath, ".", StringComparison.Ordinal))
 2135        {
 02136            error = $"Service descriptor '{ServiceDescriptorFileName}' PreservePaths entry '{rawPath}' is invalid.";
 02137            return false;
 2138        }
 2139
 52140        normalizedPath = candidatePath;
 52141        error = string.Empty;
 52142        return true;
 2143    }
 2144
 2145    /// <summary>
 2146    /// Updates bundled service-host binary when the tool-shipped host is newer.
 2147    /// </summary>
 2148    /// <param name="runtimeDirectory">Service runtime directory.</param>
 2149    /// <param name="backupDirectory">Backup directory for replaced host binary.</param>
 2150    /// <param name="error">Update error details.</param>
 2151    /// <param name="updated">True when host binary was replaced.</param>
 2152    /// <returns>True when host check/update succeeds.</returns>
 2153    private static bool TryUpdateBundledServiceHostIfNewer(string runtimeDirectory, string backupDirectory, out string e
 2154    {
 12155        updated = false;
 12156        if (!TryResolveServiceHostUpdatePaths(runtimeDirectory, out var sourceHostPath, out var targetHostPath, out erro
 2157        {
 02158            return false;
 2159        }
 2160
 12161        if (!File.Exists(targetHostPath))
 2162        {
 12163            return TryCopyServiceHostBinary(sourceHostPath, targetHostPath, out error, out updated);
 2164        }
 2165
 02166        if (!ShouldReplaceBundledServiceHostBinary(sourceHostPath, targetHostPath))
 2167        {
 02168            updated = false;
 02169            return true;
 2170        }
 2171
 02172        return TryBackupAndReplaceServiceHostBinary(sourceHostPath, targetHostPath, backupDirectory, out error, out upda
 2173    }
 2174
 2175    /// <summary>
 2176    /// Resolves source and target service-host paths used by runtime host update operations.
 2177    /// </summary>
 2178    /// <param name="runtimeDirectory">Service runtime directory.</param>
 2179    /// <param name="sourceHostPath">Tool-distributed host executable path.</param>
 2180    /// <param name="targetHostPath">Installed runtime host executable path.</param>
 2181    /// <param name="error">Resolution error details.</param>
 2182    /// <returns>True when path resolution succeeds.</returns>
 2183    private static bool TryResolveServiceHostUpdatePaths(
 2184        string runtimeDirectory,
 2185        out string sourceHostPath,
 2186        out string targetHostPath,
 2187        out string error)
 2188    {
 12189        targetHostPath = string.Empty;
 12190        error = string.Empty;
 2191
 12192        if (!TryResolveDedicatedServiceHostExecutableFromToolDistribution(out sourceHostPath))
 2193        {
 02194            error = "Unable to resolve bundled service-host from Kestrun.Tool distribution.";
 02195            return false;
 2196        }
 2197
 12198        targetHostPath = Path.Combine(runtimeDirectory, Path.GetFileName(sourceHostPath));
 12199        return true;
 2200    }
 2201
 2202    /// <summary>
 2203    /// Copies a service-host executable to the target runtime path and applies Unix execute permissions when required.
 2204    /// </summary>
 2205    /// <param name="sourceHostPath">Source host executable path.</param>
 2206    /// <param name="targetHostPath">Target runtime host executable path.</param>
 2207    /// <param name="error">Copy error details.</param>
 2208    /// <param name="updated">True when copy succeeds.</param>
 2209    /// <returns>True when copy succeeds.</returns>
 2210    private static bool TryCopyServiceHostBinary(string sourceHostPath, string targetHostPath, out string error, out boo
 2211    {
 12212        error = string.Empty;
 12213        updated = false;
 2214
 2215        try
 2216        {
 12217            File.Copy(sourceHostPath, targetHostPath, overwrite: true);
 02218            if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS())
 2219            {
 02220                TryEnsureServiceRuntimeExecutablePermissions(targetHostPath);
 2221            }
 2222
 02223            updated = true;
 02224            return true;
 2225        }
 12226        catch (Exception ex)
 2227        {
 12228            error = $"Failed to update bundled service-host: {ex.Message}";
 12229            return false;
 2230        }
 12231    }
 2232
 2233    /// <summary>
 2234    /// Determines whether the bundled host binary should replace the installed runtime host binary.
 2235    /// </summary>
 2236    /// <param name="sourceHostPath">Tool-distributed host executable path.</param>
 2237    /// <param name="targetHostPath">Installed runtime host executable path.</param>
 2238    /// <returns>True when replacement should occur.</returns>
 2239    private static bool ShouldReplaceBundledServiceHostBinary(string sourceHostPath, string targetHostPath)
 2240    {
 02241        var hasSourceVersion = TryReadFileVersion(sourceHostPath, out var sourceVersion) && sourceVersion is not null;
 02242        var hasTargetVersion = TryReadFileVersion(targetHostPath, out var targetVersion) && targetVersion is not null;
 2243
 02244        return !hasSourceVersion || !hasTargetVersion || sourceVersion > targetVersion;
 2245    }
 2246
 2247    /// <summary>
 2248    /// Backs up the installed runtime host binary and replaces it with the bundled host binary.
 2249    /// </summary>
 2250    /// <param name="sourceHostPath">Tool-distributed host executable path.</param>
 2251    /// <param name="targetHostPath">Installed runtime host executable path.</param>
 2252    /// <param name="backupDirectory">Backup directory for the previous runtime host binary.</param>
 2253    /// <param name="error">Replacement error details.</param>
 2254    /// <param name="updated">True when replacement succeeds.</param>
 2255    /// <returns>True when backup and replacement succeed.</returns>
 2256    private static bool TryBackupAndReplaceServiceHostBinary(
 2257        string sourceHostPath,
 2258        string targetHostPath,
 2259        string backupDirectory,
 2260        out string error,
 2261        out bool updated)
 2262    {
 02263        updated = false;
 2264
 2265        try
 2266        {
 02267            _ = Directory.CreateDirectory(backupDirectory);
 02268            File.Copy(targetHostPath, Path.Combine(backupDirectory, Path.GetFileName(targetHostPath)), overwrite: true);
 02269        }
 02270        catch (Exception ex)
 2271        {
 02272            error = $"Failed to update bundled service-host: {ex.Message}";
 02273            return false;
 2274        }
 2275
 02276        return TryCopyServiceHostBinary(sourceHostPath, targetHostPath, out error, out updated);
 02277    }
 2278
 2279    /// <summary>
 2280    /// Reads file version metadata from a binary file.
 2281    /// </summary>
 2282    /// <param name="filePath">Binary file path.</param>
 2283    /// <param name="version">Parsed version when available.</param>
 2284    /// <returns>True when version parsing succeeds.</returns>
 2285    private static bool TryReadFileVersion(string filePath, out Version? version)
 2286    {
 02287        version = null;
 2288        try
 2289        {
 02290            var fileVersionInfo = System.Diagnostics.FileVersionInfo.GetVersionInfo(filePath);
 02291            if (!string.IsNullOrWhiteSpace(fileVersionInfo.FileVersion)
 02292                && Version.TryParse(fileVersionInfo.FileVersion, out var parsedFileVersion)
 02293                && parsedFileVersion is not null)
 2294            {
 02295                version = parsedFileVersion;
 02296                return true;
 2297            }
 2298
 02299            if (!string.IsNullOrWhiteSpace(fileVersionInfo.ProductVersion)
 02300                && Version.TryParse(fileVersionInfo.ProductVersion, out var parsedProductVersion)
 02301                && parsedProductVersion is not null)
 2302            {
 02303                version = parsedProductVersion;
 02304                return true;
 2305            }
 2306
 02307            return false;
 2308        }
 02309        catch
 2310        {
 02311            return false;
 2312        }
 02313    }
 2314
 2315    /// <summary>
 2316    /// Resolves an installed service bundle path by service name across deployment roots.
 2317    /// </summary>
 2318    /// <param name="serviceName">Service name.</param>
 2319    /// <param name="deploymentRootOverride">Optional deployment root override.</param>
 2320    /// <param name="serviceRootPath">Resolved service bundle root path.</param>
 2321    /// <param name="error">Resolution error details.</param>
 2322    /// <returns>True when a matching installed service bundle is found.</returns>
 2323    private static bool TryResolveInstalledServiceBundleRoot(string serviceName, string? deploymentRootOverride, out str
 2324    {
 52325        serviceRootPath = string.Empty;
 52326        error = string.Empty;
 2327
 52328        var serviceDirectoryName = GetServiceDeploymentDirectoryName(serviceName);
 52329        var candidateRoots = new List<string>();
 52330        if (!string.IsNullOrWhiteSpace(deploymentRootOverride))
 2331        {
 42332            candidateRoots.Add(deploymentRootOverride);
 2333        }
 2334
 52335        candidateRoots.AddRange(GetServiceDeploymentRootCandidates());
 2336
 272337        foreach (var root in candidateRoots.Distinct(StringComparer.OrdinalIgnoreCase))
 2338        {
 102339            if (string.IsNullOrWhiteSpace(root))
 2340            {
 2341                continue;
 2342            }
 2343
 102344            var serviceBaseRoot = Path.Combine(root, serviceDirectoryName);
 102345            if (!Directory.Exists(serviceBaseRoot))
 2346            {
 2347                continue;
 2348            }
 2349
 32350            var directDescriptorPath = Path.Combine(serviceBaseRoot, ServiceBundleScriptDirectoryName, ServiceDescriptor
 32351            if (File.Exists(directDescriptorPath))
 2352            {
 32353                serviceRootPath = Path.GetFullPath(serviceBaseRoot);
 32354                return true;
 2355            }
 2356        }
 2357
 22358        error = $"Installed service bundle not found for '{serviceName}'.";
 22359        return false;
 32360    }
 2361
 2362    /// <summary>
 2363    /// Starts a Windows service using sc.exe.
 2364    /// </summary>
 2365    /// <param name="serviceName">Service name.</param>
 2366    /// <returns>Process exit code.</returns>
 2367    private static ServiceControlResult StartWindowsService(string serviceName, string? configuredLogPath, bool rawOutpu
 2368    {
 02369        var result = RunProcess("sc.exe", ["start", serviceName], writeStandardOutput: false);
 02370        if (result.ExitCode != 0)
 2371        {
 02372            WriteServiceOperationLog(
 02373                $"operation='start' service='{serviceName}' platform='windows' result='failed' exitCode={result.ExitCode
 02374                configuredLogPath,
 02375                serviceName);
 02376            return new ServiceControlResult("start", serviceName, "windows", "unknown", null, result.ExitCode, "Failed t
 2377        }
 2378
 02379        WriteServiceOperationLog(
 02380            $"operation='start' service='{serviceName}' platform='windows' result='success' exitCode=0",
 02381            configuredLogPath,
 02382            serviceName);
 02383        return new ServiceControlResult("start", serviceName, "windows", "running", null, 0, "Service started.", result.
 2384    }
 2385
 2386    /// <summary>
 2387    /// Stops a Windows service using sc.exe.
 2388    /// </summary>
 2389    /// <param name="serviceName">Service name.</param>
 2390    /// <returns>Process exit code.</returns>
 2391    private static ServiceControlResult StopWindowsService(string serviceName, string? configuredLogPath, bool rawOutput
 2392    {
 02393        var result = RunProcess("sc.exe", ["stop", serviceName], writeStandardOutput: false);
 02394        if (result.ExitCode != 0)
 2395        {
 02396            if (IsWindowsServiceAlreadyStopped(result))
 2397            {
 02398                WriteServiceOperationLog(
 02399                    $"operation='stop' service='{serviceName}' platform='windows' result='success' exitCode=0 note='alre
 02400                    configuredLogPath,
 02401                    serviceName);
 02402                return new ServiceControlResult("stop", serviceName, "windows", "stopped", null, 0, "Service is already 
 2403            }
 2404
 02405            WriteServiceOperationLog(
 02406                $"operation='stop' service='{serviceName}' platform='windows' result='failed' exitCode={result.ExitCode}
 02407                configuredLogPath,
 02408                serviceName);
 02409            return new ServiceControlResult("stop", serviceName, "windows", "unknown", null, result.ExitCode, "Failed to
 2410        }
 2411
 02412        WriteServiceOperationLog(
 02413            $"operation='stop' service='{serviceName}' platform='windows' result='success' exitCode=0",
 02414            configuredLogPath,
 02415            serviceName);
 02416        return new ServiceControlResult("stop", serviceName, "windows", "stopped", null, 0, "Service stopped.", result.O
 2417    }
 2418
 2419    /// <summary>
 2420    /// Returns true when SCM stop command indicates the service was not running.
 2421    /// </summary>
 2422    /// <param name="result">SCM command result.</param>
 2423    /// <returns>True when SCM returned service-not-started semantics.</returns>
 2424    private static bool IsWindowsServiceAlreadyStopped(ProcessResult result)
 2425    {
 02426        var text = $"{result.Output}\n{result.Error}";
 02427        return text.Contains("1062", StringComparison.OrdinalIgnoreCase)
 02428            || text.Contains("has not been started", StringComparison.OrdinalIgnoreCase)
 02429            || text.Contains("not started", StringComparison.OrdinalIgnoreCase);
 2430    }
 2431
 2432    /// <summary>
 2433    /// Queries a Windows service using sc.exe.
 2434    /// </summary>
 2435    /// <param name="serviceName">Service name.</param>
 2436    /// <returns>Process exit code.</returns>
 2437    private static ServiceControlResult QueryWindowsService(string serviceName, string? configuredLogPath, bool rawOutpu
 2438    {
 02439        var result = RunProcess("sc.exe", ["queryex", serviceName], writeStandardOutput: false);
 02440        if (result.ExitCode != 0)
 2441        {
 02442            WriteServiceOperationLog(
 02443                $"operation='query' service='{serviceName}' platform='windows' result='failed' exitCode={result.ExitCode
 02444                configuredLogPath,
 02445                serviceName);
 02446            return new ServiceControlResult("query", serviceName, "windows", "unknown", null, result.ExitCode, "Failed t
 2447        }
 2448
 02449        var stateLine = result.Output
 02450            .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 02451            .FirstOrDefault(static line => line.Contains("STATE", StringComparison.OrdinalIgnoreCase)) ?? "STATE: unknow
 02452        var state = stateLine.Contains("RUNNING", StringComparison.OrdinalIgnoreCase)
 02453            ? "running"
 02454            : stateLine.Contains("STOPPED", StringComparison.OrdinalIgnoreCase)
 02455                ? "stopped"
 02456                : "unknown";
 02457        var pid = TryExtractWindowsServicePid(result.Output);
 2458
 02459        WriteServiceOperationLog(
 02460            $"operation='query' service='{serviceName}' platform='windows' result='success' exitCode=0 state='{stateLine
 02461            configuredLogPath,
 02462            serviceName);
 2463
 02464        return new ServiceControlResult("query", serviceName, "windows", state, pid, 0, stateLine, result.Output, result
 2465    }
 2466
 2467    /// <summary>
 2468    /// Installs a systemd unit (user scope by default; system scope when serviceUser is provided).
 2469    /// </summary>
 2470    /// <param name="serviceName">Unit base name.</param>
 2471    /// <param name="exePath">Executable path.</param>
 2472    /// <param name="runnerArgs">Runner arguments.</param>
 2473    /// <param name="workingDirectory">Working directory for the unit.</param>
 2474    /// <param name="serviceUser">Optional service account for system scope.</param>
 2475    /// <returns>Process exit code.</returns>
 2476    private static int InstallLinuxUserDaemon(string serviceName, string exePath, IReadOnlyList<string> runnerArgs, stri
 2477    {
 12478        var useSystemScope = !string.IsNullOrWhiteSpace(serviceUser);
 2479
 12480        if (useSystemScope && !IsLikelyRunningAsRootOnLinux())
 2481        {
 02482            Console.Error.WriteLine("Linux system service install with --service-user requires root privileges.");
 02483            return 1;
 2484        }
 2485
 12486        if (!useSystemScope && IsLikelyRunningAsRootOnLinux())
 2487        {
 02488            Console.Error.WriteLine("Warning: Running as root installs a root user-level unit via systemctl --user.");
 02489            Console.Error.WriteLine("That unit is managed from root's user session and is separate from your regular use
 2490        }
 2491
 12492        var unitDirectory = useSystemScope
 12493            ? "/etc/systemd/system"
 12494            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "systemd", "user
 12495        _ = Directory.CreateDirectory(unitDirectory);
 2496
 12497        var unitName = GetLinuxUnitName(serviceName);
 12498        var unitPath = Path.Combine(unitDirectory, unitName);
 12499        var unitContent = BuildLinuxSystemdUnitContent(serviceName, exePath, runnerArgs, workingDirectory, serviceUser);
 2500
 12501        File.WriteAllText(unitPath, unitContent);
 2502
 12503        var reloadResult = RunLinuxSystemctl(useSystemScope, ["daemon-reload"]);
 12504        if (reloadResult.ExitCode != 0)
 2505        {
 02506            Console.Error.WriteLine(reloadResult.Error);
 02507            if (!useSystemScope)
 2508            {
 02509                WriteLinuxUserSystemdFailureHint(reloadResult);
 2510            }
 2511
 02512            return reloadResult.ExitCode;
 2513        }
 2514
 12515        var enableResult = RunLinuxSystemctl(useSystemScope, ["enable", unitName]);
 12516        if (enableResult.ExitCode != 0)
 2517        {
 02518            Console.Error.WriteLine(enableResult.Error);
 02519            if (!useSystemScope)
 2520            {
 02521                WriteLinuxUserSystemdFailureHint(enableResult);
 2522            }
 2523
 02524            return enableResult.ExitCode;
 2525        }
 2526
 12527        Console.WriteLine(useSystemScope
 12528            ? $"Installed Linux system daemon '{unitName}' for user '{serviceUser}' (not started)."
 12529            : $"Installed Linux user daemon '{unitName}' (not started).");
 12530        return 0;
 2531    }
 2532
 2533    /// <summary>
 2534    /// Builds Linux systemd unit file content for a service install.
 2535    /// </summary>
 2536    /// <param name="serviceName">Service name used for Description.</param>
 2537    /// <param name="exePath">Executable path for the runner.</param>
 2538    /// <param name="runnerArgs">Arguments passed to the runner executable.</param>
 2539    /// <param name="workingDirectory">Working directory for the systemd unit.</param>
 2540    /// <param name="serviceUser">Optional Linux user account for system-scoped units.</param>
 2541    /// <returns>Rendered unit file content.</returns>
 2542    private static string BuildLinuxSystemdUnitContent(string serviceName, string exePath, IReadOnlyList<string> runnerA
 2543    {
 32544        var useSystemScope = !string.IsNullOrWhiteSpace(serviceUser);
 32545        var execStart = string.Join(" ", new[] { EscapeSystemdToken(exePath) }.Concat(runnerArgs.Select(EscapeSystemdTok
 2546
 32547        return string.Join('\n',
 32548            "[Unit]",
 32549            $"Description={serviceName}",
 32550            "After=network.target",
 32551            "",
 32552            "[Service]",
 32553            "Type=simple",
 32554            useSystemScope ? $"User={serviceUser}" : string.Empty,
 32555            $"WorkingDirectory={workingDirectory}",
 32556            $"ExecStart={execStart}",
 32557            "Restart=always",
 32558            "RestartSec=2",
 32559            "",
 32560            "[Install]",
 32561            useSystemScope ? "WantedBy=multi-user.target" : "WantedBy=default.target",
 32562            "");
 2563    }
 2564
 2565    /// <summary>
 2566    /// Removes a user-level systemd unit.
 2567    /// </summary>
 2568    /// <param name="serviceName">Unit base name.</param>
 2569    /// <returns>Process exit code.</returns>
 2570    private static int RemoveLinuxUserDaemon(string serviceName)
 2571    {
 02572        var useSystemScope = IsLinuxSystemUnitInstalled(serviceName);
 02573        var unitDirectory = useSystemScope
 02574            ? "/etc/systemd/system"
 02575            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "systemd", "user
 02576        var unitName = GetLinuxUnitName(serviceName);
 02577        var unitPath = Path.Combine(unitDirectory, unitName);
 2578
 02579        _ = RunLinuxSystemctl(useSystemScope, ["disable", "--now", unitName]);
 02580        if (File.Exists(unitPath))
 2581        {
 02582            File.Delete(unitPath);
 2583        }
 2584
 02585        var reloadResult = RunLinuxSystemctl(useSystemScope, ["daemon-reload"]);
 02586        if (reloadResult.ExitCode != 0)
 2587        {
 02588            Console.Error.WriteLine(reloadResult.Error);
 02589            if (!useSystemScope)
 2590            {
 02591                WriteLinuxUserSystemdFailureHint(reloadResult);
 2592            }
 2593
 02594            return reloadResult.ExitCode;
 2595        }
 2596
 02597        Console.WriteLine(useSystemScope
 02598            ? $"Removed Linux system daemon '{unitName}'."
 02599            : $"Removed Linux user daemon '{unitName}'.");
 02600        return 0;
 2601    }
 2602
 2603    /// <summary>
 2604    /// Starts a Linux user-level systemd unit.
 2605    /// </summary>
 2606    /// <param name="serviceName">Unit base name.</param>
 2607    /// <returns>Process exit code.</returns>
 2608    private static ServiceControlResult StartLinuxUserDaemon(string serviceName, string? configuredLogPath, bool rawOutp
 2609    {
 22610        var useSystemScope = IsLinuxSystemUnitInstalled(serviceName);
 22611        var unitName = GetLinuxUnitName(serviceName);
 22612        var result = RunLinuxSystemctl(useSystemScope, ["start", unitName], writeStandardOutput: false);
 22613        if (result.ExitCode != 0)
 2614        {
 22615            WriteServiceOperationLog(
 22616                $"operation='start' service='{serviceName}' platform='linux' result='failed' exitCode={result.ExitCode} 
 22617                configuredLogPath,
 22618                serviceName);
 22619            return new ServiceControlResult("start", serviceName, "linux", "unknown", null, result.ExitCode, "Failed to 
 2620        }
 2621
 02622        WriteServiceOperationResult("start", "linux", serviceName, 0, configuredLogPath);
 02623        return new ServiceControlResult("start", serviceName, "linux", "running", null, 0, "Service started.", result.Ou
 2624    }
 2625
 2626    /// <summary>
 2627    /// Stops a Linux user-level systemd unit.
 2628    /// </summary>
 2629    /// <param name="serviceName">Unit base name.</param>
 2630    /// <returns>Process exit code.</returns>
 2631    private static ServiceControlResult StopLinuxUserDaemon(string serviceName, string? configuredLogPath, bool rawOutpu
 2632    {
 22633        var unitName = GetLinuxUnitName(serviceName);
 22634        if (!TryGetInstalledLinuxUnitScope(serviceName, out var useSystemScope))
 2635        {
 2636            const int missingServiceExitCode = 2;
 12637            var message = $"Service unit '{unitName}' was not found.";
 12638            WriteServiceOperationLog(
 12639                $"operation='stop' service='{serviceName}' platform='linux' result='failed' exitCode={missingServiceExit
 12640                configuredLogPath,
 12641                serviceName);
 12642            return new ServiceControlResult("stop", serviceName, "linux", "unknown", null, missingServiceExitCode, messa
 2643        }
 2644
 12645        var result = RunLinuxSystemctl(useSystemScope, ["stop", unitName], writeStandardOutput: false);
 12646        if (result.ExitCode != 0)
 2647        {
 12648            if (IsLinuxServiceAlreadyStopped(result))
 2649            {
 12650                WriteServiceOperationLog(
 12651                    $"operation='stop' service='{serviceName}' platform='linux' result='success' exitCode=0 note='alread
 12652                    configuredLogPath,
 12653                    serviceName);
 12654                return new ServiceControlResult("stop", serviceName, "linux", "stopped", null, 0, "Service is already st
 2655            }
 2656
 02657            WriteServiceOperationLog(
 02658                $"operation='stop' service='{serviceName}' platform='linux' result='failed' exitCode={result.ExitCode} e
 02659                configuredLogPath,
 02660                serviceName);
 02661            return new ServiceControlResult("stop", serviceName, "linux", "unknown", null, result.ExitCode, "Failed to s
 2662        }
 2663
 02664        WriteServiceOperationResult("stop", "linux", serviceName, 0, configuredLogPath);
 02665        return new ServiceControlResult("stop", serviceName, "linux", "stopped", null, 0, "Service stopped.", result.Out
 2666    }
 2667
 2668    /// <summary>
 2669    /// Returns true when systemctl stop indicates the unit is already inactive or absent.
 2670    /// </summary>
 2671    /// <param name="result">Systemctl command result.</param>
 2672    /// <returns>True when stop semantics indicate no-op success.</returns>
 2673    private static bool IsLinuxServiceAlreadyStopped(ProcessResult result)
 2674    {
 32675        var text = $"{result.Output}\n{result.Error}";
 32676        return text.Contains("not loaded", StringComparison.OrdinalIgnoreCase)
 32677            || text.Contains("inactive", StringComparison.OrdinalIgnoreCase)
 32678            || text.Contains("not running", StringComparison.OrdinalIgnoreCase)
 32679            || text.Contains("could not be found", StringComparison.OrdinalIgnoreCase);
 2680    }
 2681
 2682    /// <summary>
 2683    /// Queries a Linux user-level systemd unit.
 2684    /// </summary>
 2685    /// <param name="serviceName">Unit base name.</param>
 2686    /// <returns>Process exit code.</returns>
 2687    private static ServiceControlResult QueryLinuxUserDaemon(string serviceName, string? configuredLogPath, bool rawOutp
 2688    {
 22689        var useSystemScope = IsLinuxSystemUnitInstalled(serviceName);
 22690        var unitName = GetLinuxUnitName(serviceName);
 22691        var queryArgs = rawOutput ? (IReadOnlyList<string>)["status", unitName] : ["is-active", unitName];
 22692        var result = RunLinuxSystemctl(useSystemScope, queryArgs, writeStandardOutput: false);
 22693        if (result.ExitCode != 0)
 2694        {
 22695            WriteServiceOperationLog(
 22696                $"operation='query' service='{serviceName}' platform='linux' result='failed' exitCode={result.ExitCode} 
 22697                configuredLogPath,
 22698                serviceName);
 22699            return new ServiceControlResult("query", serviceName, "linux", "unknown", null, result.ExitCode, "Failed to 
 2700        }
 2701
 02702        var normalizedOutput = result.Output.Trim();
 02703        var state = normalizedOutput.StartsWith("active", StringComparison.OrdinalIgnoreCase) ? "running" : "unknown";
 02704        var pid = TryQueryLinuxServicePid(useSystemScope, unitName);
 02705        WriteServiceOperationResult("query", "linux", serviceName, 0, configuredLogPath);
 02706        return new ServiceControlResult("query", serviceName, "linux", state, pid, 0, string.IsNullOrWhiteSpace(normaliz
 2707    }
 2708
 2709    /// <summary>
 2710    /// Runs systemctl in user or system scope.
 2711    /// </summary>
 2712    /// <param name="useSystemScope">True for system scope; false for user scope.</param>
 2713    /// <param name="arguments">Arguments after optional scope switch.</param>
 2714    /// <returns>Process execution result.</returns>
 2715    private static ProcessResult RunLinuxSystemctl(bool useSystemScope, IReadOnlyList<string> arguments, bool writeStand
 2716    {
 82717        return useSystemScope
 82718            ? RunProcess("systemctl", arguments, writeStandardOutput)
 82719            : RunProcess("systemctl", ["--user", .. arguments], writeStandardOutput);
 2720    }
 2721
 2722    /// <summary>
 2723    /// Returns true when a system-scoped unit file exists for the service.
 2724    /// </summary>
 2725    /// <param name="serviceName">Service name.</param>
 2726    /// <returns>True when a system unit exists under /etc/systemd/system.</returns>
 2727    private static bool IsLinuxSystemUnitInstalled(string serviceName)
 2728    {
 92729        var unitName = GetLinuxUnitName(serviceName);
 92730        var systemUnitPath = Path.Combine("/etc/systemd/system", unitName);
 92731        return File.Exists(systemUnitPath);
 2732    }
 2733
 2734    /// <summary>
 2735    /// Resolves whether a Linux service is installed as a system or user unit.
 2736    /// </summary>
 2737    /// <param name="serviceName">Service name.</param>
 2738    /// <param name="useSystemScope">True when installed as a system unit; false for user unit.</param>
 2739    /// <returns>True when either system or user unit file exists.</returns>
 2740    private static bool TryGetInstalledLinuxUnitScope(string serviceName, out bool useSystemScope)
 2741    {
 42742        if (IsLinuxSystemUnitInstalled(serviceName))
 2743        {
 02744            useSystemScope = true;
 02745            return true;
 2746        }
 2747
 42748        var unitName = GetLinuxUnitName(serviceName);
 42749        var userUnitPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".config", "sy
 42750        useSystemScope = false;
 42751        return File.Exists(userUnitPath);
 2752    }
 2753
 2754    /// <summary>
 2755    /// Installs a macOS launch agent plist (or launch daemon when a service user is specified).
 2756    /// </summary>
 2757    /// <param name="serviceName">Agent label.</param>
 2758    /// <param name="exePath">Executable path.</param>
 2759    /// <param name="runnerArgs">Runner arguments.</param>
 2760    /// <param name="workingDirectory">Working directory for launchd.</param>
 2761    /// <param name="serviceUser">Optional service account for system daemon scope.</param>
 2762    /// <returns>Process exit code.</returns>
 2763    private static int InstallMacLaunchAgent(string serviceName, string exePath, IReadOnlyList<string> runnerArgs, strin
 2764    {
 02765        var useSystemScope = !string.IsNullOrWhiteSpace(serviceUser);
 02766        if (useSystemScope && !IsLikelyRunningAsRootOnUnix())
 2767        {
 02768            Console.Error.WriteLine("macOS system daemon install with --service-user requires root privileges.");
 02769            return 1;
 2770        }
 2771
 02772        var agentDirectory = useSystemScope
 02773            ? "/Library/LaunchDaemons"
 02774            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents");
 02775        _ = Directory.CreateDirectory(agentDirectory);
 2776
 02777        var plistName = $"{serviceName}.plist";
 02778        var plistPath = Path.Combine(agentDirectory, plistName);
 02779        var programArgs = new[] { exePath }.Concat(runnerArgs).ToArray();
 02780        var plistContent = BuildLaunchdPlist(serviceName, workingDirectory, programArgs, serviceUser);
 02781        File.WriteAllText(plistPath, plistContent);
 2782
 02783        Console.WriteLine(useSystemScope
 02784            ? $"Installed macOS launch daemon '{serviceName}' for user '{serviceUser}' (not started)."
 02785            : $"Installed macOS launch agent '{serviceName}' (not started).");
 02786        return 0;
 2787    }
 2788
 2789    /// <summary>
 2790    /// Removes a macOS launch agent plist.
 2791    /// </summary>
 2792    /// <param name="serviceName">Agent label.</param>
 2793    /// <returns>Process exit code.</returns>
 2794    private static int RemoveMacLaunchAgent(string serviceName)
 2795    {
 12796        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 12797        var agentDirectory = useSystemScope
 12798            ? "/Library/LaunchDaemons"
 12799            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents");
 12800        var plistPath = Path.Combine(agentDirectory, $"{serviceName}.plist");
 2801
 2802        // Unload the agent before deleting the plist to ensure launchd doesn't keep a stale reference to the file.
 12803        _ = useSystemScope
 12804            ? RunProcess("launchctl", ["bootout", $"system/{serviceName}"])
 12805            : RunProcess("launchctl", ["unload", plistPath]);
 2806
 2807        // It's possible for the unload to fail if the agent isn't running, but we want to attempt it anyway to avoid le
 02808        if (File.Exists(plistPath))
 2809        {
 02810            File.Delete(plistPath);
 2811        }
 2812
 02813        Console.WriteLine(useSystemScope
 02814            ? $"Removed macOS launch daemon '{serviceName}'."
 02815            : $"Removed macOS launch agent '{serviceName}'.");
 02816        return 0;
 2817    }
 2818
 2819    /// <summary>
 2820    /// Starts a macOS launch agent by loading its plist.
 2821    /// </summary>
 2822    /// <param name="serviceName">Agent label.</param>
 2823    /// <returns>Process exit code.</returns>
 2824    private static ServiceControlResult StartMacLaunchAgent(string serviceName, string? configuredLogPath, bool rawOutpu
 2825    {
 12826        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 12827        var agentDirectory = useSystemScope
 12828            ? "/Library/LaunchDaemons"
 12829            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents");
 12830        var plistPath = Path.Combine(agentDirectory, $"{serviceName}.plist");
 12831        if (!File.Exists(plistPath))
 2832        {
 12833            return new ServiceControlResult("start", serviceName, "macos", "unknown", null, 2, $"Launch agent plist not 
 2834        }
 2835
 02836        var result = useSystemScope
 02837            ? RunProcess("launchctl", ["bootstrap", "system", plistPath], writeStandardOutput: false)
 02838            : RunProcess("launchctl", ["load", "-w", plistPath], writeStandardOutput: false);
 02839        if (result.ExitCode != 0)
 2840        {
 02841            WriteServiceOperationLog(
 02842                $"operation='start' service='{serviceName}' platform='macos' result='failed' exitCode={result.ExitCode} 
 02843                configuredLogPath,
 02844                serviceName);
 02845            return new ServiceControlResult("start", serviceName, "macos", "unknown", null, result.ExitCode, "Failed to 
 2846        }
 2847
 02848        WriteServiceOperationResult("start", "macos", serviceName, 0, configuredLogPath);
 02849        return new ServiceControlResult("start", serviceName, "macos", "running", null, 0, "Service started.", result.Ou
 2850    }
 2851
 2852    /// <summary>
 2853    /// Stops a macOS launch agent by unloading its plist.
 2854    /// </summary>
 2855    /// <param name="serviceName">Agent label.</param>
 2856    /// <returns>Process exit code.</returns>
 2857    private static ServiceControlResult StopMacLaunchAgent(string serviceName, string? configuredLogPath, bool rawOutput
 2858    {
 12859        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 12860        var agentDirectory = useSystemScope
 12861            ? "/Library/LaunchDaemons"
 12862            : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Library", "LaunchAgents");
 12863        var plistPath = Path.Combine(agentDirectory, $"{serviceName}.plist");
 12864        if (!File.Exists(plistPath))
 2865        {
 12866            return new ServiceControlResult("stop", serviceName, "macos", "unknown", null, 2, $"Launch agent plist not f
 2867        }
 2868
 02869        var result = useSystemScope
 02870            ? RunProcess("launchctl", ["bootout", $"system/{serviceName}"], writeStandardOutput: false)
 02871            : RunProcess("launchctl", ["unload", plistPath], writeStandardOutput: false);
 02872        if (result.ExitCode != 0)
 2873        {
 02874            if (IsMacServiceAlreadyStopped(result))
 2875            {
 02876                WriteServiceOperationLog(
 02877                    $"operation='stop' service='{serviceName}' platform='macos' result='success' exitCode=0 note='alread
 02878                    configuredLogPath,
 02879                    serviceName);
 02880                return new ServiceControlResult("stop", serviceName, "macos", "stopped", null, 0, "Service is already st
 2881            }
 2882
 02883            WriteServiceOperationLog(
 02884                $"operation='stop' service='{serviceName}' platform='macos' result='failed' exitCode={result.ExitCode} e
 02885                configuredLogPath,
 02886                serviceName);
 02887            return new ServiceControlResult("stop", serviceName, "macos", "unknown", null, result.ExitCode, "Failed to s
 2888        }
 2889
 02890        WriteServiceOperationResult("stop", "macos", serviceName, 0, configuredLogPath);
 02891        return new ServiceControlResult("stop", serviceName, "macos", "stopped", null, 0, "Service stopped.", result.Out
 2892    }
 2893
 2894    /// <summary>
 2895    /// Returns true when launchctl stop semantics indicate service is not currently running.
 2896    /// </summary>
 2897    /// <param name="result">Launchctl command result.</param>
 2898    /// <returns>True when stop is effectively a no-op success.</returns>
 2899    private static bool IsMacServiceAlreadyStopped(ProcessResult result)
 2900    {
 02901        var text = $"{result.Output}\n{result.Error}";
 02902        return text.Contains("Could not find specified service", StringComparison.OrdinalIgnoreCase)
 02903            || text.Contains("No such process", StringComparison.OrdinalIgnoreCase)
 02904            || text.Contains("not loaded", StringComparison.OrdinalIgnoreCase)
 02905            || text.Contains("service is not loaded", StringComparison.OrdinalIgnoreCase);
 2906    }
 2907
 2908    /// <summary>
 2909    /// Queries a macOS launch agent by label.
 2910    /// </summary>
 2911    /// <param name="serviceName">Agent label.</param>
 2912    /// <returns>Process exit code.</returns>
 2913    private static ServiceControlResult QueryMacLaunchAgent(string serviceName, string? configuredLogPath, bool rawOutpu
 2914    {
 12915        var useSystemScope = IsMacSystemLaunchDaemonInstalled(serviceName);
 12916        var result = useSystemScope
 12917            ? RunProcess("launchctl", ["print", $"system/{serviceName}"], writeStandardOutput: false)
 12918            : RunProcess("launchctl", ["list", serviceName], writeStandardOutput: false);
 02919        if (result.ExitCode != 0)
 2920        {
 02921            WriteServiceOperationLog(
 02922                $"operation='query' service='{serviceName}' platform='macos' result='failed' exitCode={result.ExitCode} 
 02923                configuredLogPath,
 02924                serviceName);
 02925            return new ServiceControlResult("query", serviceName, "macos", "unknown", null, result.ExitCode, "Failed to 
 2926        }
 2927
 02928        var state = result.Output.Contains("\"PID\" =", StringComparison.OrdinalIgnoreCase)
 02929            || result.Output.Contains("pid =", StringComparison.OrdinalIgnoreCase)
 02930            ? "running"
 02931            : "loaded";
 02932        var pid = TryExtractMacServicePid(result.Output);
 2933
 02934        WriteServiceOperationResult("query", "macos", serviceName, 0, configuredLogPath);
 02935        return new ServiceControlResult("query", serviceName, "macos", state, pid, 0, "Service queried.", result.Output,
 2936    }
 2937
 2938    /// <summary>
 2939    /// Extracts a Windows service PID from sc.exe queryex output.
 2940    /// </summary>
 2941    /// <param name="output">Raw command output.</param>
 2942    /// <returns>Parsed PID when available.</returns>
 2943    private static int? TryExtractWindowsServicePid(string output)
 2944    {
 12945        var pidLine = output
 12946            .Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
 32947            .FirstOrDefault(static line => line.Contains("PID", StringComparison.OrdinalIgnoreCase));
 2948
 12949        if (string.IsNullOrWhiteSpace(pidLine))
 2950        {
 02951            return null;
 2952        }
 2953
 12954        var parts = pidLine.Split(':', 2, StringSplitOptions.TrimEntries);
 12955        return parts.Length == 2 && int.TryParse(parts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var p
 12956            ? pid
 12957            : null;
 2958    }
 2959
 2960    /// <summary>
 2961    /// Queries Linux MainPID for a systemd unit.
 2962    /// </summary>
 2963    /// <param name="useSystemScope">True for system scope; false for user scope.</param>
 2964    /// <param name="unitName">Systemd unit name.</param>
 2965    /// <returns>Main PID when available.</returns>
 2966    private static int? TryQueryLinuxServicePid(bool useSystemScope, string unitName)
 2967    {
 02968        var pidResult = RunLinuxSystemctl(useSystemScope, ["show", "-p", "MainPID", "--value", unitName], writeStandardO
 02969        if (pidResult.ExitCode != 0)
 2970        {
 02971            return null;
 2972        }
 2973
 02974        var text = pidResult.Output.Trim();
 02975        return int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) && pid > 0
 02976            ? pid
 02977            : null;
 2978    }
 2979
 2980    /// <summary>
 2981    /// Extracts a macOS launchd PID from launchctl output.
 2982    /// </summary>
 2983    /// <param name="output">Raw command output.</param>
 2984    /// <returns>Parsed PID when available.</returns>
 2985    private static int? TryExtractMacServicePid(string output)
 2986    {
 22987        var lines = output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
 72988        foreach (var line in lines)
 2989        {
 22990            if (line.Contains("pid =", StringComparison.OrdinalIgnoreCase))
 2991            {
 02992                var value = line[(line.IndexOf('=') + 1)..].Trim().TrimEnd(';');
 02993                if (int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var pid) && pid > 0)
 2994                {
 02995                    return pid;
 2996                }
 2997            }
 2998
 22999            var tokens = line.Split(['\t', ' '], StringSplitOptions.RemoveEmptyEntries);
 23000            if (tokens.Length > 0 && int.TryParse(tokens[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var
 3001            {
 13002                return listPid;
 3003            }
 3004        }
 3005
 13006        return null;
 3007    }
 3008
 3009    /// <summary>
 3010    /// Returns true when a system-scoped launch daemon plist exists for the service.
 3011    /// </summary>
 3012    /// <param name="serviceName">Service label.</param>
 3013    /// <returns>True when plist exists under /Library/LaunchDaemons.</returns>
 3014    private static bool IsMacSystemLaunchDaemonInstalled(string serviceName)
 3015    {
 43016        var plistPath = Path.Combine("/Library/LaunchDaemons", $"{serviceName}.plist");
 43017        return File.Exists(plistPath);
 3018    }
 3019
 3020    /// <summary>
 3021    /// Builds a launchd plist document for a persistent launch agent/daemon.
 3022    /// </summary>
 3023    /// <param name="label">Launchd label.</param>
 3024    /// <param name="workingDirectory">Working directory.</param>
 3025    /// <param name="programArguments">Program argument list.</param>
 3026    /// <param name="serviceUser">Optional macOS account name for LaunchDaemon UserName.</param>
 3027    /// <returns>XML plist content.</returns>
 3028    private static string BuildLaunchdPlist(string label, string workingDirectory, IReadOnlyList<string> programArgument
 3029    {
 83030        var argsXml = string.Join(string.Empty, programArguments.Select(arg => $"\n    <string>{EscapeXml(arg)}</string>
 23031        var userXml = string.IsNullOrWhiteSpace(serviceUser)
 23032            ? string.Empty
 23033            : $"\n  <key>UserName</key>\n  <string>{EscapeXml(serviceUser)}</string>";
 23034        return $"""
 23035<?xml version="1.0" encoding="UTF-8"?>
 23036<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 23037<plist version="1.0">
 23038<dict>
 23039  <key>Label</key>
 23040  <string>{EscapeXml(label)}</string>
 23041  <key>ProgramArguments</key>
 23042  <array>{argsXml}
 23043  </array>
 23044  <key>WorkingDirectory</key>
 23045    <string>{EscapeXml(workingDirectory)}</string>{userXml}
 23046  <key>RunAtLoad</key>
 23047  <true/>
 23048  <key>KeepAlive</key>
 23049  <true/>
 23050</dict>
 23051</plist>
 23052""";
 3053    }
 3054
 3055    /// <summary>
 3056    /// Creates a per-service deployment bundle with runtime binary, module files, and the script entrypoint.
 3057    /// </summary>
 3058    /// <param name="serviceName">Service name.</param>
 3059    /// <param name="sourceScriptPath">Source script path.</param>
 3060    /// <param name="sourceModuleManifestPath">Source module manifest path.</param>
 3061    /// <param name="serviceVersion">Optional service version from descriptor metadata.</param>
 3062    /// <param name="serviceBundle">Created service bundle paths.</param>
 3063    /// <param name="error">Error details when bundling fails.</param>
 3064    /// <param name="deploymentRootOverride">Optional deployment root override for tests.</param>
 3065    /// <returns>True when service bundle creation succeeds.</returns>
 3066    private static bool TryPrepareServiceBundle(
 3067        string serviceName,
 3068        string sourceScriptPath,
 3069        string sourceModuleManifestPath,
 3070        string? sourceContentRoot,
 3071        string relativeScriptPath,
 3072        out ServiceBundleLayout? serviceBundle,
 3073        out string error,
 3074        string? deploymentRootOverride = null,
 3075        string? serviceVersion = null)
 3076    {
 33077        serviceBundle = null;
 33078        error = string.Empty;
 3079
 33080        if (!TryResolveServiceBundleContext(
 33081                serviceName,
 33082                sourceScriptPath,
 33083                sourceModuleManifestPath,
 33084                deploymentRootOverride,
 33085                serviceVersion,
 33086                out var context,
 33087                out error))
 3088        {
 03089            return false;
 3090        }
 3091
 33092        var showProgress = !Console.IsOutputRedirected;
 33093        using var bundleProgress = showProgress
 33094            ? new ConsoleProgressBar("Preparing service bundle", 5, FormatServiceBundleStepProgressDetail)
 33095            : null;
 33096        var completedBundleSteps = 0;
 33097        bundleProgress?.Report(0);
 3098
 3099        try
 3100        {
 33101            RecreateServiceBundleDirectories(context);
 33102            completedBundleSteps++;
 33103            bundleProgress?.Report(completedBundleSteps);
 3104
 33105            var bundledRuntimePath = CopyServiceRuntimeExecutable(context);
 33106            completedBundleSteps++;
 33107            bundleProgress?.Report(completedBundleSteps);
 3108
 33109            if (!TryCopyServiceHostExecutable(context.RuntimeDirectory, out var bundledServiceHostPath, out error))
 3110            {
 03111                return false;
 3112            }
 3113
 33114            if (!TryCopyBundledToolModules(context.ModulesDirectory, showProgress, out error))
 3115            {
 03116                return false;
 3117            }
 3118
 33119            completedBundleSteps++;
 33120            bundleProgress?.Report(completedBundleSteps);
 3121
 33122            EnsureBundleExecutablesAreRunnable(bundledRuntimePath, bundledServiceHostPath);
 3123
 33124            if (!TryCopyServiceModuleFiles(context, showProgress, out var bundledManifestPath, out error))
 3125            {
 03126                return false;
 3127            }
 3128
 33129            completedBundleSteps++;
 33130            bundleProgress?.Report(completedBundleSteps);
 3131
 33132            if (!TryCopyServiceScriptFiles(context, sourceContentRoot, relativeScriptPath, showProgress, out var bundled
 3133            {
 03134                return false;
 3135            }
 3136
 33137            completedBundleSteps++;
 33138            bundleProgress?.Report(completedBundleSteps);
 33139            bundleProgress?.Complete(completedBundleSteps);
 3140
 33141            serviceBundle = new ServiceBundleLayout(
 33142                Path.GetFullPath(context.ServiceRoot),
 33143                Path.GetFullPath(bundledRuntimePath),
 33144                Path.GetFullPath(bundledServiceHostPath),
 33145                Path.GetFullPath(bundledScriptPath),
 33146                Path.GetFullPath(bundledManifestPath));
 33147            return true;
 3148        }
 03149        catch (Exception ex)
 3150        {
 03151            error = $"Failed to prepare service bundle at '{context.ServiceRoot}': {ex.Message}";
 03152            return false;
 3153        }
 33154    }
 3155
 3156    /// <summary>
 3157    /// Resolves and validates all source and destination paths required to build a service bundle.
 3158    /// </summary>
 3159    /// <param name="serviceName">Service name.</param>
 3160    /// <param name="sourceScriptPath">Source script path.</param>
 3161    /// <param name="sourceModuleManifestPath">Source module manifest path.</param>
 3162    /// <param name="deploymentRootOverride">Optional deployment root override for tests.</param>
 3163    /// <param name="serviceVersion">Optional service version from descriptor metadata.</param>
 3164    /// <param name="context">Resolved bundle path context.</param>
 3165    /// <param name="error">Error details when resolution fails.</param>
 3166    /// <returns>True when context resolution succeeds.</returns>
 3167    private static bool TryResolveServiceBundleContext(
 3168        string serviceName,
 3169        string sourceScriptPath,
 3170        string sourceModuleManifestPath,
 3171        string? deploymentRootOverride,
 3172        string? serviceVersion,
 3173        out ServiceBundleContext context,
 3174        out string error)
 3175    {
 33176        context = default;
 33177        error = string.Empty;
 3178
 33179        var fullScriptPath = Path.GetFullPath(sourceScriptPath);
 33180        if (!File.Exists(fullScriptPath))
 3181        {
 03182            error = $"Script file not found: {fullScriptPath}";
 03183            return false;
 3184        }
 3185
 33186        var fullManifestPath = Path.GetFullPath(sourceModuleManifestPath);
 33187        if (!File.Exists(fullManifestPath))
 3188        {
 03189            error = $"Kestrun manifest file not found: {fullManifestPath}";
 03190            return false;
 3191        }
 3192
 33193        if (!TryResolveServiceRuntimeExecutableFromModule(fullManifestPath, out var runtimeExecutablePath, out var runti
 3194        {
 03195            error = runtimeError;
 03196            return false;
 3197        }
 3198
 33199        if (!TryResolveServiceDeploymentRoot(deploymentRootOverride, out var deploymentRoot, out var deploymentError))
 3200        {
 03201            error = deploymentError;
 03202            return false;
 3203        }
 3204
 33205        var serviceDirectoryName = GetServiceDeploymentDirectoryName(serviceName);
 33206        var serviceRoot = Path.Combine(deploymentRoot, serviceDirectoryName);
 33207        var runtimeDirectory = Path.Combine(serviceRoot, ServiceBundleRuntimeDirectoryName);
 33208        var modulesDirectory = Path.Combine(serviceRoot, ServiceBundleModulesDirectoryName);
 33209        var moduleDirectory = Path.Combine(modulesDirectory, ModuleName);
 33210        var scriptDirectory = Path.Combine(serviceRoot, ServiceBundleScriptDirectoryName);
 33211        var moduleRoot = Path.GetDirectoryName(fullManifestPath)!;
 3212
 33213        context = new ServiceBundleContext(
 33214            fullScriptPath,
 33215            fullManifestPath,
 33216            runtimeExecutablePath,
 33217            moduleRoot,
 33218            serviceRoot,
 33219            runtimeDirectory,
 33220            modulesDirectory,
 33221            moduleDirectory,
 33222            scriptDirectory);
 33223        return true;
 3224    }
 3225
 3226    /// <summary>
 3227    /// Recreates the target service bundle directory structure from scratch.
 3228    /// </summary>
 3229    /// <param name="context">Resolved service bundle context.</param>
 3230    private static void RecreateServiceBundleDirectories(ServiceBundleContext context)
 3231    {
 33232        if (Directory.Exists(context.ServiceRoot))
 3233        {
 03234            Directory.Delete(context.ServiceRoot, recursive: true);
 3235        }
 3236
 33237        _ = Directory.CreateDirectory(context.RuntimeDirectory);
 33238        _ = Directory.CreateDirectory(context.ModulesDirectory);
 33239        _ = Directory.CreateDirectory(context.ModuleDirectory);
 33240        _ = Directory.CreateDirectory(context.ScriptDirectory);
 33241    }
 3242
 3243    /// <summary>
 3244    /// Copies the resolved runtime executable into the service bundle runtime directory.
 3245    /// </summary>
 3246    /// <param name="context">Resolved service bundle context.</param>
 3247    /// <returns>Bundled runtime executable path.</returns>
 3248    private static string CopyServiceRuntimeExecutable(ServiceBundleContext context)
 3249    {
 33250        var bundledRuntimePath = Path.Combine(context.RuntimeDirectory, Path.GetFileName(context.RuntimeExecutablePath))
 33251        File.Copy(context.RuntimeExecutablePath, bundledRuntimePath, overwrite: true);
 33252        return bundledRuntimePath;
 3253    }
 3254
 3255    /// <summary>
 3256    /// Copies the dedicated service host executable into the service bundle runtime directory.
 3257    /// </summary>
 3258    /// <param name="runtimeDirectory">Runtime directory path.</param>
 3259    /// <param name="bundledServiceHostPath">Bundled service host executable path.</param>
 3260    /// <param name="error">Error details when host resolution or copy fails.</param>
 3261    /// <returns>True when the service host executable is copied successfully.</returns>
 3262    private static bool TryCopyServiceHostExecutable(string runtimeDirectory, out string bundledServiceHostPath, out str
 3263    {
 33264        bundledServiceHostPath = string.Empty;
 33265        error = string.Empty;
 3266
 33267        if (!TryResolveDedicatedServiceHostExecutableFromToolDistribution(out var serviceHostExecutablePath))
 3268        {
 03269            error = $"Unable to locate dedicated service host for current RID in Kestrun.Tool distribution. Expected '{(
 03270            return false;
 3271        }
 3272
 33273        bundledServiceHostPath = Path.Combine(runtimeDirectory, Path.GetFileName(serviceHostExecutablePath));
 33274        File.Copy(serviceHostExecutablePath, bundledServiceHostPath, overwrite: true);
 33275        return true;
 3276    }
 3277
 3278    /// <summary>
 3279    /// Copies bundled PowerShell modules payload from the tool distribution into the service bundle.
 3280    /// </summary>
 3281    /// <param name="modulesDirectory">Destination modules directory in the service bundle.</param>
 3282    /// <param name="showProgress">True to print copy progress details.</param>
 3283    /// <param name="error">Error details when payload resolution fails.</param>
 3284    /// <returns>True when bundled modules copy succeeds.</returns>
 3285    private static bool TryCopyBundledToolModules(string modulesDirectory, bool showProgress, out string error)
 3286    {
 33287        error = string.Empty;
 3288
 33289        if (!TryResolvePowerShellModulesPayloadFromToolDistribution(out var toolModulesPayloadPath))
 3290        {
 03291            error = "Unable to locate bundled PowerShell Modules payload for current RID in Kestrun.Tool distribution. E
 03292            return false;
 3293        }
 3294
 33295        CopyDirectoryContents(
 33296            toolModulesPayloadPath,
 33297            modulesDirectory,
 33298            showProgress,
 33299            "Bundling service runtime modules",
 33300            exclusionPatterns: null);
 33301        return true;
 3302    }
 3303
 3304    /// <summary>
 3305    /// Ensures copied runtime executables are executable on Unix-like platforms.
 3306    /// </summary>
 3307    /// <param name="bundledRuntimePath">Bundled runtime executable path.</param>
 3308    /// <param name="bundledServiceHostPath">Bundled service host executable path.</param>
 3309    private static void EnsureBundleExecutablesAreRunnable(string bundledRuntimePath, string bundledServiceHostPath)
 3310    {
 33311        if (!OperatingSystem.IsLinux() && !OperatingSystem.IsMacOS())
 3312        {
 03313            return;
 3314        }
 3315
 33316        TryEnsureServiceRuntimeExecutablePermissions(bundledRuntimePath);
 33317        if (!string.Equals(bundledRuntimePath, bundledServiceHostPath, StringComparison.OrdinalIgnoreCase))
 3318        {
 33319            TryEnsureServiceRuntimeExecutablePermissions(bundledServiceHostPath);
 3320        }
 33321    }
 3322
 3323    /// <summary>
 3324    /// Copies module files into the service bundle and validates that the module manifest is present.
 3325    /// </summary>
 3326    /// <param name="context">Resolved service bundle context.</param>
 3327    /// <param name="showProgress">True to print copy progress details.</param>
 3328    /// <param name="bundledManifestPath">Resulting bundled manifest path.</param>
 3329    /// <param name="error">Error details when the manifest is not present after copy.</param>
 3330    /// <returns>True when module files are copied and manifest validation succeeds.</returns>
 3331    private static bool TryCopyServiceModuleFiles(ServiceBundleContext context, bool showProgress, out string bundledMan
 3332    {
 33333        error = string.Empty;
 3334
 33335        CopyDirectoryContents(
 33336            context.ModuleRoot,
 33337            context.ModuleDirectory,
 33338            showProgress,
 33339            "Bundling module files",
 33340            ServiceBundleModuleExclusionPatterns);
 3341
 33342        bundledManifestPath = Path.Combine(context.ModuleDirectory, Path.GetFileName(context.FullManifestPath));
 33343        if (File.Exists(bundledManifestPath))
 3344        {
 33345            return true;
 3346        }
 3347
 03348        error = $"Service bundle copy did not include module manifest: {bundledManifestPath}";
 03349        return false;
 3350    }
 3351
 3352    /// <summary>
 3353    /// Copies service script files into the service bundle and validates that the entry script exists.
 3354    /// </summary>
 3355    /// <param name="context">Resolved service bundle context.</param>
 3356    /// <param name="sourceContentRoot">Optional script content root for folder copy mode.</param>
 3357    /// <param name="relativeScriptPath">Relative script path under the script folder.</param>
 3358    /// <param name="showProgress">True to print copy progress details.</param>
 3359    /// <param name="bundledScriptPath">Resulting bundled script entrypoint path.</param>
 3360    /// <param name="error">Error details when the bundled script is not present.</param>
 3361    /// <returns>True when script copy and validation succeed.</returns>
 3362    private static bool TryCopyServiceScriptFiles(
 3363        ServiceBundleContext context,
 3364        string? sourceContentRoot,
 3365        string relativeScriptPath,
 3366        bool showProgress,
 3367        out string bundledScriptPath,
 3368        out string error)
 3369    {
 33370        error = string.Empty;
 33371        bundledScriptPath = Path.Combine(context.ScriptDirectory, relativeScriptPath.Replace('/', Path.DirectorySeparato
 3372
 33373        if (string.IsNullOrWhiteSpace(sourceContentRoot))
 3374        {
 23375            var bundledScriptDirectory = Path.GetDirectoryName(bundledScriptPath);
 23376            if (!string.IsNullOrWhiteSpace(bundledScriptDirectory))
 3377            {
 23378                _ = Directory.CreateDirectory(bundledScriptDirectory);
 3379            }
 3380
 23381            File.Copy(context.FullScriptPath, bundledScriptPath, overwrite: true);
 3382        }
 3383        else
 3384        {
 13385            CopyDirectoryContents(
 13386                sourceContentRoot,
 13387                context.ScriptDirectory,
 13388                showProgress,
 13389                "Bundling service script folder",
 13390                exclusionPatterns: null);
 3391        }
 3392
 33393        if (File.Exists(bundledScriptPath))
 3394        {
 33395            return true;
 3396        }
 3397
 03398        error = $"Service bundle copy did not include script: {bundledScriptPath}";
 03399        return false;
 3400    }
 3401
 3402    /// <summary>
 3403    /// Stores resolved paths used during service bundle creation.
 3404    /// </summary>
 3405    /// <param name="FullScriptPath">Resolved source script path.</param>
 3406    /// <param name="FullManifestPath">Resolved source module manifest path.</param>
 3407    /// <param name="RuntimeExecutablePath">Resolved runtime executable source path.</param>
 3408    /// <param name="ModuleRoot">Resolved module directory root.</param>
 3409    /// <param name="ServiceRoot">Resolved service bundle root path.</param>
 3410    /// <param name="RuntimeDirectory">Resolved runtime directory inside bundle.</param>
 3411    /// <param name="ModulesDirectory">Resolved modules directory inside bundle.</param>
 3412    /// <param name="ModuleDirectory">Resolved module-specific directory inside bundle.</param>
 3413    /// <param name="ScriptDirectory">Resolved script directory inside bundle.</param>
 3414    private readonly record struct ServiceBundleContext(
 23415        string FullScriptPath,
 33416        string FullManifestPath,
 63417        string RuntimeExecutablePath,
 33418        string ModuleRoot,
 63419        string ServiceRoot,
 93420        string RuntimeDirectory,
 63421        string ModulesDirectory,
 93422        string ModuleDirectory,
 73423        string ScriptDirectory);
 3424}

/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    {
 1597        public string ServiceName { get; set; } = string.Empty;
 8
 1029        public bool ServiceNameSet { get; set; }
 10
 10811        public string ScriptPath { get; set; } = string.Empty;
 12
 9213        public bool ScriptPathSet { get; set; }
 14
 9915        public string[] ScriptArguments { get; set; } = [];
 16
 3617        public string? ServiceLogPath { get; set; }
 18
 8519        public string? ServiceUser { get; set; }
 20
 8421        public string? ServicePassword { get; set; }
 22
 11723        public string? ServiceContentRoot { get; set; }
 24
 5325        public bool ServicePackageSet { get; set; }
 26
 3627        public string? ServiceDeploymentRoot { get; set; }
 28
 7729        public string? ServiceContentRootChecksum { get; set; }
 30
 7531        public string? ServiceContentRootChecksumAlgorithm { get; set; }
 32
 7433        public string? ServiceContentRootBearerToken { get; set; }
 34
 7035        public bool ServiceContentRootIgnoreCertificate { get; set; }
 36
 5537        public bool ServiceFailbackRequested { get; set; }
 38
 3939        public bool ServiceUseRepositoryKestrun { get; set; }
 40
 8841        public bool ServiceJsonOutputRequested { get; set; }
 42
 4043        public bool ServiceRawOutputRequested { get; set; }
 44
 13745        public List<string> ServiceContentRootHeaders { get; } = [];
 46    }
 47
 48    private sealed class ServiceRegisterParseState
 49    {
 350        public string ServiceName { get; set; } = string.Empty;
 51
 352        public string ServiceHostExecutablePath { get; set; } = string.Empty;
 53
 354        public string RunnerExecutablePath { get; set; } = string.Empty;
 55
 356        public string ScriptPath { get; set; } = string.Empty;
 57
 358        public string ModuleManifestPath { get; set; } = string.Empty;
 59
 360        public string[] ScriptArguments { get; set; } = [];
 61
 062        public string? ServiceLogPath { get; set; }
 63
 064        public string? ServiceUser { get; set; }
 65
 066        public string? ServicePassword { get; set; }
 67    }
 68
 69    /// <summary>
 70    /// Parses arguments for service install/remove/start/stop/query/info commands.
 71    /// </summary>
 72    /// <param name="args">Raw command-line arguments.</param>
 73    /// <param name="startIndex">Index after service token.</param>
 74    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 75    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 76    /// <param name="parsedCommand">Parsed command payload.</param>
 77    /// <param name="error">Error message when parsing fails.</param>
 78    /// <returns>True when parsing succeeds.</returns>
 79    private static bool TryParseServiceArguments(string[] args, int startIndex, string? kestrunFolder, string? kestrunMa
 80    {
 6481        parsedCommand = CreateDefaultServiceParsedCommand(kestrunFolder, kestrunManifestPath);
 6482        if (!TryResolveServiceMode(args, startIndex, out var mode, out error))
 83        {
 084            return false;
 85        }
 86
 6487        var state = new ServiceParseState();
 6488        if (!TryParseServiceOptionLoop(args, mode, state, startIndex + 1, ref kestrunFolder, ref kestrunManifestPath, ou
 89        {
 1390            return false;
 91        }
 92
 5193        if (!TryValidateServiceParseState(mode, state, out error))
 94        {
 1695            return false;
 96        }
 97
 3598        parsedCommand = CreateServiceParsedCommand(mode, state, kestrunFolder, kestrunManifestPath);
 3599        return true;
 100    }
 101
 102    /// <summary>
 103    /// Creates the default parsed command placeholder for service command parsing.
 104    /// </summary>
 105    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 106    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 107    /// <returns>Default parsed command for service mode.</returns>
 108    private static ParsedCommand CreateDefaultServiceParsedCommand(string? kestrunFolder, string? kestrunManifestPath)
 64109        => new(CommandMode.ServiceInstall, string.Empty, false, [], kestrunFolder, kestrunManifestPath, null, false, nul
 110
 111    /// <summary>
 112    /// Validates service token bounds and resolves command mode.
 113    /// </summary>
 114    /// <param name="args">Raw command-line arguments.</param>
 115    /// <param name="startIndex">Index after service token.</param>
 116    /// <param name="mode">Resolved service mode.</param>
 117    /// <param name="error">Error text when parsing fails.</param>
 118    /// <returns>True when a service mode is resolved.</returns>
 119    private static bool TryResolveServiceMode(string[] args, int startIndex, out CommandMode mode, out string error)
 120    {
 64121        mode = CommandMode.Run;
 64122        if (startIndex >= args.Length)
 123        {
 0124            error = "Missing service action. Use 'service install', 'service update', 'service remove', 'service start',
 0125            return false;
 126        }
 127
 64128        return TryParseServiceMode(args[startIndex], out mode, out error);
 129    }
 130
 131    /// <summary>
 132    /// Parses all option and positional tokens for service commands.
 133    /// </summary>
 134    /// <param name="args">Raw command-line arguments.</param>
 135    /// <param name="mode">Current service mode.</param>
 136    /// <param name="state">Mutable service parse state.</param>
 137    /// <param name="startIndex">First token index after service action.</param>
 138    /// <param name="kestrunFolder">Optional folder override.</param>
 139    /// <param name="kestrunManifestPath">Optional manifest override.</param>
 140    /// <param name="error">Error text when parsing fails.</param>
 141    /// <returns>True when option parsing succeeds.</returns>
 142    private static bool TryParseServiceOptionLoop(
 143        string[] args,
 144        CommandMode mode,
 145        ServiceParseState state,
 146        int startIndex,
 147        ref string? kestrunFolder,
 148        ref string? kestrunManifestPath,
 149        out string error)
 150    {
 64151        error = string.Empty;
 64152        var index = startIndex;
 185153        while (index < args.Length)
 154        {
 134155            if (TryConsumeServiceOption(args, mode, state, ref index, ref kestrunFolder, ref kestrunManifestPath, out er
 156            {
 127157                if (!string.IsNullOrEmpty(error))
 158                {
 6159                    return false;
 160                }
 161
 162                continue;
 163            }
 164
 7165            if (!string.IsNullOrEmpty(error))
 166            {
 0167                return false;
 168            }
 169
 7170            var current = args[index];
 7171            if (mode == CommandMode.ServiceInstall && (current is "--arguments" or "--"))
 172            {
 0173                state.ScriptArguments = [.. args.Skip(index + 1)];
 0174                break;
 175            }
 176
 7177            if (!TryConsumeServicePositionalScript(current, mode, state, out error))
 178            {
 7179                return false;
 180            }
 181
 0182            index += 1;
 183        }
 184
 51185        return true;
 186    }
 187
 188    /// <summary>
 189    /// Creates the final parsed command from service parse state.
 190    /// </summary>
 191    /// <param name="mode">Resolved service mode.</param>
 192    /// <param name="state">Completed parse state.</param>
 193    /// <param name="kestrunFolder">Optional folder containing Kestrun.psd1.</param>
 194    /// <param name="kestrunManifestPath">Optional explicit path to Kestrun.psd1.</param>
 195    /// <returns>Parsed command payload.</returns>
 196    private static ParsedCommand CreateServiceParsedCommand(CommandMode mode, ServiceParseState state, string? kestrunFo
 35197        => new(
 35198            mode,
 35199            state.ScriptPath,
 35200            state.ScriptPathSet,
 35201            state.ScriptArguments,
 35202            kestrunFolder,
 35203            kestrunManifestPath,
 35204            state.ServiceName,
 35205            state.ServiceNameSet,
 35206            state.ServiceLogPath,
 35207            state.ServiceUser,
 35208            state.ServicePassword,
 35209            null,
 35210            ModuleStorageScope.Local,
 35211            false,
 35212            state.ServiceContentRoot,
 35213            state.ServiceDeploymentRoot,
 35214            state.ServiceContentRootChecksum,
 35215            state.ServiceContentRootChecksumAlgorithm,
 35216            state.ServiceContentRootBearerToken,
 35217            state.ServiceContentRootIgnoreCertificate,
 35218            [.. state.ServiceContentRootHeaders],
 35219            state.ServiceFailbackRequested,
 35220            state.ServiceUseRepositoryKestrun,
 35221            state.ServiceJsonOutputRequested,
 35222            state.ServiceRawOutputRequested);
 223
 224    /// <summary>
 225    /// Parses the service action token into a concrete command mode.
 226    /// </summary>
 227    /// <param name="action">Service action token.</param>
 228    /// <param name="mode">Parsed service mode.</param>
 229    /// <param name="error">Error text when parsing fails.</param>
 230    /// <returns>True when the action token is valid.</returns>
 231    private static bool TryParseServiceMode(string action, out CommandMode mode, out string error)
 232    {
 64233        mode = action.ToLowerInvariant() switch
 64234        {
 34235            "install" => CommandMode.ServiceInstall,
 13236            "update" => CommandMode.ServiceUpdate,
 2237            "remove" => CommandMode.ServiceRemove,
 4238            "start" => CommandMode.ServiceStart,
 3239            "stop" => CommandMode.ServiceStop,
 3240            "query" => CommandMode.ServiceQuery,
 5241            "info" => CommandMode.ServiceInfo,
 0242            _ => CommandMode.Run,
 64243        };
 244
 64245        if (mode != CommandMode.Run)
 246        {
 64247            error = string.Empty;
 64248            return true;
 249        }
 250
 0251        error = $"Unknown service action: {action}. Use 'service install', 'service update', 'service remove', 'service 
 0252        return false;
 253    }
 254
 255    /// <summary>
 256    /// Attempts to consume one named option in service argument parsing.
 257    /// </summary>
 258    /// <param name="args">Raw command-line arguments.</param>
 259    /// <param name="mode">Current service mode.</param>
 260    /// <param name="state">Mutable service parse state.</param>
 261    /// <param name="index">Current argument index.</param>
 262    /// <param name="kestrunFolder">Optional folder override.</param>
 263    /// <param name="kestrunManifestPath">Optional manifest override.</param>
 264    /// <param name="error">Error text when parsing fails.</param>
 265    /// <returns>True when an option was consumed or handled.</returns>
 266    private static bool TryConsumeServiceOption(
 267        string[] args,
 268        CommandMode mode,
 269        ServiceParseState state,
 270        ref int index,
 271        ref string? kestrunFolder,
 272        ref string? kestrunManifestPath,
 273        out string error)
 274    {
 134275        error = string.Empty;
 134276        var current = args[index];
 277
 134278        return current switch
 134279        {
 9280            "--script" => TryConsumeServiceScriptOption(args, mode, state, ref index, out error),
 34281            "--name" or "-n" => TryConsumeServiceNameOption(args, state, ref index, out error),
 0282            "--kestrun-folder" or "-k" => TryConsumeKestrunFolderOption(args, ref kestrunFolder, ref index, out error),
 4283            "--kestrun-manifest" or "-m" => TryConsumeKestrunManifestOption(args, ref kestrunManifestPath, ref index, "-
 1284            "--kestrun-module" or "--kestrunModule" => TryConsumeKestrunManifestOption(args, ref kestrunManifestPath, re
 1285            "--service-log-path" => TryConsumeServiceLogPathOption(args, state, ref index, out error),
 1286            "--service-user" => TryConsumeServiceUserOption(args, mode, state, ref index, out error),
 1287            "--service-password" => TryConsumeServicePasswordOption(args, mode, state, ref index, out error),
 2288            "--deployment-root" => TryConsumeServiceDeploymentRootOption(args, mode, state, ref index, out error),
 8289            "--package" => TryConsumeServicePackageOption(args, mode, state, ref index, out error),
 26290            "--content-root" => TryConsumeDeprecatedServiceContentRootOption(args, mode, state, ref index, out error),
 4291            "--content-root-checksum" => TryConsumeServiceContentRootChecksumOption(args, mode, state, ref index, out er
 3292            "--content-root-checksum-algorithm" => TryConsumeServiceContentRootChecksumAlgorithmOption(args, mode, state
 5293            "--content-root-bearer-token" => TryConsumeServiceContentRootBearerTokenOption(args, mode, state, ref index,
 4294            "--content-root-ignore-certificate" => TryConsumeServiceContentRootIgnoreCertificateOption(mode, state, ref 
 9295            "--content-root-header" => TryConsumeServiceContentRootHeaderOption(args, mode, state, ref index, out error)
 8296            "--failback" => TryConsumeServiceFailbackOption(mode, state, ref index, out error),
 2297            "--kestrun" => TryConsumeServiceRepositoryKestrunOption(mode, state, ref index, out error),
 3298            "--json" => TryConsumeServiceJsonOption(mode, state, ref index, out error),
 2299            RawOption => TryConsumeServiceRawOption(mode, state, ref index, out error),
 7300            _ => false,
 134301        };
 302    }
 303
 304    /// <summary>
 305    /// Consumes and validates the json output switch.
 306    /// </summary>
 307    /// <param name="mode">Current service mode.</param>
 308    /// <param name="state">Mutable service parse state.</param>
 309    /// <param name="index">Current parser index.</param>
 310    /// <param name="error">Error text when parsing fails.</param>
 311    /// <returns>True when the option token is handled.</returns>
 312    private static bool TryConsumeServiceJsonOption(CommandMode mode, ServiceParseState state, ref int index, out string
 313    {
 3314        if (mode is not (CommandMode.ServiceInfo or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.S
 315        {
 0316            error = "--json is only supported for service start/stop/query/info.";
 0317            return true;
 318        }
 319
 3320        state.ServiceJsonOutputRequested = true;
 3321        index += 1;
 3322        error = string.Empty;
 3323        return true;
 324    }
 325
 326    /// <summary>
 327    /// Consumes and validates the raw output switch.
 328    /// </summary>
 329    /// <param name="mode">Current service mode.</param>
 330    /// <param name="state">Mutable service parse state.</param>
 331    /// <param name="index">Current parser index.</param>
 332    /// <param name="error">Error text when parsing fails.</param>
 333    /// <returns>True when the option token is handled.</returns>
 334    private static bool TryConsumeServiceRawOption(CommandMode mode, ServiceParseState state, ref int index, out string 
 335    {
 2336        if (mode is not (CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.ServiceQuery))
 337        {
 0338            error = "--raw is only supported for service start/stop/query.";
 0339            return true;
 340        }
 341
 2342        state.ServiceRawOutputRequested = true;
 2343        index += 1;
 2344        error = string.Empty;
 2345        return true;
 346    }
 347
 348    /// <summary>
 349    /// Consumes and validates the repository Kestrun module switch.
 350    /// </summary>
 351    /// <param name="mode">Current service mode.</param>
 352    /// <param name="state">Mutable service parse state.</param>
 353    /// <param name="index">Current parser index.</param>
 354    /// <param name="error">Error text when parsing fails.</param>
 355    /// <returns>True when the option token is handled.</returns>
 356    private static bool TryConsumeServiceRepositoryKestrunOption(CommandMode mode, ServiceParseState state, ref int inde
 357    {
 2358        if (mode != CommandMode.ServiceUpdate)
 359        {
 0360            error = "--kestrun is only supported for service update.";
 0361            return true;
 362        }
 363
 2364        state.ServiceUseRepositoryKestrun = true;
 2365        index += 1;
 2366        error = string.Empty;
 2367        return true;
 368    }
 369
 370    /// <summary>
 371    /// Consumes and validates the failback switch.
 372    /// </summary>
 373    /// <param name="mode">Current service mode.</param>
 374    /// <param name="state">Mutable service parse state.</param>
 375    /// <param name="index">Current parser index.</param>
 376    /// <param name="error">Error text when parsing fails.</param>
 377    /// <returns>True when the option token is handled.</returns>
 378    private static bool TryConsumeServiceFailbackOption(CommandMode mode, ServiceParseState state, ref int index, out st
 379    {
 8380        if (mode != CommandMode.ServiceUpdate)
 381        {
 0382            error = "--failback is only supported for service update.";
 0383            return true;
 384        }
 385
 8386        state.ServiceFailbackRequested = true;
 8387        index += 1;
 8388        error = string.Empty;
 8389        return true;
 390    }
 391
 392    /// <summary>
 393    /// Consumes and validates the service script option.
 394    /// </summary>
 395    /// <param name="args">Raw command-line arguments.</param>
 396    /// <param name="mode">Current service mode.</param>
 397    /// <param name="state">Mutable service parse state.</param>
 398    /// <param name="index">Current parser index.</param>
 399    /// <param name="error">Error text when parsing fails.</param>
 400    /// <returns>True when the option token is handled.</returns>
 401    private static bool TryConsumeServiceScriptOption(string[] args, CommandMode mode, ServiceParseState state, ref int 
 402    {
 9403        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 404        {
 0405            error = "Service remove/start/stop/query/info/update does not accept --script.";
 0406            return true;
 407        }
 408
 9409        if (!TryConsumeOptionValue(args, ref index, "--script", out var value, out error))
 410        {
 0411            return true;
 412        }
 413
 9414        if (state.ScriptPathSet)
 415        {
 0416            error = "Script path was provided multiple times. Use either positional script path or --script once.";
 0417            return true;
 418        }
 419
 9420        state.ScriptPath = value;
 9421        state.ScriptPathSet = true;
 9422        return true;
 423    }
 424
 425    /// <summary>
 426    /// Consumes and applies the service name option.
 427    /// </summary>
 428    /// <param name="args">Raw command-line arguments.</param>
 429    /// <param name="state">Mutable service parse state.</param>
 430    /// <param name="index">Current parser index.</param>
 431    /// <param name="error">Error text when parsing fails.</param>
 432    /// <returns>True when the option token is handled.</returns>
 433    private static bool TryConsumeServiceNameOption(string[] args, ServiceParseState state, ref int index, out string er
 434    {
 34435        if (!TryConsumeOptionValue(args, ref index, "--name", out var value, out error))
 436        {
 0437            return true;
 438        }
 439
 34440        state.ServiceName = value;
 34441        state.ServiceNameSet = true;
 34442        return true;
 443    }
 444
 445    /// <summary>
 446    /// Consumes and applies the Kestrun folder option.
 447    /// </summary>
 448    /// <param name="args">Raw command-line arguments.</param>
 449    /// <param name="kestrunFolder">Optional folder override.</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 TryConsumeKestrunFolderOption(string[] args, ref string? kestrunFolder, ref int index, out strin
 454    {
 0455        if (!TryConsumeOptionValue(args, ref index, "--kestrun-folder", out _, out error))
 456        {
 0457            return true;
 458        }
 459
 460        _ = kestrunFolder;
 0461        error = "--kestrun-folder is no longer supported. Use --kestrun-manifest when a custom manifest path is required
 0462        return true;
 463    }
 464
 465    /// <summary>
 466    /// Consumes and applies the Kestrun manifest option.
 467    /// </summary>
 468    /// <param name="args">Raw command-line arguments.</param>
 469    /// <param name="kestrunManifestPath">Optional manifest 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 TryConsumeKestrunManifestOption(string[] args, ref string? kestrunManifestPath, ref int index, s
 474    {
 5475        if (!TryConsumeOptionValue(args, ref index, optionName, out var value, out error))
 476        {
 0477            return true;
 478        }
 479
 5480        kestrunManifestPath = value;
 5481        return true;
 482    }
 483
 484    /// <summary>
 485    /// Consumes and applies the service log path option.
 486    /// </summary>
 487    /// <param name="args">Raw command-line arguments.</param>
 488    /// <param name="state">Mutable service parse state.</param>
 489    /// <param name="index">Current parser index.</param>
 490    /// <param name="error">Error text when parsing fails.</param>
 491    /// <returns>True when the option token is handled.</returns>
 492    private static bool TryConsumeServiceLogPathOption(string[] args, ServiceParseState state, ref int index, out string
 493    {
 1494        if (!TryConsumeOptionValue(args, ref index, "--service-log-path", out var value, out error))
 495        {
 0496            return true;
 497        }
 498
 1499        state.ServiceLogPath = value;
 1500        return true;
 501    }
 502
 503    /// <summary>
 504    /// Consumes and validates the service-user option.
 505    /// </summary>
 506    /// <param name="args">Raw command-line arguments.</param>
 507    /// <param name="mode">Current service mode.</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 TryConsumeServiceUserOption(string[] args, CommandMode mode, ServiceParseState state, ref int in
 513    {
 1514        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 515        {
 0516            error = "Service remove/start/stop/query/info/update does not accept --service-user.";
 0517            return true;
 518        }
 519
 1520        if (!TryConsumeOptionValue(args, ref index, "--service-user", out var value, out error))
 521        {
 0522            return true;
 523        }
 524
 1525        state.ServiceUser = value;
 1526        return true;
 527    }
 528
 529    /// <summary>
 530    /// Consumes and validates the service-password option.
 531    /// </summary>
 532    /// <param name="args">Raw command-line arguments.</param>
 533    /// <param name="mode">Current service mode.</param>
 534    /// <param name="state">Mutable service parse state.</param>
 535    /// <param name="index">Current parser index.</param>
 536    /// <param name="error">Error text when parsing fails.</param>
 537    /// <returns>True when the option token is handled.</returns>
 538    private static bool TryConsumeServicePasswordOption(string[] args, CommandMode mode, ServiceParseState state, ref in
 539    {
 1540        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 541        {
 0542            error = "Service remove/start/stop/query/info/update does not accept --service-password.";
 0543            return true;
 544        }
 545
 1546        if (!TryConsumeOptionValue(args, ref index, "--service-password", out var value, out error))
 547        {
 0548            return true;
 549        }
 550
 1551        state.ServicePassword = value;
 1552        return true;
 553    }
 554
 555    /// <summary>
 556    /// Consumes and validates the deployment-root option.
 557    /// </summary>
 558    /// <param name="args">Raw command-line arguments.</param>
 559    /// <param name="mode">Current service mode.</param>
 560    /// <param name="state">Mutable service parse state.</param>
 561    /// <param name="index">Current parser index.</param>
 562    /// <param name="error">Error text when parsing fails.</param>
 563    /// <returns>True when the option token is handled.</returns>
 564    private static bool TryConsumeServiceDeploymentRootOption(string[] args, CommandMode mode, ServiceParseState state, 
 565    {
 2566        if (mode is CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.ServiceQuery)
 567        {
 1568            error = "Service start/stop/query does not accept --deployment-root.";
 1569            return true;
 570        }
 571
 1572        if (!TryConsumeOptionValue(args, ref index, "--deployment-root", out var value, out error))
 573        {
 0574            return true;
 575        }
 576
 1577        state.ServiceDeploymentRoot = value;
 1578        return true;
 579    }
 580
 581    /// <summary>
 582    /// Consumes and validates the package option.
 583    /// </summary>
 584    /// <param name="args">Raw command-line arguments.</param>
 585    /// <param name="mode">Current service mode.</param>
 586    /// <param name="state">Mutable service parse state.</param>
 587    /// <param name="index">Current parser index.</param>
 588    /// <param name="error">Error text when parsing fails.</param>
 589    /// <returns>True when the option token is handled.</returns>
 590    private static bool TryConsumeServicePackageOption(string[] args, CommandMode mode, ServiceParseState state, ref int
 591    {
 8592        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 593        {
 5594            error = "Service remove/start/stop/query/info does not accept --package.";
 5595            return true;
 596        }
 597
 3598        if (!TryConsumeOptionValue(args, ref index, "--package", out var value, out error))
 599        {
 0600            return true;
 601        }
 602
 3603        state.ServiceContentRoot = value;
 3604        state.ServicePackageSet = true;
 3605        return true;
 606    }
 607
 608    /// <summary>
 609    /// Handles deprecated content-root option for service install.
 610    /// </summary>
 611    /// <param name="mode">Current service mode.</param>
 612    /// <param name="index">Current parser index.</param>
 613    /// <param name="error">Error text when parsing fails.</param>
 614    /// <returns>True when the option token is handled.</returns>
 615    private static bool TryConsumeDeprecatedServiceContentRootOption(string[] args, CommandMode mode, ServiceParseState 
 616    {
 26617        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 618        {
 0619            error = "Service remove/start/stop/query/info does not accept --content-root.";
 0620            return true;
 621        }
 622
 26623        if (!TryConsumeOptionValue(args, ref index, "--content-root", out var value, out error))
 624        {
 0625            return true;
 626        }
 627
 26628        state.ServiceContentRoot = value;
 26629        state.ServicePackageSet = false;
 26630        error = string.Empty;
 26631        return true;
 632    }
 633
 634    /// <summary>
 635    /// Consumes and validates the content-root checksum option.
 636    /// </summary>
 637    /// <param name="args">Raw command-line arguments.</param>
 638    /// <param name="mode">Current service mode.</param>
 639    /// <param name="state">Mutable service parse state.</param>
 640    /// <param name="index">Current parser index.</param>
 641    /// <param name="error">Error text when parsing fails.</param>
 642    /// <returns>True when the option token is handled.</returns>
 643    private static bool TryConsumeServiceContentRootChecksumOption(string[] args, CommandMode mode, ServiceParseState st
 644    {
 4645        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 646        {
 0647            error = "Service remove/start/stop/query/info does not accept --content-root-checksum.";
 0648            return true;
 649        }
 650
 4651        if (!TryConsumeOptionValue(args, ref index, "--content-root-checksum", out var value, out error))
 652        {
 0653            return true;
 654        }
 655
 4656        state.ServiceContentRootChecksum = value;
 4657        return true;
 658    }
 659
 660    /// <summary>
 661    /// Consumes and validates the content-root checksum algorithm option.
 662    /// </summary>
 663    /// <param name="args">Raw command-line arguments.</param>
 664    /// <param name="mode">Current service mode.</param>
 665    /// <param name="state">Mutable service parse state.</param>
 666    /// <param name="index">Current parser index.</param>
 667    /// <param name="error">Error text when parsing fails.</param>
 668    /// <returns>True when the option token is handled.</returns>
 669    private static bool TryConsumeServiceContentRootChecksumAlgorithmOption(string[] args, CommandMode mode, ServicePars
 670    {
 3671        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 672        {
 0673            error = "Service remove/start/stop/query/info does not accept --content-root-checksum-algorithm.";
 0674            return true;
 675        }
 676
 3677        if (!TryConsumeOptionValue(args, ref index, "--content-root-checksum-algorithm", out var value, out error))
 678        {
 0679            return true;
 680        }
 681
 3682        state.ServiceContentRootChecksumAlgorithm = value;
 3683        return true;
 684    }
 685
 686    /// <summary>
 687    /// Consumes and validates the content-root bearer token option.
 688    /// </summary>
 689    /// <param name="args">Raw command-line arguments.</param>
 690    /// <param name="mode">Current service mode.</param>
 691    /// <param name="state">Mutable service parse state.</param>
 692    /// <param name="index">Current parser index.</param>
 693    /// <param name="error">Error text when parsing fails.</param>
 694    /// <returns>True when the option token is handled.</returns>
 695    private static bool TryConsumeServiceContentRootBearerTokenOption(string[] args, CommandMode mode, ServiceParseState
 696    {
 5697        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 698        {
 0699            error = "Service remove/start/stop/query/info does not accept --content-root-bearer-token.";
 0700            return true;
 701        }
 702
 5703        if (!TryConsumeOptionValue(args, ref index, "--content-root-bearer-token", out var value, out error))
 704        {
 0705            return true;
 706        }
 707
 5708        state.ServiceContentRootBearerToken = value;
 5709        return true;
 710    }
 711
 712    /// <summary>
 713    /// Consumes and validates the content-root certificate-ignore option.
 714    /// </summary>
 715    /// <param name="mode">Current service mode.</param>
 716    /// <param name="state">Mutable service parse state.</param>
 717    /// <param name="index">Current parser index.</param>
 718    /// <param name="error">Error text when parsing fails.</param>
 719    /// <returns>True when the option token is handled.</returns>
 720    private static bool TryConsumeServiceContentRootIgnoreCertificateOption(CommandMode mode, ServiceParseState state, r
 721    {
 4722        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 723        {
 0724            error = "Service remove/start/stop/query/info does not accept --content-root-ignore-certificate.";
 0725            return true;
 726        }
 727
 4728        state.ServiceContentRootIgnoreCertificate = true;
 4729        index += 1;
 4730        error = string.Empty;
 4731        return true;
 732    }
 733
 734    /// <summary>
 735    /// Consumes and validates the content-root custom header option.
 736    /// </summary>
 737    /// <param name="args">Raw command-line arguments.</param>
 738    /// <param name="mode">Current service mode.</param>
 739    /// <param name="state">Mutable service parse state.</param>
 740    /// <param name="index">Current parser index.</param>
 741    /// <param name="error">Error text when parsing fails.</param>
 742    /// <returns>True when the option token is handled.</returns>
 743    private static bool TryConsumeServiceContentRootHeaderOption(string[] args, CommandMode mode, ServiceParseState stat
 744    {
 9745        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 746        {
 0747            error = "Service remove/start/stop/query/info does not accept --content-root-header.";
 0748            return true;
 749        }
 750
 9751        if (!TryConsumeOptionValue(args, ref index, "--content-root-header", out var value, out error))
 752        {
 0753            return true;
 754        }
 755
 9756        state.ServiceContentRootHeaders.Add(value);
 9757        return true;
 758    }
 759
 760    /// <summary>
 761    /// Consumes a single option value and advances the argument index.
 762    /// </summary>
 763    /// <param name="args">Raw command-line arguments.</param>
 764    /// <param name="index">Current parser index.</param>
 765    /// <param name="optionName">Option name for error reporting.</param>
 766    /// <param name="value">Parsed option value.</param>
 767    /// <param name="error">Error text when parsing fails.</param>
 768    /// <returns>True when the option value was consumed.</returns>
 769    private static bool TryConsumeOptionValue(string[] args, ref int index, string optionName, out string value, out str
 770    {
 105771        value = string.Empty;
 105772        error = string.Empty;
 773
 105774        if (index + 1 >= args.Length)
 775        {
 1776            error = $"Missing value for {optionName}.";
 1777            return false;
 778        }
 779
 104780        value = args[index + 1];
 104781        index += 2;
 104782        return true;
 783    }
 784
 785    /// <summary>
 786    /// Consumes the positional script path for service install mode.
 787    /// </summary>
 788    /// <param name="current">Current argument token.</param>
 789    /// <param name="mode">Current service mode.</param>
 790    /// <param name="state">Mutable service parse state.</param>
 791    /// <param name="error">Error text when parsing fails.</param>
 792    /// <returns>True when parsing can continue.</returns>
 793    private static bool TryConsumeServicePositionalScript(string current, CommandMode mode, ServiceParseState state, out
 794    {
 7795        error = string.Empty;
 796
 7797        if (current.StartsWith("--", StringComparison.Ordinal))
 798        {
 7799            error = $"Unknown option: {current}";
 7800            return false;
 801        }
 802
 0803        if (mode is CommandMode.ServiceRemove or CommandMode.ServiceStart or CommandMode.ServiceStop or CommandMode.Serv
 804        {
 0805            error = "Service remove/start/stop/query/info/update does not accept a script path.";
 0806            return false;
 807        }
 808
 0809        if (state.ScriptPathSet)
 810        {
 0811            error = "Service install script arguments must be preceded by --arguments (or --).";
 0812            return false;
 813        }
 814
 0815        state.ScriptPath = current;
 0816        state.ScriptPathSet = true;
 0817        return true;
 818    }
 819
 820    /// <summary>
 821    /// Validates parsed service arguments and applies install defaults.
 822    /// </summary>
 823    /// <param name="mode">Current service mode.</param>
 824    /// <param name="state">Mutable service parse state.</param>
 825    /// <param name="error">Error text when validation fails.</param>
 826    /// <returns>True when validation succeeds.</returns>
 827    private static bool TryValidateServiceParseState(CommandMode mode, ServiceParseState state, out string error)
 828    {
 51829        if (!TryValidateServiceName(mode, state, out error))
 830        {
 1831            return false;
 832        }
 833
 50834        if (state.ServiceJsonOutputRequested && state.ServiceRawOutputRequested)
 835        {
 1836            error = "--json cannot be combined with --raw.";
 1837            return false;
 838        }
 839
 49840        ApplyDefaultServiceInstallScript(mode, state);
 841
 49842        return TryValidateServiceCredentialOptions(mode, state, out error)
 49843            && TryValidateServiceContentRootDependentOptions(mode, state, out error)
 49844            && TryValidateServiceUpdateOptions(mode, state, out error);
 845    }
 846
 847    /// <summary>
 848    /// Validates update-mode specific options.
 849    /// </summary>
 850    /// <param name="mode">Current service mode.</param>
 851    /// <param name="state">Mutable service parse state.</param>
 852    /// <param name="error">Error text when validation fails.</param>
 853    /// <returns>True when update-mode options are valid.</returns>
 854    private static bool TryValidateServiceUpdateOptions(CommandMode mode, ServiceParseState state, out string error)
 855    {
 42856        if (mode != CommandMode.ServiceUpdate)
 857        {
 30858            error = string.Empty;
 30859            return true;
 860        }
 861
 12862        var hasPackageUpdate = !string.IsNullOrWhiteSpace(state.ServiceContentRoot);
 863
 12864        if (state.ServiceFailbackRequested)
 865        {
 8866            if (hasPackageUpdate)
 867            {
 1868                error = "--failback cannot be combined with --package.";
 1869                return false;
 870            }
 871
 7872            if (!string.IsNullOrWhiteSpace(state.ServiceContentRootChecksum)
 7873                || !string.IsNullOrWhiteSpace(state.ServiceContentRootChecksumAlgorithm)
 7874                || !string.IsNullOrWhiteSpace(state.ServiceContentRootBearerToken)
 7875                || state.ServiceContentRootIgnoreCertificate
 7876                || state.ServiceContentRootHeaders.Count > 0)
 877            {
 5878                error = "--failback does not accept --content-root* update options.";
 5879                return false;
 880            }
 881
 2882            if (state.ServiceUseRepositoryKestrun)
 883            {
 1884                error = "--failback cannot be combined with --kestrun.";
 1885                return false;
 886            }
 887
 1888            error = string.Empty;
 1889            return true;
 890        }
 891
 4892        error = string.Empty;
 4893        return true;
 894    }
 895
 896    /// <summary>
 897    /// Validates that the service name option was provided.
 898    /// </summary>
 899    /// <param name="mode">Current service mode.</param>
 900    /// <param name="state">Mutable service parse state.</param>
 901    /// <param name="error">Error text when validation fails.</param>
 902    /// <returns>True when the service name is valid.</returns>
 903    private static bool TryValidateServiceName(CommandMode mode, ServiceParseState state, out string error)
 904    {
 51905        if (mode != CommandMode.ServiceInstall)
 906        {
 18907            if (mode == CommandMode.ServiceInfo)
 908            {
 3909                error = string.Empty;
 3910                return true;
 911            }
 912
 15913            if (mode == CommandMode.ServiceUpdate
 15914                && string.IsNullOrWhiteSpace(state.ServiceName)
 15915                && !string.IsNullOrWhiteSpace(state.ServiceContentRoot))
 916            {
 1917                error = string.Empty;
 1918                return true;
 919            }
 920
 14921            if (string.IsNullOrWhiteSpace(state.ServiceName))
 922            {
 0923                error = "Service name is required. Use --name <value>.";
 0924                return false;
 925            }
 926
 14927            error = string.Empty;
 14928            return true;
 929        }
 930
 33931        if (state.ServiceNameSet && !string.IsNullOrWhiteSpace(state.ServiceContentRoot))
 932        {
 1933            error = "--name is no longer supported when installing from --package/--content-root. Define Name in Service
 1934            return false;
 935        }
 936
 32937        error = string.Empty;
 32938        return true;
 939    }
 940
 941    /// <summary>
 942    /// Applies the default script path for service install when script is omitted.
 943    /// </summary>
 944    /// <param name="mode">Current service mode.</param>
 945    /// <param name="state">Mutable service parse state.</param>
 946    private static void ApplyDefaultServiceInstallScript(CommandMode mode, ServiceParseState state)
 947    {
 948        _ = mode;
 949        _ = state;
 49950    }
 951
 952    /// <summary>
 953    /// Validates credential-related service install options.
 954    /// </summary>
 955    /// <param name="mode">Current service mode.</param>
 956    /// <param name="state">Mutable service parse state.</param>
 957    /// <param name="error">Error text when validation fails.</param>
 958    /// <returns>True when credential options are valid.</returns>
 959    private static bool TryValidateServiceCredentialOptions(CommandMode mode, ServiceParseState state, out string error)
 960    {
 49961        if (mode != CommandMode.ServiceInstall && (!string.IsNullOrWhiteSpace(state.ServiceUser) || !string.IsNullOrWhit
 962        {
 0963            error = "Service user credentials are only supported for service install.";
 0964            return false;
 965        }
 966
 49967        if (mode == CommandMode.ServiceInstall && string.IsNullOrWhiteSpace(state.ServiceUser) && !string.IsNullOrWhiteS
 968        {
 0969            error = "--service-password requires --service-user.";
 0970            return false;
 971        }
 972
 49973        error = string.Empty;
 49974        return true;
 975    }
 976
 977    /// <summary>
 978    /// Validates content-root dependent options for service install mode.
 979    /// </summary>
 980    /// <param name="mode">Current service mode.</param>
 981    /// <param name="state">Mutable service parse state.</param>
 982    /// <param name="error">Error text when validation fails.</param>
 983    /// <returns>True when content-root dependent options are valid.</returns>
 984    private static bool TryValidateServiceContentRootDependentOptions(CommandMode mode, ServiceParseState state, out str
 985    {
 49986        if (mode != CommandMode.ServiceInstall)
 987        {
 17988            error = string.Empty;
 17989            return true;
 990        }
 991
 32992        var hasContentRoot = !string.IsNullOrWhiteSpace(state.ServiceContentRoot);
 993
 32994        if (!TryValidateServiceInstallScriptContentRootSelection(state, hasContentRoot, out error))
 995        {
 1996            return false;
 997        }
 998
 31999        if (!TryValidateServiceInstallPackageExtension(state, hasContentRoot, out error))
 1000        {
 01001            return false;
 1002        }
 1003
 311004        if (!TryValidateServiceContentRootLinkedOptions(state, hasContentRoot, out error))
 1005        {
 61006            return false;
 1007        }
 1008
 251009        error = string.Empty;
 251010        return true;
 1011    }
 1012
 1013    /// <summary>
 1014    /// Validates service-install script/content-root selection rules.
 1015    /// </summary>
 1016    /// <param name="state">Mutable service parse state.</param>
 1017    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1018    /// <param name="error">Validation error text.</param>
 1019    /// <returns>True when script/content-root selection is valid.</returns>
 1020    private static bool TryValidateServiceInstallScriptContentRootSelection(ServiceParseState state, bool hasContentRoot
 1021    {
 321022        if (state.ScriptPathSet && hasContentRoot)
 1023        {
 11024            error = "--script (or positional script path) is not supported when --package/--content-root is used. Define
 11025            return false;
 1026        }
 1027
 311028        if (!hasContentRoot && !state.ScriptPathSet)
 1029        {
 01030            error = "Service install requires either --package/--content-root or --script.";
 01031            return false;
 1032        }
 1033
 311034        error = string.Empty;
 311035        return true;
 1036    }
 1037
 1038    /// <summary>
 1039    /// Validates package extension requirements when --package semantics are in effect.
 1040    /// </summary>
 1041    /// <param name="state">Mutable service parse state.</param>
 1042    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1043    /// <param name="error">Validation error text.</param>
 1044    /// <returns>True when package extension usage is valid.</returns>
 1045    private static bool TryValidateServiceInstallPackageExtension(ServiceParseState state, bool hasContentRoot, out stri
 1046    {
 311047        if (hasContentRoot && state.ServicePackageSet)
 1048        {
 01049            var contentRoot = state.ServiceContentRoot!.Trim();
 1050
 1051            // When --package points to an HTTP/HTTPS URL, rely on the downloader/content-type/signature
 1052            // pipeline to determine the archive type instead of enforcing a local file extension check.
 01053            if (Uri.TryCreate(contentRoot, UriKind.Absolute, out var uri)
 01054                && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
 1055            {
 01056                error = string.Empty;
 01057                return true;
 1058            }
 1059
 1060            // For local paths (and non-HTTP(S) URIs), enforce the extension on the path component only.
 01061            var pathToCheck = contentRoot;
 01062            if (Uri.TryCreate(contentRoot, UriKind.Absolute, out uri)
 01063                && uri.IsAbsoluteUri
 01064                && uri.Scheme == Uri.UriSchemeFile)
 1065            {
 01066                pathToCheck = uri.AbsolutePath;
 1067            }
 1068
 01069            if (!pathToCheck.EndsWith(ServicePackageExtension, StringComparison.OrdinalIgnoreCase))
 1070            {
 01071                error = $"--package must point to a '{ServicePackageExtension}' file.";
 01072                return false;
 1073            }
 1074        }
 1075
 311076        error = string.Empty;
 311077        return true;
 1078    }
 1079
 1080    /// <summary>
 1081    /// Validates options that require --content-root to be supplied.
 1082    /// </summary>
 1083    /// <param name="state">Mutable service parse state.</param>
 1084    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1085    /// <param name="error">Validation error text.</param>
 1086    /// <returns>True when content-root dependent option usage is valid.</returns>
 1087    private static bool TryValidateServiceContentRootLinkedOptions(ServiceParseState state, bool hasContentRoot, out str
 1088    {
 311089        if (!TryValidateServiceContentRootChecksumOptions(state, hasContentRoot, out error))
 1090        {
 21091            return false;
 1092        }
 1093
 291094        if (!TryValidateContentRootLinkedOption(!string.IsNullOrWhiteSpace(state.ServiceContentRootBearerToken), hasCont
 1095        {
 21096            return false;
 1097        }
 1098
 271099        if (!TryValidateContentRootLinkedOption(state.ServiceContentRootIgnoreCertificate, hasContentRoot, "--content-ro
 1100        {
 11101            return false;
 1102        }
 1103
 261104        if (!TryValidateContentRootLinkedOption(state.ServiceContentRootHeaders.Count > 0, hasContentRoot, "--content-ro
 1105        {
 11106            return false;
 1107        }
 1108
 251109        error = string.Empty;
 251110        return true;
 1111    }
 1112
 1113    /// <summary>
 1114    /// Validates checksum-specific option dependencies for service content-root installs.
 1115    /// </summary>
 1116    /// <param name="state">Mutable service parse state.</param>
 1117    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1118    /// <param name="error">Validation error text.</param>
 1119    /// <returns>True when checksum-related options are valid.</returns>
 1120    private static bool TryValidateServiceContentRootChecksumOptions(ServiceParseState state, bool hasContentRoot, out s
 1121    {
 311122        var hasChecksum = !string.IsNullOrWhiteSpace(state.ServiceContentRootChecksum);
 311123        var hasChecksumAlgorithm = !string.IsNullOrWhiteSpace(state.ServiceContentRootChecksumAlgorithm);
 311124        if (hasChecksumAlgorithm && !hasChecksum)
 1125        {
 11126            error = "--content-root-checksum-algorithm requires --content-root-checksum.";
 11127            return false;
 1128        }
 1129
 301130        if (!TryValidateContentRootLinkedOption(hasChecksum, hasContentRoot, "--content-root-checksum requires --content
 1131        {
 11132            return false;
 1133        }
 1134
 291135        error = string.Empty;
 291136        return true;
 1137    }
 1138
 1139    /// <summary>
 1140    /// Validates that an option requiring a content root is only supplied with --content-root.
 1141    /// </summary>
 1142    /// <param name="optionIsSet">True when the dependent option was supplied.</param>
 1143    /// <param name="hasContentRoot">True when a content root or package was specified.</param>
 1144    /// <param name="errorMessage">Validation error to emit when dependency is missing.</param>
 1145    /// <param name="error">Validation error text.</param>
 1146    /// <returns>True when the option dependency is satisfied.</returns>
 1147    private static bool TryValidateContentRootLinkedOption(bool optionIsSet, bool hasContentRoot, string errorMessage, o
 1148    {
 1121149        if (optionIsSet && !hasContentRoot)
 1150        {
 51151            error = errorMessage;
 51152            return false;
 1153        }
 1154
 1071155        error = string.Empty;
 1071156        return true;
 1157    }
 1158
 1159    /// <summary>
 1160    /// Parses internal Windows service registration arguments when present.
 1161    /// </summary>
 1162    /// <param name="args">Raw command-line arguments.</param>
 1163    /// <param name="options">Parsed registration options when successful.</param>
 1164    /// <param name="error">Parse error when registration mode is requested but invalid.</param>
 1165    /// <returns>True when service registration mode is recognized and parsed.</returns>
 1166    private static bool TryParseServiceRegisterArguments(string[] args, out ServiceRegisterOptions? options, out string?
 1167    {
 61168        options = null;
 61169        error = null;
 1170
 61171        if (args.Length == 0 || !string.Equals(args[0], "--service-register", StringComparison.OrdinalIgnoreCase))
 1172        {
 31173            return false;
 1174        }
 1175
 31176        var state = new ServiceRegisterParseState();
 31177        if (!TryParseServiceRegisterOptionLoop(args, state, out error))
 1178        {
 31179            return false;
 1180        }
 1181        // Service registration mode is recognized. Validate required options and build immutable options.
 01182        return TryBuildServiceRegisterOptions(state, out options, out error);
 1183    }
 1184
 1185    /// <summary>
 1186    /// Parses service-register option tokens into a mutable parse state.
 1187    /// </summary>
 1188    /// <param name="args">Raw command-line arguments.</param>
 1189    /// <param name="state">Mutable parse state.</param>
 1190    /// <param name="error">Parse error when an unsupported option or missing option value is encountered.</param>
 1191    /// <returns>True when parsing succeeds.</returns>
 1192    private static bool TryParseServiceRegisterOptionLoop(string[] args, ServiceRegisterParseState state, out string? er
 1193    {
 31194        error = null;
 31195        var index = 1;
 1196
 31197        while (index < args.Length)
 1198        {
 31199            if (!TryConsumeServiceRegisterOption(args, state, ref index, out error))
 1200            {
 31201                return false;
 1202            }
 1203        }
 1204
 01205        return true;
 1206    }
 1207
 1208    /// <summary>
 1209    /// Consumes one internal service-register option from the current parser index.
 1210    /// </summary>
 1211    /// <param name="args">Raw command-line arguments.</param>
 1212    /// <param name="state">Mutable parse state.</param>
 1213    /// <param name="index">Current parser index.</param>
 1214    /// <param name="error">Parse error when an unsupported option or missing option value is encountered.</param>
 1215    /// <returns>True when parsing can continue.</returns>
 1216    private static bool TryConsumeServiceRegisterOption(string[] args, ServiceRegisterParseState state, ref int index, o
 1217    {
 31218        error = null;
 31219        var current = args[index];
 1220
 31221        if (current is "--arguments" or "--")
 1222        {
 01223            state.ScriptArguments = [.. args.Skip(index + 1)];
 01224            index = args.Length;
 01225            return true;
 1226        }
 1227
 31228        if (!TryConsumeServiceRegisterOptionValue(args, ref index, current, out var value))
 1229        {
 31230            error = IsServiceRegisterOptionWithValue(current)
 31231                ? $"Missing value for {current}."
 31232                : $"Unknown service register option: {current}";
 31233            return false;
 1234        }
 1235
 01236        return TryApplyServiceRegisterOptionValue(current, value, state, out error);
 1237    }
 1238
 1239    /// <summary>
 1240    /// Applies a consumed service-register option value to parse state.
 1241    /// </summary>
 1242    /// <param name="option">Service-register option token.</param>
 1243    /// <param name="value">Consumed option value.</param>
 1244    /// <param name="state">Mutable service-register parse state.</param>
 1245    /// <param name="error">Error text when the option token is unsupported.</param>
 1246    /// <returns>True when the option value is applied.</returns>
 1247    private static bool TryApplyServiceRegisterOptionValue(string option, string value, ServiceRegisterParseState state,
 1248    {
 01249        error = null;
 1250
 1251        switch (option)
 1252        {
 1253            case "--name":
 01254                state.ServiceName = value;
 01255                return true;
 1256            case "--service-host-exe":
 01257                state.ServiceHostExecutablePath = value;
 01258                return true;
 1259            case "--runner-exe":
 01260                state.RunnerExecutablePath = value;
 01261                return true;
 1262            case "--exe":
 1263                // Backward compatibility for older elevated registration invocations.
 01264                state.ServiceHostExecutablePath = value;
 01265                return true;
 1266            case "--script":
 01267                state.ScriptPath = value;
 01268                return true;
 1269            case "--kestrun-manifest":
 1270            case "-m":
 01271                state.ModuleManifestPath = value;
 01272                return true;
 1273            case "--service-log-path":
 01274                state.ServiceLogPath = value;
 01275                return true;
 1276            case "--service-user":
 01277                state.ServiceUser = value;
 01278                return true;
 1279            case "--service-password":
 01280                state.ServicePassword = value;
 01281                return true;
 1282            default:
 01283                error = $"Unknown service register option: {option}";
 01284                return false;
 1285        }
 1286    }
 1287
 1288    /// <summary>
 1289    /// Determines whether a token is a supported service-register option that requires a value.
 1290    /// </summary>
 1291    /// <param name="option">Option token to evaluate.</param>
 1292    /// <returns>True when the token is a recognized option that requires a value.</returns>
 1293    private static bool IsServiceRegisterOptionWithValue(string option)
 61294        => option is "--name"
 61295            or "--service-host-exe"
 61296            or "--runner-exe"
 61297            or "--exe"
 61298            or "--script"
 61299            or "--kestrun-manifest"
 61300            or "-m"
 61301            or "--service-log-path"
 61302            or "--service-user"
 61303            or "--service-password";
 1304
 1305    /// <summary>
 1306    /// Attempts to consume a single service-register option value.
 1307    /// </summary>
 1308    /// <param name="args">Raw command-line arguments.</param>
 1309    /// <param name="index">Current parser index.</param>
 1310    /// <param name="option">Current option token.</param>
 1311    /// <param name="value">Consumed option value when available.</param>
 1312    /// <returns>True when an option value pair is consumed.</returns>
 1313    private static bool TryConsumeServiceRegisterOptionValue(string[] args, ref int index, string option, out string val
 1314    {
 31315        value = string.Empty;
 1316
 31317        if (!IsServiceRegisterOptionWithValue(option))
 1318        {
 11319            return false;
 1320        }
 1321
 21322        if (index + 1 >= args.Length)
 1323        {
 21324            return false;
 1325        }
 1326
 01327        value = args[index + 1];
 01328        index += 2;
 01329        return true;
 1330    }
 1331
 1332    /// <summary>
 1333    /// Validates parsed service-register values and creates immutable registration options.
 1334    /// </summary>
 1335    /// <param name="state">Completed parse state.</param>
 1336    /// <param name="options">Parsed registration options.</param>
 1337    /// <param name="error">Validation error text.</param>
 1338    /// <returns>True when validation succeeds.</returns>
 1339    private static bool TryBuildServiceRegisterOptions(
 1340        ServiceRegisterParseState state,
 1341        out ServiceRegisterOptions? options,
 1342        out string? error)
 1343    {
 01344        options = null;
 01345        error = null;
 1346
 01347        if (string.IsNullOrWhiteSpace(state.ServiceName))
 1348        {
 01349            error = "Missing --name for internal service registration mode.";
 01350            return false;
 1351        }
 1352
 01353        if (string.IsNullOrWhiteSpace(state.ServiceHostExecutablePath))
 1354        {
 01355            error = "Missing --service-host-exe for internal service registration mode.";
 01356            return false;
 1357        }
 1358
 01359        if (string.IsNullOrWhiteSpace(state.RunnerExecutablePath))
 1360        {
 01361            state.RunnerExecutablePath = state.ServiceHostExecutablePath;
 1362        }
 1363
 01364        if (string.IsNullOrWhiteSpace(state.ScriptPath))
 1365        {
 01366            error = "Missing --script for internal service registration mode.";
 01367            return false;
 1368        }
 1369
 01370        if (string.IsNullOrWhiteSpace(state.ModuleManifestPath))
 1371        {
 01372            error = "Missing --kestrun-manifest for internal service registration mode.";
 01373            return false;
 1374        }
 1375
 01376        options = new ServiceRegisterOptions(
 01377            state.ServiceName,
 01378            state.ServiceHostExecutablePath,
 01379            state.RunnerExecutablePath,
 01380            state.ScriptPath,
 01381            state.ModuleManifestPath,
 01382            state.ScriptArguments,
 01383            state.ServiceLogPath,
 01384            state.ServiceUser,
 01385            state.ServicePassword);
 01386        return true;
 1387    }
 1388}

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.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_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,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()
.ctor(System.String,System.String,System.String,System.String,System.String,System.String,System.Collections.Generic.IReadOnlyList`1<System.String>)
get_FormatVersion()
get_Name()
get_EntryPoint()
get_Description()
get_Version()
get_ServiceLogPath()
get_PreservePaths()
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)
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/ServiceBundleLayout&,System.String&,System.String,System.String)
TryResolveServiceBundleContext(System.String,System.String,System.String,System.String,System.String,Kestrun.Tool.Program/ServiceBundleContext&,System.String&)
RecreateServiceBundleDirectories(Kestrun.Tool.Program/ServiceBundleContext)
CopyServiceRuntimeExecutable(Kestrun.Tool.Program/ServiceBundleContext)
TryCopyServiceHostExecutable(System.String,System.String&,System.String&)
TryCopyBundledToolModules(System.String,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_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_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&)
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&)
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&)
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&)