Skip to content

Commit 06b31a3

Browse files
Merge pull request Osmantic#1700 from IronicRayquaza/fix/windows-cli-enable-disable
feat(windows-cli): add enable/disable commands to ods.ps1
2 parents 35b32c8 + 2fcb0fc commit 06b31a3

2 files changed

Lines changed: 668 additions & 0 deletions

File tree

ods/installers/windows/ods.ps1

Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
# .\ods.ps1 update # Pull latest images and restart
1717
# .\ods.ps1 doctor # Diagnose runtime readiness
1818
# .\ods.ps1 repair voice # Repair voice/STT/TTS readiness
19+
# .\ods.ps1 enable <service> # Enable an extension service
20+
# .\ods.ps1 disable <service> # Disable an extension service
1921
# .\ods.ps1 report # Generate Windows diagnostics bundle
2022
# .\ods.ps1 version # Show version
2123
# .\ods.ps1 help # Show help
@@ -1722,6 +1724,298 @@ Start-Process -FilePath $_pythonLiteral -ArgumentList `$agentArgs -WorkingDirect
17221724
}
17231725
}
17241726

1727+
function Update-ComposeFlags {
1728+
<#
1729+
.SYNOPSIS
1730+
Regenerate .compose-flags after an enable/disable operation.
1731+
1732+
Strategy (in priority order):
1733+
1. If scripts/resolve-compose-stack.sh exists and bash is available,
1734+
delegate entirely to the canonical resolver (preserves backend
1735+
overlays, multi-GPU overlays, user-extension overlays, and
1736+
docker-compose.override.yml -- exactly the same stack the installer
1737+
built). This is the safe path.
1738+
2. Otherwise fall back to a minimal in-process swap: keep every token
1739+
in the existing .compose-flags that is NOT an extension service -f
1740+
entry, then re-scan extensions/services for enabled compose.yaml
1741+
fragments and append them. This preserves all backend and GPU
1742+
overlays (--env-file, -f docker-compose.base.yml,
1743+
-f docker-compose.nvidia.yml, etc.) because those paths never
1744+
match 'extensions/services' and are kept verbatim.
1745+
1746+
The fallback intentionally mirrors only what the Windows installer
1747+
writes: base + GPU overlay + enabled extension compose.yaml entries.
1748+
It does NOT add GPU-specific per-extension overlays (compose.nvidia.yaml
1749+
etc.) because those are the canonical resolver's responsibility and
1750+
we must not silently diverge from it.
1751+
#>
1752+
$flagsFile = Join-Path $InstallDir ".compose-flags"
1753+
if (-not (Test-Path $flagsFile)) {
1754+
Write-AIWarn "No .compose-flags file found -- skipping regeneration."
1755+
return
1756+
}
1757+
1758+
# ── Path 1: delegate to the canonical resolver ────────────────────────────
1759+
$resolverScript = Join-Path (Join-Path $InstallDir "scripts") "resolve-compose-stack.sh"
1760+
$bashExe = Get-Command bash -ErrorAction SilentlyContinue
1761+
if ((Test-Path $resolverScript) -and $bashExe) {
1762+
# Read GPU_BACKEND and TIER from .env so the resolver uses the same
1763+
# parameters that the installer originally selected.
1764+
$gpuBackend = "nvidia"
1765+
$tier = "1"
1766+
try {
1767+
$envMap = Read-ODSEnv
1768+
if ($envMap.ContainsKey("GPU_BACKEND") -and $envMap["GPU_BACKEND"]) {
1769+
$gpuBackend = $envMap["GPU_BACKEND"].ToLower()
1770+
}
1771+
if ($envMap.ContainsKey("TIER") -and $envMap["TIER"]) {
1772+
$tier = $envMap["TIER"]
1773+
}
1774+
} catch { }
1775+
1776+
$wslInstallDir = $InstallDir -replace "\\", "/" -replace "^([A-Za-z]):", "/mnt/`$1"
1777+
$wslInstallDir = $wslInstallDir.ToLower() -replace "^/mnt/([a-z])", { "/mnt/$($_.Groups[1].Value.ToLower())" }
1778+
1779+
$resolvedFlagsRaw = & $bashExe.Source "$resolverScript" `
1780+
--script-dir "$InstallDir" `
1781+
--gpu-backend "$gpuBackend" `
1782+
--tier "$tier" `
1783+
2>$null
1784+
if ($LASTEXITCODE -eq 0 -and -not [string]::IsNullOrWhiteSpace($resolvedFlagsRaw)) {
1785+
# Prepend --env-file .env if the existing flags had it (the resolver
1786+
# emits only -f flags; the Windows installer adds --env-file separately).
1787+
$existingRaw = (Get-Content $flagsFile -Raw).Trim()
1788+
$newContent = $resolvedFlagsRaw.Trim()
1789+
if ($existingRaw -match '--env-file') {
1790+
$newContent = "--env-file .env " + $newContent
1791+
}
1792+
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
1793+
[System.IO.File]::WriteAllText($flagsFile, $newContent, $utf8NoBom)
1794+
Write-AI "Updated .compose-flags (via resolve-compose-stack.sh)"
1795+
return
1796+
}
1797+
Write-AIWarn "resolve-compose-stack.sh returned non-zero or empty output; falling back to minimal swap."
1798+
}
1799+
1800+
# ── Path 2: minimal in-process swap (fallback) ────────────────────────────
1801+
# Keep all tokens that are NOT an extension service -f entry, then
1802+
# re-append only the enabled compose.yaml fragments.
1803+
# This preserves --env-file, -f docker-compose.base.yml,
1804+
# -f docker-compose.nvidia.yml, and any other backend overlays verbatim.
1805+
$existing = (Get-Content $flagsFile -Raw).Trim() -split "\s+"
1806+
$baseFlags = New-Object System.Collections.Generic.List[string]
1807+
$skipNext = $false
1808+
for ($i = 0; $i -lt $existing.Count; $i++) {
1809+
if ($skipNext) { $skipNext = $false; continue }
1810+
if ($existing[$i] -eq "-f" -and ($i + 1) -lt $existing.Count) {
1811+
$nextVal = $existing[$i + 1]
1812+
# Strip extension service entries (compose.yaml and per-backend
1813+
# overlays such as compose.nvidia.yaml, compose.local.yaml).
1814+
if ($nextVal -match "extensions[/\\]services[/\\]") {
1815+
$skipNext = $true # also drop the path token that follows -f
1816+
continue
1817+
}
1818+
}
1819+
[void]$baseFlags.Add($existing[$i])
1820+
}
1821+
1822+
# Re-append only compose.yaml (the base fragment) for enabled extensions.
1823+
# Per-backend and local-mode overlays require the canonical resolver.
1824+
$extDir = Join-Path (Join-Path $InstallDir "extensions") "services"
1825+
if (Test-Path $extDir) {
1826+
Get-ChildItem -Path $extDir -Directory | Sort-Object Name | ForEach-Object {
1827+
$composePath = Join-Path $_.FullName "compose.yaml"
1828+
if (Test-Path $composePath) {
1829+
$relPath = $composePath.Substring($InstallDir.Length + 1) -replace "\\", "/"
1830+
[void]$baseFlags.Add("-f")
1831+
[void]$baseFlags.Add($relPath)
1832+
}
1833+
}
1834+
}
1835+
1836+
$newContent = $baseFlags -join " "
1837+
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
1838+
[System.IO.File]::WriteAllText($flagsFile, $newContent, $utf8NoBom)
1839+
Write-AI "Updated .compose-flags (fallback minimal swap)"
1840+
}
1841+
1842+
function Get-ExtensionServiceDir {
1843+
<#
1844+
.SYNOPSIS
1845+
Resolve the extension service directory for a given service ID.
1846+
Returns $null if not found.
1847+
#>
1848+
param([Parameter(Mandatory=$true)][string]$ServiceId)
1849+
1850+
$extDir = Join-Path (Join-Path $InstallDir "extensions") "services"
1851+
if (-not (Test-Path $extDir)) { return $null }
1852+
1853+
$svcDir = Join-Path $extDir $ServiceId
1854+
if (Test-Path $svcDir) { return $svcDir }
1855+
return $null
1856+
}
1857+
1858+
function Get-ExtensionCategory {
1859+
<#
1860+
.SYNOPSIS
1861+
Read the category field from manifest.yaml for a service directory.
1862+
Returns empty string if not found or unreadable.
1863+
#>
1864+
param([Parameter(Mandatory=$true)][string]$ServiceDir)
1865+
1866+
foreach ($manifestName in @("manifest.yaml", "manifest.yml")) {
1867+
$manifestPath = Join-Path $ServiceDir $manifestName
1868+
if (Test-Path $manifestPath) {
1869+
$line = Get-Content $manifestPath -ErrorAction SilentlyContinue |
1870+
Where-Object { $_ -match "^\s*category:" } |
1871+
Select-Object -First 1
1872+
if ($line) {
1873+
return (($line -split "category:")[1]).Trim().Trim('"').Trim("'")
1874+
}
1875+
}
1876+
}
1877+
return ""
1878+
}
1879+
1880+
function Test-ODSInstallFiles {
1881+
<#
1882+
.SYNOPSIS
1883+
Validate that the ODS install directory and compose files are present.
1884+
Does NOT require Docker Desktop to be running -- intentionally lighter
1885+
than Test-Install so that 'ods enable' works offline.
1886+
#>
1887+
if (-not (Test-Path $InstallDir)) {
1888+
Write-AIError "ODS not found at $InstallDir. Set ODS_HOME or run installer first."
1889+
exit 1
1890+
}
1891+
$baseCompose = Join-Path $InstallDir "docker-compose.base.yml"
1892+
$monoCompose = Join-Path $InstallDir "docker-compose.yml"
1893+
if (-not (Test-Path $baseCompose) -and -not (Test-Path $monoCompose)) {
1894+
Write-AIError "docker-compose.base.yml not found in $InstallDir"
1895+
exit 1
1896+
}
1897+
}
1898+
1899+
function Invoke-Enable {
1900+
<#
1901+
.SYNOPSIS
1902+
Enable an extension service -- mirrors 'ods enable <service>' from the Linux CLI.
1903+
Renames compose.yaml.disabled back to compose.yaml and regenerates .compose-flags.
1904+
Does NOT require Docker Desktop to be running (file-only operation).
1905+
#>
1906+
param([string]$ServiceId)
1907+
1908+
# Validate install files only -- Docker is not needed to rename a compose fragment.
1909+
Test-ODSInstallFiles
1910+
1911+
if ([string]::IsNullOrWhiteSpace($ServiceId)) {
1912+
Write-AIError "Usage: .\ods.ps1 enable <service>"
1913+
Write-AI "Example: .\ods.ps1 enable comfyui"
1914+
exit 1
1915+
}
1916+
1917+
$svcDir = Get-ExtensionServiceDir -ServiceId $ServiceId
1918+
if (-not $svcDir) {
1919+
Write-AIError "Unknown extension service: '$ServiceId'"
1920+
Write-AI "Check available services under: $(Join-Path (Join-Path $InstallDir 'extensions') 'services')"
1921+
exit 1
1922+
}
1923+
1924+
$category = Get-ExtensionCategory -ServiceDir $svcDir
1925+
if ($category -eq "core") {
1926+
Write-AISuccess "$ServiceId is a core service (always enabled)."
1927+
return
1928+
}
1929+
1930+
$composePath = Join-Path $svcDir "compose.yaml"
1931+
$disabledPath = Join-Path $svcDir "compose.yaml.disabled"
1932+
1933+
if (Test-Path $composePath) {
1934+
Write-AISuccess "$ServiceId is already enabled."
1935+
Write-AI "Run '.\ods.ps1 start $ServiceId' to launch it."
1936+
return
1937+
}
1938+
1939+
if (Test-Path $disabledPath) {
1940+
Rename-Item -LiteralPath $disabledPath -NewName "compose.yaml" -Force
1941+
Update-ComposeFlags
1942+
Write-AISuccess "$ServiceId enabled."
1943+
Write-AI "Run '.\ods.ps1 start $ServiceId' to launch it."
1944+
return
1945+
}
1946+
1947+
Write-AIError "No compose fragment found for '$ServiceId' (expected compose.yaml or compose.yaml.disabled)."
1948+
Write-AI "This may be a core service or the extension is not installed."
1949+
exit 1
1950+
}
1951+
1952+
function Invoke-Disable {
1953+
<#
1954+
.SYNOPSIS
1955+
Disable an extension service -- mirrors 'ods disable <service>' from the Linux CLI.
1956+
Stops the running container when Docker is available, then renames
1957+
compose.yaml to compose.yaml.disabled and regenerates .compose-flags.
1958+
The file/cache changes always run even when Docker Desktop is offline.
1959+
#>
1960+
param([string]$ServiceId)
1961+
1962+
# Validate install files only -- Docker stop is best-effort below.
1963+
Test-ODSInstallFiles
1964+
1965+
if ([string]::IsNullOrWhiteSpace($ServiceId)) {
1966+
Write-AIError "Usage: .\ods.ps1 disable <service>"
1967+
Write-AI "Example: .\ods.ps1 disable comfyui"
1968+
exit 1
1969+
}
1970+
1971+
$svcDir = Get-ExtensionServiceDir -ServiceId $ServiceId
1972+
if (-not $svcDir) {
1973+
Write-AIError "Unknown extension service: '$ServiceId'"
1974+
Write-AI "Check available services under: $(Join-Path (Join-Path $InstallDir 'extensions') 'services')"
1975+
exit 1
1976+
}
1977+
1978+
$category = Get-ExtensionCategory -ServiceDir $svcDir
1979+
if ($category -eq "core") {
1980+
Write-AIError "Cannot disable core service: $ServiceId"
1981+
exit 1
1982+
}
1983+
1984+
$composePath = Join-Path $svcDir "compose.yaml"
1985+
$disabledPath = Join-Path $svcDir "compose.yaml.disabled"
1986+
1987+
if (Test-Path $disabledPath) {
1988+
Write-AISuccess "$ServiceId is already disabled."
1989+
return
1990+
}
1991+
1992+
if (-not (Test-Path $composePath)) {
1993+
Write-AIError "No compose fragment found for '$ServiceId'."
1994+
exit 1
1995+
}
1996+
1997+
# Best-effort container stop -- skip gracefully when Docker Desktop is
1998+
# offline so the rename + flags update always succeeds.
1999+
$dockerRunning = $false
2000+
try { $null = docker info 2>$null; $dockerRunning = ($LASTEXITCODE -eq 0) } catch { }
2001+
if ($dockerRunning) {
2002+
$flags = Get-ComposeFlags
2003+
Write-AI "Stopping $ServiceId..."
2004+
$prevEAP = $ErrorActionPreference
2005+
$ErrorActionPreference = "SilentlyContinue"
2006+
& docker compose @flags stop $ServiceId 2>$null
2007+
$ErrorActionPreference = $prevEAP
2008+
} else {
2009+
Write-AIWarn "Docker Desktop is not running -- skipping container stop. $ServiceId will be excluded from the next 'ods start'."
2010+
}
2011+
2012+
# Rename and refresh flags regardless of Docker state.
2013+
Rename-Item -LiteralPath $composePath -NewName "compose.yaml.disabled" -Force
2014+
Update-ComposeFlags
2015+
Write-AISuccess "$ServiceId disabled."
2016+
Write-AI "Data preserved. Run '.\ods.ps1 enable $ServiceId' to re-enable."
2017+
}
2018+
17252019
function Show-Help {
17262020
Write-Host ""
17272021
Write-Host " ODS CLI (Windows)" -ForegroundColor Green
@@ -1753,6 +2047,10 @@ function Show-Help {
17532047
Write-Host "Diagnose runtime readiness" -ForegroundColor DarkGray
17542048
Write-Host " repair voice " -ForegroundColor Cyan -NoNewline
17552049
Write-Host "Start voice services and cache STT model" -ForegroundColor DarkGray
2050+
Write-Host " enable <service> " -ForegroundColor Cyan -NoNewline
2051+
Write-Host "Enable an extension service (e.g. comfyui, langfuse)" -ForegroundColor DarkGray
2052+
Write-Host " disable <service> " -ForegroundColor Cyan -NoNewline
2053+
Write-Host "Disable an extension service" -ForegroundColor DarkGray
17562054
Write-Host " agent [action] " -ForegroundColor Cyan -NoNewline
17572055
Write-Host "Host agent: status|start|stop|restart|logs" -ForegroundColor DarkGray
17582056
Write-Host " report " -ForegroundColor Cyan -NoNewline
@@ -1767,6 +2065,8 @@ function Show-Help {
17672065
Write-Host " .\ods.ps1 logs llama-server 50" -ForegroundColor DarkGray
17682066
Write-Host " .\ods.ps1 restart open-webui" -ForegroundColor DarkGray
17692067
Write-Host " .\ods.ps1 repair voice" -ForegroundColor DarkGray
2068+
Write-Host " .\ods.ps1 enable comfyui" -ForegroundColor DarkGray
2069+
Write-Host " .\ods.ps1 disable langfuse" -ForegroundColor DarkGray
17702070
Write-Host " .\ods.ps1 chat `"What is quantum computing?`"" -ForegroundColor DarkGray
17712071
Write-Host ""
17722072
}
@@ -1798,6 +2098,8 @@ switch ($Command.ToLower()) {
17982098
"update" { Invoke-Update }
17992099
"doctor" { Invoke-Doctor }
18002100
"repair" { Invoke-Repair -Target ($Arguments | Select-Object -First 1) }
2101+
"enable" { Invoke-Enable -ServiceId ($Arguments | Select-Object -First 1) }
2102+
"disable" { Invoke-Disable -ServiceId ($Arguments | Select-Object -First 1) }
18012103
"report" { Invoke-Report }
18022104
"agent" {
18032105
$action = ($Arguments | Select-Object -First 1)

0 commit comments

Comments
 (0)