install.ps1
Rama main · réplica de lectura del repositorio original.
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
try {
[Console]::OutputEncoding = [Text.Encoding]::UTF8
$OutputEncoding = [Text.Encoding]::UTF8
} catch {}
# windows PowerShell 5.1 still opens with TLS 1.0/1.1 enabled on
# plenty of boxes. everything this script downloads (PHP, the CA
# bundle, PSGallery) is https, so ask for 1.2 and up and nothing
# older.
try {
[Net.ServicePointManager]::SecurityProtocol =
[Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13
} catch {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
}
$repoUpstream = 'https://github.com/efficiencyx/JunOS.git'
$repo = if ($env:JUN_REPO) { $env:JUN_REPO } else { $repoUpstream }
$dir = if ($env:JUN_DIR) { $env:JUN_DIR } else { 'JunOS' }
# JUN_REF pins an exact branch or tag and skips the channel
# lookup. without it JUN_CHANNEL (or the .env of an existing
# install) decides, see Resolve-Channel.
$ref = if ($env:JUN_REF) { $env:JUN_REF } else { '' }
# JUN_PIN_SHA is the commit $ref has to be. a versioned
# JunSetup-<tag>.exe sets both, so that exe installs its own
# version and nothing else, even if the tag moved under it.
$pinSha = "$env:JUN_PIN_SHA".Trim().ToLower()
$handoff = $env:JUN_HANDOFF -eq '1'
# windows terminal, VS Code and modern conhost all handle VT
# sequences (the ANSI escape codes behind the colours).
# anything else gets plain unstyled text.
$VTSupported = $false
if ($Host.UI.SupportsVirtualTerminal -or $env:WT_SESSION -or
$env:TERM_PROGRAM -eq 'vscode' -or $PSVersionTable.PSVersion.Major -ge 7) {
$VTSupported = $true
}
if ($VTSupported) {
$e = [char]27
$R = "$e[0m"
$B = "$e[1m"
$D = "$e[2m"
$ACCENT = "$e[38;2;96;205;255m" # #60CDFF Fluent light blue
$BLUE = "$e[38;2;0;120;212m" # #0078D4 Windows system blue
$OK = "$e[38;2;108;203;95m" # #6CCB5F Fluent green
$DANGER = "$e[38;2;255;76;76m" # #FF4C4C Fluent red
$WARN = "$e[38;2;252;225;0m" # #FCE100 Fluent amber
$DIM = "$e[38;2;158;158;158m" # #9E9E9E Neutral gray
$MUTED = "$e[38;2;204;204;204m" # #CCCCCC Light gray
$FRAME = "$e[38;2;80;80;80m" # #505050 Card border
} else {
$R = ''; $B = ''; $D = ''; $ACCENT = ''; $BLUE = ''; $OK = ''
$DANGER = ''; $WARN = ''; $DIM = ''; $MUTED = ''; $FRAME = ''
}
$UI_W = 54
function _Rule([string]$ch) { $ch * $UI_W }
function Show-Banner {
$blank = ' ' * $UI_W
$bar = _Rule '═'
Write-Host ''
Write-Host " ${FRAME}╔${bar}╗${R}"
Write-Host " ${FRAME}║${R}${blank}${FRAME}║${R}"
Write-Host " ${FRAME}║${R} ${B}${BLUE}Ω${R} ${B}${ACCENT}JUN OS${R}$(' ' * ($UI_W - 13))${FRAME}║${R}"
Write-Host " ${FRAME}║${R}${blank}${FRAME}║${R}"
Write-Host " ${FRAME}║${R} ${MUTED}Welcome to Jun OS${R} ${DIM}·${R} ${DIM}omega build${R}$(' ' * ($UI_W - 35))${FRAME}║${R}"
Write-Host " ${FRAME}║${R} ${DIM}Windows installer${R}$(' ' * ($UI_W - 21))${FRAME}║${R}"
Write-Host " ${FRAME}║${R}${blank}${FRAME}║${R}"
Write-Host " ${FRAME}╚${bar}╝${R}"
Write-Host ''
}
function Step([string]$msg) { Write-Host " ${ACCENT}▸${R} ${B}${msg}${R}" }
function Ok([string]$msg) { Write-Host " ${OK}✓${R} ${MUTED}${msg}${R}" }
function Note([string]$msg) { Write-Host " ${DIM}ℹ ${msg}${R}" }
function Warn_([string]$msg) { Write-Host " ${WARN}⚠${R} ${WARN}${msg}${R}" }
function Fail_([string]$msg) { Write-Host " ${DANGER}✗${R} ${DANGER}${msg}${R}" }
# read a line after our own prompt. Read-Host would stick a ': '
# on the end.
function Read-Styled([string]$prompt) {
Write-Host -NoNewline $prompt
try { $line = [Console]::ReadLine() }
catch { $line = Read-Host }
# " 2 " and "Y " are still a 2 and a yes. ReadLine is $null on a
# closed stdin, which every caller already treats as Enter.
if ($null -eq $line) { return '' }
return $line.Trim()
}
# "Run with PowerShell" opens a window that closes the moment we
# exit. error included. so on a failure we wait for Enter. not
# when the GUI drives us (output redirected, it shows the log
# itself) and not in a handoff child, the parent waits for it.
function Exit-Install([int]$code) {
if ($code -ne 0 -and -not $handoff -and [Environment]::UserInteractive) {
$redirected = $true
try { $redirected = [Console]::IsInputRedirected -or [Console]::IsOutputRedirected } catch {}
if (-not $redirected) {
Write-Host ''
Read-Styled " ${DIM}press Enter to exit${R} " | Out-Null
}
}
exit $code
}
# anything nobody caught. without this PowerShell dumps a red
# stack trace, and from "Run with PowerShell" the window is gone
# before anyone reads it.
trap {
Write-Host ''
Fail_ ("setup stopped: {0}" -f $_.Exception.Message)
Note 'fix what it says above, then run the same install line again. it picks up where it stopped.'
Note 'still stuck? open an issue with this whole window copied in: https://github.com/efficiencyx/JunOS/issues'
Exit-Install 1
}
# git through here, never bare with 2>&1. Windows PowerShell 5.1
# with $ErrorActionPreference='Stop' turns the first stderr line of
# a native command into a terminating NativeCommandError, and git
# writes progress and hints to stderr even when it works. output
# comes back as plain strings, $LASTEXITCODE is git's.
function Invoke-Git {
$prev = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
return @(& git @args 2>&1 | ForEach-Object {
if ($_ -is [Management.Automation.ErrorRecord]) { $_.Exception.Message } else { "$_" }
})
} finally {
$ErrorActionPreference = $prev
}
}
# ollama isn't here on purpose, Install-Ollama fetches it
# straight from ollama.com. see there for why.
$wingetIds = @{ git = 'Git.Git'; python = 'Python.Python.3.11'; llamacpp = 'ggml.llamacpp' }
# llama.cpp moves fast and start.ps1 runs it in router mode with
# --fit, so it's pinned to the build docker runs (11804). older
# than 10448 and llama-server starts, then dies on --models-preset.
$wingetVersions = @{ llamacpp = 'b11804' }
$LlamacppMinBuild = 11804
$manualUrls = @{
git = 'https://git-scm.com/download/win'
ollama = 'https://ollama.com/download/windows'
python = 'https://www.python.org/downloads/windows/'
llamacpp = 'https://github.com/ggml-org/llama.cpp/releases'
}
# menu picks MUST get the quant Recommend-Alias named
function Get-QuantFor([string]$size, [int]$mb) {
switch ($size) {
'12B' { if ($mb -ge 23500) { return 'Q8_0' }; if ($mb -ge 15500) { return 'Q6_K' } }
'E4B' { if ($mb -ge 9500) { return 'Q8_0' } }
'E2B' { if ($mb -ge 7500) { return 'Q8_0' }; if ($mb -ge 5500) { return 'Q6_K' } }
}
return 'Q4_K_M'
}
function Resolve-Model([string]$a, [int]$mb = 0) {
$size = switch -Regex ($a.ToLower()) {
'^(12b|jun|best)$' { '12B' }
'^(e4b|balanced|fast)$' { 'E4B' }
'^(e2b|fastest|cpu)$' { 'E2B' }
}
if (-not $size) { return $a }
return "hf.co/efficiencyx/Jun-LoRA-$size-GGUF:$(Get-QuantFor $size $mb)"
}
$ggufGb = @{
'12B:Q4_K_M' = '7.4'; '12B:Q6_K' = '9.8'; '12B:Q8_0' = '12.7'
'E4B:Q4_K_M' = '5.3'; 'E4B:Q6_K' = '6.2'; 'E4B:Q8_0' = '8.0'
'E2B:Q4_K_M' = '3.4'; 'E2B:Q6_K' = '3.8'; 'E2B:Q8_0' = '4.9'
}
# Enter keeps the quant $modelRef already has
function Ask-Quant([string]$modelRef) {
$cut = $modelRef.LastIndexOf(':')
$base = $modelRef.Substring(0, $cut)
$rec = $modelRef.Substring($cut + 1)
$size = $base -replace '^.*Jun-LoRA-', '' -replace '-GGUF$', ''
$quants = @('Q4_K_M', 'Q6_K', 'Q8_0')
$notes = @{ 'Q4_K_M' = ', lightest'; 'Q6_K' = ''; 'Q8_0' = ', closest to the full model' }
Write-Host ''
Write-Host " ${B}select a quant for Jun ${size}${R}"
for ($i = 0; $i -lt $quants.Count; $i++) {
$q = $quants[$i]
$note = $notes[$q]
if ($q -eq $rec) { $note += ' (recommended)' }
Write-Host (" ${ACCENT}[$($i + 1)]${R} {0,-7} ${DIM}- $($ggufGb["${size}:$q"]) GB${note}${R}" -f $q)
}
$ans = (Read-Styled " ${OK}▸${R} choice ${DIM}[Enter = recommended ${rec}]${R} ${ACCENT}›${R} ").ToUpper()
$pick = switch -Regex ($ans) {
'^(1|Q4_K_M)$' { 'Q4_K_M' }
'^(2|Q6_K)$' { 'Q6_K' }
'^(3|Q8_0)$' { 'Q8_0' }
'^$' { $rec }
default { Warn_ "unrecognized choice, using $rec"; $rec }
}
return "${base}:$pick"
}
# match the drafter, the little model that guesses tokens
# ahead, to the same Gemma 4 size and QAT branch (QAT is
# quantization-aware training, the -qat- repos). a mismatch
# still Runs but accepted tokens dropped from 2.74 to 2.10 per
# pass. QAT and plain repos are not interchangeable.
$mtpDrafters = @{
'12b' = 'hf.co/Janvitos/gemma-4-12B-it-qat-assistant-MTP-Q8_0-GGUF:Q8_0'
'e4b' = 'hf.co/amaranus/Gemma-4-E4B-it-qat-assistant-MTP-Q8_0-GGUF:Q8_0'
'e2b' = 'hf.co/amaranus/Gemma-4-E2B-it-qat-assistant-MTP-Q8_0-GGUF:Q8_0'
}
# Live2D is drawn by the browser on the same card Jun sits on,
# and it wants about this much while a chat is open. leave it out
# of the budget and the install looks fine right up until she
# spills onto the CPU the moment somebody opens the tab.
$LIVE2D_VRAM_MB = 1500
function Get-MtpDrafter([string]$modelRef) {
switch -Regex ($modelRef) {
'Jun-LoRA-12B' { return $mtpDrafters['12b'] }
'E4B' { return $mtpDrafters['e4b'] }
'E2B' { return $mtpDrafters['e2b'] }
default { return '' }
}
}
# roughly what each model weighs once it's resident, drafter
# included. close enough to tell "fits" from "doesn't", which is
# all we use it for.
function Get-MtpBudgetMb([string]$modelRef) {
switch -Regex ($modelRef) {
'Jun-LoRA-12B.*Q8_0' { return 13500 }
'Jun-LoRA-12B.*Q6_K' { return 10800 }
'Jun-LoRA-12B' { return 8100 }
'E4B.*Q8_0' { return 9000 }
'E4B.*Q6_K' { return 6500 }
'E4B' { return 5000 }
'E2B.*Q8_0' { return 5600 }
'E2B.*Q6_K' { return 4500 }
'E2B' { return 3400 }
default { return 0 }
}
}
# no nvidia-smi on the AMD side, so we ask Windows. and Windows
# gets it wrong in two places.
#
# AdapterRAM (WMI) is 32 bit, every card past 4GB comes back as
# 4095 MB. the real size is the registry's 64 bit
# HardwareInformation.qwMemorySize under Control\Video\{guid}\000x.
# finding the right key is the cursed part. AdapterString is
# REG_BINARY on some drivers and a generic "AMD Radeon Graphics
# Processor (0x7550)" on others, so matching it against the WMI
# name missed a 9070 XT and the installer sized a 16GB card as 4GB.
# match on the PCI vendor+device id instead (MatchingDeviceId vs
# PNPDeviceID), DriverDesc as the backup.
#
# one lookup per card, so an iGPU that matches can't hide the dGPU
# that didn't. a capped AdapterRAM only says "4GB or more", that's
# not a size, it gets dropped. under 1GB is an APU carveout, not a
# card (same rule as amd_vram_mb_list in install.sh).
#
# keep this byte for byte the same as the copies in installer-gui.ps1 and start.ps1.
function Get-AmdMemoryMb([object[]]$controllers) {
$entries = @(Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Video\*\000*' -ErrorAction SilentlyContinue)
$capMb = 4095
return @($controllers | ForEach-Object {
$card = $_
$pci = if ([string]$card.PNPDeviceID -match '(?i)^PCI\\VEN_[0-9A-F]{4}&DEV_[0-9A-F]{4}') { $Matches[0] } else { '' }
$keys = @($entries | Where-Object {
($pci -and ([string]$_.MatchingDeviceId) -like "$pci*") -or
([string]$_.DriverDesc -eq [string]$card.Name)
})
$mb = @($keys | ForEach-Object {
$memory = $_.'HardwareInformation.qwMemorySize'
if ($memory -is [byte[]] -and $memory.Length -ge 8) {
[BitConverter]::ToUInt64($memory, 0)
} elseif ($null -ne $memory -and $memory -isnot [byte[]]) {
[uint64]$memory
}
} | Where-Object { $_ -gt 0 } | ForEach-Object { [int]($_ / 1MB) } |
Sort-Object -Descending | Select-Object -First 1)
if ($mb.Count -eq 0 -and $card.AdapterRAM -gt 0) {
$wmiMb = [int]([uint64]$card.AdapterRAM / 1MB)
if ($wmiMb -lt $capMb) { $mb = @($wmiMb) }
}
$mb
} | Where-Object { $_ -ge 1024 })
}
function Get-GpuMemoryMb {
if (Get-Command nvidia-smi -ErrorAction SilentlyContinue) {
$out = & nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>$null
$mb = @($out | Where-Object { $_ -match '\d' } | ForEach-Object { [int]($_.Trim()) })
if ($mb.Count -gt 0) { return $mb }
}
try {
$amd = @(Get-CimInstance Win32_VideoController -ErrorAction Stop |
Where-Object { $_.Name -match '(?i)AMD|Radeon' })
return @(Get-AmdMemoryMb $amd)
} catch {
return @()
}
}
function Get-VramMb {
$mb = @(Get-GpuMemoryMb)
if ($mb.Count -eq 0) { return $null }
return [int](($mb | Measure-Object -Maximum).Maximum)
}
function Get-VramTotalMb {
$mb = @(Get-GpuMemoryMb)
if ($mb.Count -eq 0) { return $null }
return [int](($mb | Measure-Object -Sum).Sum)
}
function Get-GpuCount { return @(Get-GpuMemoryMb).Count }
function Recommend-Alias([int]$mb) {
if ($mb -ge 11500) { return Resolve-Model '12b' $mb }
if ($mb -ge 7500) { return Resolve-Model 'e4b' $mb }
return Resolve-Model 'e2b' $mb
}
function Set-EnvKey([string]$key, [string]$val) {
$lines = @()
if (Test-Path .env) { $lines = Get-Content .env | Where-Object { $_ -notmatch "^$key=" } }
$lines += "$key=$val"
Set-Content -Path .env -Value $lines
}
function Get-EnvValue([string]$key, [string]$file = '.env') {
if (-not (Test-Path -LiteralPath $file)) { return '' }
$line = Get-Content -LiteralPath $file | Where-Object { $_ -match "^$key=" } | Select-Object -Last 1
if (-not $line) { return '' }
return $line.Substring($line.IndexOf('=') + 1)
}
function New-AccessKey {
$bytes = [byte[]]::new(16)
if ($PSVersionTable.PSVersion.Major -ge 7) {
[Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
} else {
# windows PowerShell 5.1 runs on .NET Framework and has no static
# Fill. calling it there throws and takes the whole install down.
[Security.Cryptography.RandomNumberGenerator]::Create().GetBytes($bytes)
}
return (($bytes | ForEach-Object { $_.ToString('x2') }) -join '')
}
# only when the line is missing altogether. an empty
# OMEGA_REGISTRATION_KEY= is the operator saying "off", and every
# upgrade run comes back through here, so filling that in would
# silently turn the gate back on behind their back.
function Add-EnvKeyIfMissing([string]$key) {
if ((Test-Path .env) -and (Get-Content .env | Where-Object { $_ -match "^$key=" })) { return }
Set-EnvKey $key (New-AccessKey)
}
$interactive = [Environment]::UserInteractive -and ($env:JUN_YES -ne '1')
# true only when a person picked Express at the keyboard. JUN_YES
# on its own can also mean an unattended run (CI, or
# installer-gui.ps1 driving this script with its output
# redirected). the moment something needs asking, the person and
# the unattended run want opposite things. a person can answer.
# a redirected run just Hangs on Read-Host with nobody to see it.
$script:expressInteractive = $false
# Cloud: the model runs on colab.ipynb (or Kaggle) in inference
# only mode, everything else here. cloudAsk is a person at the
# keyboard, who still gets asked the notebook lines, voice and
# karaoke while the rest goes Express.
$script:cloud = $env:JUN_CLOUD -match '^(1|on|yes|true)$'
$script:cloudAsk = $handoff -and $env:JUN_CLOUD_ASK -eq '1'
function Test-Sha256([string]$path, [string]$expected) {
if (-not $expected) { return $false }
return (Get-FileHash -Algorithm SHA256 -LiteralPath $path).Hash -ieq $expected.Trim()
}
# JUN_REPO lets people install from a fork. so https only, and
# anything that isn't upstream needs an explicit yes.
# $env:JUN_ALLOW_FORK='1' is that yes for unattended runs.
# JUN_YES doesn't count, on purpose.
function Confirm-RepoSource {
if ($repo -notmatch '^https://') {
Fail_ "JUN_REPO must be an https:// URL - refusing to clone $repo"
Exit-Install 1
}
if ($repo -eq $repoUpstream) { return }
Warn_ 'JUN_REPO points somewhere other than upstream:'
Note " yours: $repo"
Note " upstream: $repoUpstream"
if ($env:JUN_ALLOW_FORK -eq '1') {
Ok 'JUN_ALLOW_FORK=1 - installing from the fork'
return
}
if (-not [Environment]::UserInteractive) {
Fail_ "set `$env:JUN_ALLOW_FORK='1' to install from a fork non-interactively."
Exit-Install 1
}
$a = Read-Styled " ${OK}▸${R} clone from it anyway? ${DIM}[y/N]${R} ${ACCENT}›${R} "
if ($a -notmatch '^(y|yes)$') { Fail_ 'aborted.'; Exit-Install 1 }
}
# .env holds the OpenRouter key once somebody types one in.
# windows hands new files the folder's inherited ACL, which on a
# shared box can mean every local account reads it. so break
# inheritance, then owner + SYSTEM only.
function Protect-EnvFile {
if (-not (Test-Path .env)) { return }
$failed = $false
try {
$me = [Security.Principal.WindowsIdentity]::GetCurrent().Name
icacls .env /inheritance:r /grant:r "${me}:(F)" "SYSTEM:(F)" | Out-Null
# icacls is a native exe, so a failure here is an exit code and not
# an exception, and the catch below would never see it.
$failed = $LASTEXITCODE -ne 0
} catch {
$failed = $true
}
if ($failed) {
Warn_ 'could not lock down .env permissions - check who can read it if you put an API key in.'
}
}
# the first thing someone non technical sees. Express installs
# the lot with what we detected and asks nothing else, same as
# JUN_YES=1. Custom walks the prompts. Cloud is Express minus
# the model. JUN_EXPRESS=1 / JUN_CLOUD=1 pick one before we even
# ask.
function Enter-CloudMode {
$env:JUN_YES = '1'; $env:JUN_EXPRESS = '1'; $env:JUN_CLOUD = '1'; $env:JUN_CLOUD_ASK = '1'
$script:interactive = $false
$script:expressInteractive = $true
$script:cloud = $true; $script:cloudAsk = $true
Ok "cloud install - no model here, I'll ask for the notebook's lines"
}
# Express alone puts llama.cpp and karaoke back over what they
# picked. a yes skips Configure-Jun and .env stays as it is
function Choose-Reuse([string]$existing) {
if (-not $existing) { return }
$envFile = Join-Path $existing '.env'
if (-not (Get-EnvValue 'AI_PROVIDER' $envFile)) { return }
if ($env:JUN_REUSE -match '^(0|off|no|false)$') { return }
if ($env:JUN_REUSE -notmatch '^(1|on|yes|true)$') {
if ($handoff -or $env:JUN_EXPRESS -or $env:JUN_CLOUD -or $env:JUN_YES) { return }
if ([Environment]::UserInteractive) {
Write-Host ''
Write-Host " ${B}found Jun in $existing${R}"
$ans = Read-Styled " ${OK}▸${R} update her with the settings she has now? ${DIM}[Y/n, n = set up again]${R} ${ACCENT}›${R} "
if ($ans -match '^(n|no)$') { $env:JUN_REUSE = '0'; return }
}
}
$script:reuse = $true
$env:JUN_REUSE = '1'; $env:JUN_YES = '1'
$script:interactive = $false
Ok 'updating with the settings in .env'
}
function Choose-InstallMode {
if ($script:cloud -and -not $handoff) {
$env:JUN_CLOUD = '1'
if ($env:JUN_YES -eq '1' -or -not [Environment]::UserInteractive) { return }
Enter-CloudMode
return
}
# a handoff child takes the answer the parent already got.
if ($handoff -and $env:JUN_EXPRESS -eq '1') {
$script:interactive = $false
$script:expressInteractive = [Environment]::UserInteractive
return
}
if ($handoff -and $env:JUN_EXPRESS -eq '0') { return }
if ($env:JUN_YES -eq '1') { return }
if ($env:JUN_EXPRESS -match '^(1|on|yes|true)$') {
$env:JUN_YES = '1'; $env:JUN_EXPRESS = '1'; $script:interactive = $false
$script:expressInteractive = [Environment]::UserInteractive
return
}
if (-not [Environment]::UserInteractive) { return }
Write-Host ''
Write-Host " ${B}how should I install?${R}"
Write-Host " ${ACCENT}[1]${R} Express ${DIM}- everything with recommended settings${R} ${DIM}(default)${R}"
Write-Host " ${ACCENT}[2]${R} Custom ${DIM}- pick the provider, model, voice, and more${R}"
Write-Host " ${ACCENT}[3]${R} Cloud ${DIM}- everything but the model, she thinks on a Colab/Kaggle notebook. chats go there${R}"
$ans = Read-Styled " ${OK}▸${R} choice ${DIM}[Enter = Express]${R} ${ACCENT}›${R} "
if ($ans -match '^(2|custom)$') {
$env:JUN_EXPRESS = '0'
Note "custom install - I'll ask about each option below"
} elseif ($ans -match '^(3|cloud)$') {
Enter-CloudMode
} else {
$env:JUN_YES = '1'; $env:JUN_EXPRESS = '1'; $script:interactive = $false
$script:expressInteractive = $true
Ok 'express install - using recommended settings'
}
}
function Ask-ModelRef {
$gpus = Get-GpuCount
$vram = if ($script:tensorParallel -eq 'on') { Get-VramTotalMb } else { Get-VramMb }
$rec = if ($null -ne $vram) { Recommend-Alias $vram } else { Resolve-Model 'e2b' }
$alias = $env:JUN_MODEL
if (-not $alias) {
if ($interactive) {
Write-Host ''
Write-Host " ${B}select a model${R}"
Write-Host " ${ACCENT}[1]${R} Jun 12B ${DIM}- highest quality${R}"
Write-Host " ${ACCENT}[2]${R} Jun E4B ${DIM}- balanced${R}"
Write-Host " ${ACCENT}[3]${R} Jun E2B ${DIM}- lightest / CPU-friendly${R}"
if ($null -ne $vram) {
$vramNote = ''
if ($gpus -ge 2) {
$vramNote = if ($script:tensorParallel -eq 'on') { " across $gpus GPUs" } else { ' on the largest GPU' }
}
Write-Host " ${DIM}detected ${vram}MB VRAM${vramNote}${R}"
}
$ans = Read-Styled " ${OK}▸${R} choice ${DIM}[Enter = recommended ${rec}]${R} ${ACCENT}›${R} "
switch ($ans) {
'1' { $alias = '12b' }
'2' { $alias = 'e4b' }
'3' { $alias = 'e2b' }
'' { $alias = $rec }
{ $_ -match '^(12b|e4b|e2b)$' } { $alias = $ans }
default { Warn_ 'unrecognized choice, using recommended model'; $alias = $rec }
}
return Ask-Quant (Resolve-Model $alias $(if ($null -ne $vram) { $vram } else { 0 }))
} else {
$alias = $rec
}
}
return Resolve-Model $alias $(if ($null -ne $vram) { $vram } else { 0 })
}
function Ask-TensorParallel {
if ($env:JUN_TENSOR_PARALLEL) {
return $(if ($env:JUN_TENSOR_PARALLEL.ToLower() -match '^(on|1|true|yes|y)$') { 'on' } else { 'off' })
}
if (-not $interactive -or (Get-GpuCount) -lt 2) { return 'off' }
$v = Read-Styled " ${OK}▸${R} use all GPUs for one model? ${DIM}(fits bigger models, but slower per token)${R} ${DIM}[y/N]${R} ${ACCENT}›${R} "
return $(if ($v -match '^(y|yes)$') { 'on' } else { 'off' })
}
# JUN_KARAOKE first, then KARAOKE, same order install.sh takes.
# keep both spellings working, people copy install lines between
# the two scripts.
function Ask-Karaoke([string]$voice) {
$preset = if ($env:JUN_KARAOKE) { $env:JUN_KARAOKE } else { $env:KARAOKE }
if ($preset) {
return $(if ($preset.ToLower() -match '^(off|0|false|no|n)$') { 'off' } else { 'on' })
}
if ($voice -ne 'on') { return 'off' }
if (-not ($interactive -or $script:cloudAsk)) { return 'on' }
$v = Read-Styled " ${OK}▸${R} enable karaoke ${DIM}(sing along - adds a few GB)${R} ${DIM}[Y/n]${R} ${ACCENT}›${R} "
return $(if ($v -match '^(n|no)$') { 'off' } else { 'on' })
}
# JUN_SHORTCUTS first, then what .env says from last time, so a
# no in Custom sticks across re-runs instead of Express quietly
# putting the icons back.
function Ask-Shortcuts {
$preset = if ($env:JUN_SHORTCUTS) { $env:JUN_SHORTCUTS } else { Get-EnvValue 'JUN_SHORTCUTS' }
if ($preset) {
return $(if ($preset.ToLower() -match '^(off|0|false|no|n)$') { 'off' } else { 'on' })
}
if (-not $interactive) { return 'on' }
$v = Read-Styled " ${OK}▸${R} put a Jun OS shortcut on the desktop and in the Start menu? ${DIM}[Y/n]${R} ${ACCENT}›${R} "
return $(if ($v -match '^(n|no)$') { 'off' } else { 'on' })
}
# start.ps1 asks GitHub about releases on every start when this
# is on, so somebody has to say yes. Express asks too. a run with
# nobody to answer (JUN_YES alone, the GUI) writes nothing unless
# JUN_UPDATE_CHECK says, and the check stays off.
function Ask-UpdateCheck {
$val = switch -Regex ("$env:JUN_UPDATE_CHECK".ToLower()) {
'^(1|on|yes|true)$' { 'on' }
'^(0|off|no|false)$' { 'off' }
default { Get-EnvValue 'UPDATE_CHECK' }
}
if (-not $val) {
if (-not ($interactive -or $script:expressInteractive)) {
Note 'update check off - JUN_UPDATE_CHECK=on turns it on'
return
}
$a = Read-Styled " ${OK}▸${R} enable the update checker, for new releases and security fixes? ${DIM}(asks GitHub when she starts. it only warns you, it never updates anything for you)${R} ${DIM}[Y/n]${R} ${ACCENT}›${R} "
$val = if ($a -match '^(n|no)$') { 'off' } else { 'on' }
}
Set-EnvKey 'UPDATE_CHECK' $val
Ok "update check $val"
}
# MTP is multi-token prediction, the drafter guesses tokens and
# Jun checks every one of them. so it only changes speed, what she
# actually says comes out the same. how much speed depends on the
# card. Express turns it on and measures depth.
# unattended: JUN_MTP=on|off, JUN_MTP_DEPTH=auto|1|2|3|4.
function Ask-Mtp {
if ($env:JUN_MTP) {
return $(if ($env:JUN_MTP.ToLower() -match '^(on|1|true|yes|y)$') { 'on' } else { 'off' })
}
if (-not $interactive) { return 'on' }
$v = Read-Styled " ${OK}▸${R} enable experimental multi-token prediction? ${DIM}(speculative decoding - faster on some cards, slower on others)${R} ${DIM}[y/N]${R} ${ACCENT}›${R} "
return $(if ($v -match '^(y|yes)$') { 'on' } else { 'off' })
}
# auto measures instead of guessing, and it's the right answer
# for almost everybody. the best depth swings wildly with the
# card. on a 3060 the gain is gone by 3 and depth 4 is slower
# than not drafting at all.
function Ask-MtpDepth {
if ($env:JUN_MTP_DEPTH) {
return $(if ($env:JUN_MTP_DEPTH -match '^[1-4]$') { $env:JUN_MTP_DEPTH } else { 'auto' })
}
if (-not $interactive) { return 'auto' }
Write-Host ''
Write-Host " ${B}how many tokens should it guess ahead?${R}"
Write-Host " ${ACCENT}auto${R} ${DIM}- try each one on your hardware and keep the fastest (recommended)${R}"
Write-Host " ${ACCENT}1-4${R} ${DIM}- fix it yourself; deeper guesses cost more to check${R}"
$v = Read-Styled " ${OK}▸${R} choice ${DIM}[Enter = auto]${R} ${ACCENT}›${R} "
if ($v -match '^[1-4]$') { return $v }
if ($v -and $v -notmatch '^(a|auto)$') { Warn_ 'unrecognized choice, measuring instead' }
return 'auto'
}
# ask, then write whichever pair of keys this provider reads.
# returns $true when the depth still has to be measured, which
# can only happen once the stack is up and the models are pulled.
function Configure-Mtp([string]$provider, [string]$modelRef) {
$drafter = Get-MtpDrafter $modelRef
# only Gemma 4 ships an MTP head, and only for the sizes mapped
# above. on anything else there's no drafter to pair, so there's
# no question to ask.
if (-not $drafter) { return $false }
if ((Ask-Mtp) -ne 'on') { Ok 'multi-token prediction off'; return $false }
$depth = Ask-MtpDepth
$vram = if ($script:tensorParallel -eq 'on') { Get-VramTotalMb } else { Get-VramMb }
$budget = Get-MtpBudgetMb $modelRef
if ($null -ne $vram -and $budget -gt 0) {
if (($vram - $budget - $LIVE2D_VRAM_MB) -lt 0) {
Warn_ "with Live2D drawing (~${LIVE2D_VRAM_MB}MB) she won't fit on the card - some layers"
Note " run on the CPU. Drafting helps MORE there, +31% measured against +21%"
Note " fully on the GPU, so this stays worth having. Everything is just slower."
}
}
$autotune = ($depth -eq 'auto')
# .env only ever holds a NUMBER. the entrypoint bakes this
# straight into a Modelfile as draft_num_predict, and "auto" in
# there is a broken model, not a default. 1 is the provisional
# pick, the autotune overwrites it with whatever actually won.
$written = if ($autotune) { '1' } else { $depth }
if ($provider -eq 'ollama') {
Set-EnvKey 'OLLAMA_MTP' $drafter
Set-EnvKey 'OLLAMA_MTP_N_MAX' $written
} else {
# llama-server's -hfd takes a bare repo. no hf.co in front, no
# quant tag. these drafter repos hold a single gguf each.
Set-EnvKey 'LLAMACPP_MTP' (($drafter -replace '^hf\.co/', '') -replace ':.*$', '')
Set-EnvKey 'LLAMACPP_MTP_N_MAX' $written
}
if ($autotune) {
Ok 'multi-token prediction on, depth measured after the models are pulled'
} else {
Ok "multi-token prediction on, drafting $depth token(s) ahead"
}
return $autotune
}
# what colab.ipynb prints in inference only mode. only these
# names get into .env, the rest of a paste is dropped.
$CloudKeys = @('AI_PROVIDER', 'OLLAMA_URL', 'OLLAMA_API_KEY', 'OLLAMA_MODELS_TO_PULL', 'TITLE_MODEL',
'OMEGA_NUM_CTX', 'VLLM_URL', 'VLLM_API_KEY', 'VLLM_MODEL')
# the lines come from a paste, or from the environment on an
# unattended run (same names, the GUI sends them that way). no
# paste keeps whatever a previous run left in .env.
function Configure-Cloud {
if ($script:cloudAsk) {
Write-Host ''
Write-Host " ${B}paste the lines from the notebook${R}"
Write-Host " ${DIM}colab.ipynb or Kaggle with Mode = inference only, Step 3 prints them.${R}"
Write-Host " ${DIM}then Enter on an empty line. no notebook yet? just Enter, they go in .env later${R}"
while ($true) {
$line = Read-Styled ' '
if (-not $line) { break }
$eq = $line.IndexOf('=')
if ($eq -lt 1) { continue }
$key = $line.Substring(0, $eq)
if ($key -cnotin $CloudKeys) { Note "skipped $key, the notebook never prints that one"; continue }
Set-EnvKey $key $line.Substring($eq + 1)
}
} else {
foreach ($key in $CloudKeys) {
$val = [Environment]::GetEnvironmentVariable($key)
if ($val) { Set-EnvKey $key $val }
}
}
$provider = (Get-EnvValue 'AI_PROVIDER').ToLower()
$url = switch ($provider) {
'vllm' { Get-EnvValue 'VLLM_URL' }
'ollama' { Get-EnvValue 'OLLAMA_URL' }
default { '' }
}
# start.ps1 only leaves ollama alone when the URL is off this PC.
# ollama:11434 is .env.example's docker default, start.ps1 drops
# that one and goes local too
if ($provider -eq 'ollama' -and $url -match '://(127\.0\.0\.1|localhost|\[::1\]|ollama)(:|/|$)') { $url = '' }
if ($url) {
Ok "her brain at $url"
} else {
# vllm with no URL starts nothing. ollama would get installed
# and pull her onto this PC. until the lines arrive her
# replies just fail.
$provider = 'vllm'
Warn_ "no notebook link yet - the manager's Notebook link button takes Step 3's lines later"
}
# a remote ollama has no jun-mtp, php would ask it for one
# anyway when an old local install left this set
Set-EnvKey 'OLLAMA_MTP' ''
return $provider
}
function Configure-FromEnv {
$provider = (Get-EnvValue 'AI_PROVIDER').ToLower()
$voice = if ((Get-EnvValue 'VOICE') -match '^(off|0|false|no)$') { 'off' } else { 'on' }
$karaoke = if ((Get-EnvValue 'KARAOKE') -match '^(on|1|true|yes)$') { 'on' } else { 'off' }
$shortcuts = if ((Get-EnvValue 'JUN_SHORTCUTS') -match '^(off|0|false|no)$') { 'off' } else { 'on' }
$ollamaUrl = Get-EnvValue 'OLLAMA_URL'
$llamaUrl = Get-EnvValue 'LLAMACPP_URL'
Step 'configure'
Ok "provider $provider"
Ok "voice $voice, karaoke $karaoke"
# keys a newer release reads that an older Configure-Jun never wrote
if ($voice -eq 'on' -and -not (Get-EnvValue 'TTS_DEVICE')) { Set-EnvKey 'TTS_DEVICE' 'cpu' }
if ($karaoke -eq 'on' -and -not (Get-EnvValue 'SEP_DEVICE')) { Set-EnvKey 'SEP_DEVICE' 'cpu' }
Add-EnvKeyIfMissing 'OMEGA_REGISTRATION_KEY'
Add-EnvKeyIfMissing 'SIDECAR_SECRET'
Ask-UpdateCheck
return @{ provider = $provider; voice = $voice; karaoke = $karaoke; shortcuts = $shortcuts
needsOllama = ($provider -eq 'ollama' -and (-not $ollamaUrl -or $ollamaUrl -match '://(localhost|127\.0\.0\.1|ollama)[:/]'))
needsLlamacpp = ($provider -eq 'llamacpp' -and (-not $llamaUrl -or $llamaUrl -match '://127\.0\.0\.1:8081'))
}
}
function Configure-Jun {
if ($script:reuse) { return Configure-FromEnv }
$provider = if ($script:cloud) { 'cloud' } elseif ($env:JUN_PROVIDER) { $env:JUN_PROVIDER.ToLower() } else { '' }
if (-not $provider) {
if ($interactive) {
Write-Host ''
Write-Host " ${B}select an AI provider${R}"
Write-Host " ${ACCENT}[1]${R} llama.cpp ${DIM}- local llama-server, fully managed${R} ${DIM}(default)${R}"
Write-Host " ${ACCENT}[2]${R} OpenRouter ${DIM}- cloud API - needs an API key; chats leave this machine${R}"
Write-Host " ${ACCENT}[3]${R} Ollama ${DIM}- local, fully managed${R}"
$ans = Read-Styled " ${OK}▸${R} choice ${DIM}[Enter = llama.cpp]${R} ${ACCENT}›${R} "
$provider = switch -Regex ($ans) {
'^(2|openrouter)$' { 'openrouter' }
'^(3|ollama)$' { 'ollama' }
'^(1|llama|llamacpp|llama\.cpp)?$' { 'llamacpp' }
default { Warn_ "unrecognized choice '$ans', using llama.cpp"; 'llamacpp' }
}
} else {
$provider = 'llamacpp'
}
}
if ($provider -notin 'ollama', 'openrouter', 'llamacpp', 'cloud') {
Warn_ "unknown provider '$provider', using llama.cpp"
$provider = 'llamacpp'
}
Step 'configure'
# we ask this before the model question. splitting across cards
# changes how much VRAM we get to assume when recommending one.
$script:tensorParallel = if ($provider -in 'openrouter', 'cloud') { 'off' } else { Ask-TensorParallel }
$needsOllama = ($provider -eq 'ollama')
$needsLlamacpp = $false
switch ($provider) {
'ollama' {
$modelRef = Ask-ModelRef
Set-EnvKey 'OLLAMA_MODELS_TO_PULL' $modelRef
Ok "model $modelRef"
$script:mtpAutotune = Configure-Mtp 'ollama' $modelRef
}
'openrouter' {
$key = $env:OPENROUTER_API_KEY
if (-not $key -and $interactive) {
# hidden input that works on PS 5.1. the key is never echoed
# back and never printed in the summary at the end.
Write-Host " ${OK}▸${R} OpenRouter API key ${DIM}(hidden; from openrouter.ai/keys)${R}"
$sec = Read-Host ' key' -AsSecureString
$key = [Runtime.InteropServices.Marshal]::PtrToStringAuto(
[Runtime.InteropServices.Marshal]::SecureStringToBSTR($sec))
}
if (-not $key) { Warn_ 'no API key set - add OPENROUTER_API_KEY to .env before chatting' }
$orm = $env:OPENROUTER_MODEL
if (-not $orm -and $interactive) {
$orm = Read-Styled " ${OK}▸${R} model id ${DIM}[Enter = openrouter/auto]${R} ${ACCENT}›${R} "
}
if (-not $orm) { $orm = 'openrouter/auto' }
Set-EnvKey 'OPENROUTER_API_KEY' "$key"
Set-EnvKey 'OPENROUTER_MODEL' $orm
Ok "model $orm"
}
'llamacpp' {
$url = $env:LLAMACPP_URL
if (-not $url -and $interactive) {
$url = Read-Styled " ${OK}▸${R} llama-server URL ${DIM}[Enter = managed setup]${R} ${ACCENT}›${R} "
}
if ($url -and $url -notmatch '^https?://') {
Warn_ 'not an http(s) URL, using managed setup'
$url = ''
}
if ($url) {
Set-EnvKey 'LLAMACPP_URL' $url
Ok "llama-server $url"
} else {
$modelRef = Ask-ModelRef
# llama-server -hf wants the name without the hf.co/ in front
$hfRef = $modelRef -replace '^hf\.co/', ''
Set-EnvKey 'LLAMACPP_MODEL_HF' $hfRef
Set-EnvKey 'LLAMACPP_URL' 'http://127.0.0.1:8081'
Set-EnvKey 'LLAMACPP_PORT' '8081'
$needsLlamacpp = $true
Ok "model $hfRef"
$script:mtpAutotune = Configure-Mtp 'llamacpp' $modelRef
}
}
'cloud' {
$provider = Configure-Cloud
}
}
Set-EnvKey 'AI_PROVIDER' $provider
Ok "provider $provider"
Set-EnvKey 'TENSOR_PARALLEL' $script:tensorParallel
if ($script:tensorParallel -eq 'on') { Ok 'tensor parallel on' }
$voice = $env:VOICE
if ($voice) {
$voice = if ($voice.ToLower() -match '^(off|0|false|no)$') { 'off' } else { 'on' }
} elseif ($interactive -or $script:cloudAsk) {
$v = Read-Styled " ${OK}▸${R} enable voice ${DIM}(TTS)${R} ${DIM}[Y/n]${R} ${ACCENT}›${R} "
$voice = if ($v -match '^(n|no)$') { 'off' } else { 'on' }
} else {
$voice = 'on'
}
Set-EnvKey 'VOICE' $voice
Ok "voice $voice"
# the voice sidecar stays on the CPU on purpose. both engines
# keep up in real time there, and a GPU copy would squat on VRAM
# she wants for her own layers.
if ($voice -eq 'on') {
Set-EnvKey 'TTS_DEVICE' 'cpu'
}
# on bare metal one process does both jobs, so karaoke is just a
# second pip install into the same venv, not a service of its
# own. it stays on the CPU, the windows venv is built against the
# CPU torch wheel.
$karaoke = Ask-Karaoke $voice
Set-EnvKey 'KARAOKE' $karaoke
if ($karaoke -eq 'on') {
# no JUN_KARAOKE_GPU question here, unlike install.sh.
# SEP_DEVICE=auto would go looking for cuda and there is none in
# a CPU torch venv.
Set-EnvKey 'SEP_DEVICE' 'cpu'
}
Ok "karaoke $karaoke"
$shortcuts = Ask-Shortcuts
Set-EnvKey 'JUN_SHORTCUTS' $shortcuts
Ok "shortcuts $shortcuts"
Add-EnvKeyIfMissing 'OMEGA_REGISTRATION_KEY'
Add-EnvKeyIfMissing 'SIDECAR_SECRET'
Ok 'registration key ready'
Ask-UpdateCheck
return @{ provider = $provider; voice = $voice; karaoke = $karaoke; shortcuts = $shortcuts
needsOllama = $needsOllama; needsLlamacpp = $needsLlamacpp }
}
function Refresh-Path {
$machine = [Environment]::GetEnvironmentVariable('Path', 'Machine')
$user = [Environment]::GetEnvironmentVariable('Path', 'User')
$env:Path = (($machine, $user, $env:Path) | Where-Object { $_ }) -join ';'
}
# clean/LTSC/Server images can ship without winget (LTSC is the
# enterprise build with no Store). bootstrapping it means
# installing a machine-wide package manager off PSGallery, so we
# ask first. $env:JUN_BOOTSTRAP_WINGET='1' says yes, '0' leaves
# App Installer to the user.
function Ensure-Winget {
if (Get-Command winget -ErrorAction SilentlyContinue) { return $true }
if ($env:JUN_BOOTSTRAP_WINGET -match '^(0|off|no|false)$') {
Note 'winget is missing and JUN_BOOTSTRAP_WINGET is off.'
return $false
}
if ($env:JUN_BOOTSTRAP_WINGET -notmatch '^(1|on|yes|true)$') {
Warn_ 'winget (App Installer) is not on this machine.'
Note 'bootstrapping it installs the Microsoft.WinGet.Client module from'
Note 'PSGallery, which then installs the winget client machine-wide.'
Note 'the other option is App Installer from the Store, by hand.'
if (-not [Environment]::UserInteractive) {
Note "set `$env:JUN_BOOTSTRAP_WINGET='1' to allow it non-interactively."
return $false
}
$a = Read-Styled " ${OK}▸${R} bootstrap winget now? ${DIM}[y/N]${R} ${ACCENT}›${R} "
if ($a -notmatch '^(y|yes)$') { return $false }
}
Note 'bootstrapping winget via the WinGet PowerShell module'
try {
Install-PackageProvider -Name NuGet -Force | Out-Null
Install-Module -Name Microsoft.WinGet.Client -Force -Repository PSGallery | Out-Null
Repair-WinGetPackageManager -AllUsers
} catch {
Warn_ ("couldn't bootstrap winget automatically: {0}" -f $_.Exception.Message)
}
Refresh-Path
return [bool](Get-Command winget -ErrorAction SilentlyContinue)
}
# straight from ollama.com, not winget. winget can die with
# "Failed in attempting to update the source" or just a bare
# exit code somewhere in the 1.5 GB download. so we fetch
# OllamaSetup.exe ourselves and check it's signed by Ollama Inc.
# it's an Inno Setup installer and per-user, which means
# PrivilegesRequired=lowest, %LocalAppData%\Programs\Ollama and
# user PATH. the silent switches are the ones winget passes it,
# keep them.
function Install-Ollama {
$url = 'https://ollama.com/download/OllamaSetup.exe'
$tmp = Join-Path $env:TEMP ('jun-ollama-' + [guid]::NewGuid().ToString('N') + '.exe')
try {
Note 'downloading OllamaSetup.exe (about 1.5 GB, this takes a while)'
# WebClient streams to disk. Invoke-WebRequest on 5.1 holds the
# whole body in memory before it writes the file. all 1.5 GB of
# it.
$task = (New-Object Net.WebClient).DownloadFileTaskAsync($url, $tmp)
while (-not $task.IsCompleted) {
Start-Sleep -Seconds 3
if (Test-Path -LiteralPath $tmp) {
Write-Host -NoNewline ("`r ${DIM} {0:N0} MB${R}" -f ((Get-Item -LiteralPath $tmp).Length / 1MB))
}
}
Write-Host ''
if ($task.IsFaulted) { throw ("download failed: {0}" -f $task.Exception.GetBaseException().Message) }
# authenticode, so the check is on who signed it, not on a digest
# served by the same host as the file.
$sig = Get-AuthenticodeSignature -LiteralPath $tmp
if ($sig.Status -ne 'Valid' -or $sig.SignerCertificate.Subject -notmatch 'CN=Ollama Inc') {
throw ("OllamaSetup.exe isn't signed by Ollama Inc. (signature {0}) - not running it." -f $sig.Status)
}
Step 'run OllamaSetup.exe'
# WaitForExit, not Start-Process -Wait. -Wait also waits for
# every descendant, and the installer ends by launching the
# ollama tray app (nowait), which never exits. so -Wait never
# returns.
$p = Start-Process -FilePath $tmp -ArgumentList '/VERYSILENT', '/SUPPRESSMSGBOXES', '/NORESTART' -PassThru
$p.WaitForExit()
if ($p.ExitCode -ne 0) { throw ("OllamaSetup.exe exited with code {0}" -f $p.ExitCode) }
Ok 'ollama installed'
} catch {
Fail_ $_.Exception.Message
Note ("install it yourself from {0} and re-run this installer." -f $manualUrls.ollama)
Exit-Install 1
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
# a fresh install lands on PATH for new terminals only, so the
# way out is a new one, not a retry in this one
function Assert-OnPath([string]$command) {
if (Get-Command $command -ErrorAction SilentlyContinue) { return }
Fail_ "$command still not on PATH"
Note 'open a NEW terminal (so PATH refreshes) and run the one-liner again; setup will resume.'
Exit-Install 1
}
# install the named tools, after warning that these are the only
# machine-wide pieces. each keeps its own uninstaller in Settings
# > Apps. anything with a winget id goes through winget, ollama
# goes through Install-Ollama.
# missing counts as too old too, see $LlamacppMinBuild
function Test-LlamaServerBuild {
if (-not (Get-Command llama-server -ErrorAction SilentlyContinue)) { return $false }
# cmd /c, llama-server prints its version on stderr and 5.1 throws on that
$out = (cmd /c "llama-server --version 2>&1" | Out-String)
return ($out -match 'build (\d+)') -and ([int]$matches[1] -ge $LlamacppMinBuild)
}
function Install-MachineTools([string[]]$missing, [switch]$Optional) {
if ($missing.Count -eq 0) { return }
Write-Host ''
Warn_ ("these tools aren't installed: {0}" -f ($missing -join ', '))
Note 'they are the only machine-wide installs Jun needs; everything else stays'
Note 'inside the Jun folder. Each gets a normal uninstaller under Settings > Apps.'
if (($missing | Where-Object { $wingetIds[$_] }) -and -not (Ensure-Winget)) {
Fail_ "winget (App Installer) isn't available - install them manually and re-run:"
foreach ($c in $missing) { Write-Host (" ${ACCENT}{0,-7}${R} ${DIM}{1}${R}" -f $c, $manualUrls[$c]) }
if ($Optional) { return } else { Exit-Install 1 }
}
$proceed = $env:JUN_YES -eq '1'
if (-not $proceed) {
if (-not [Environment]::UserInteractive) {
Note "re-run in an interactive terminal (or set `$env:JUN_YES='1') to install them."
if ($Optional) { return } else { Exit-Install 1 }
}
$answer = Read-Styled (" ${OK}▸${R} install {0} now? ${DIM}[Y/n]${R} ${ACCENT}›${R} " -f ($missing -join ' and '))
$proceed = $answer -notmatch '^(n|no)$'
}
if (-not $proceed) {
Note 'okay, leaving it to you - install the tools above and re-run this installer.'
if ($Optional) { return } else { Exit-Install 1 }
}
foreach ($c in $missing) {
Step ("install {0}" -f $c)
if ($c -eq 'ollama') { Install-Ollama; continue }
# --source winget on purpose. without it the id can resolve out of
# msstore or any private source somebody added to this machine, and
# we'd install whatever answers to that name over there.
$pin = if ($wingetVersions[$c]) { @('--version', $wingetVersions[$c]) } else { @() }
winget install -e --id $wingetIds[$c] @pin --source winget `
--accept-source-agreements --accept-package-agreements
if ($LASTEXITCODE -ne 0) {
Warn_ ("winget returned {0} for {1} - carrying on, the PATH check below decides." -f $LASTEXITCODE, $c)
} else {
Ok ("{0} installed" -f $c)
}
}
Refresh-Path
}
# every interpreter this box has, PATH ones first. the py
# launcher matters here: winget can install 3.11 and leave 3.14
# sitting first on PATH, and then the only way to reach the one
# we asked for is `py -3.11`.
function Get-PythonCandidates {
$paths = @()
foreach ($c in @(
Get-Command python -ErrorAction SilentlyContinue
Get-Command python3 -ErrorAction SilentlyContinue
)) {
# skip the windows store alias stub outright. probing it writes
# to stderr, which $ErrorActionPreference='Stop' turns into a
# terminating NativeCommandError on PowerShell 5.1. run every
# probe through cmd so any other stderr output never reaches
# PowerShell either.
if ($c -and $c.Source -and $c.Source -notlike '*\WindowsApps\*') { $paths += $c.Source }
}
$launcher = Get-Command py -ErrorAction SilentlyContinue
if ($launcher -and $launcher.Source -notlike '*\WindowsApps\*') {
foreach ($v in '3.12', '3.11', '3.10') {
$exe = cmd /c "`"$($launcher.Source)`" -$v -c `"import sys; print(sys.executable)`" 2>nul"
if ($LASTEXITCODE -eq 0 -and $exe) { $paths += $exe.Trim() }
}
}
$paths | Where-Object { $_ } | Select-Object -Unique
}
# returns a path to an interpreter, or $null. $Below is an
# exclusive upper bound like '3.13'. the voice venv needs one,
# kokoro 0.9.4 declares Requires-Python <3.13 and pip on a 3.13+
# interpreter just says "no matching distribution" and takes
# voice down with it.
function Get-UsablePython([string]$Min = '3.10', [string]$Below) {
$check = "import ensurepip, sys, venv; assert sys.version_info >= ({0})" -f ($Min -replace '\.', ', ')
if ($Below) { $check += "; assert sys.version_info < ({0})" -f ($Below -replace '\.', ', ') }
foreach ($path in Get-PythonCandidates) {
cmd /c "`"$path`" -c `"$check`" >nul 2>nul"
if ($LASTEXITCODE -eq 0) { return $path }
}
return $null
}
function Install-Php {
$phpDir = Join-Path (Get-Location) 'runtime\php'
$phpExe = Join-Path $phpDir 'php.exe'
# the windows.php.net builds link against the VC++ runtime, which
# fresh windows installs often just don't have (php.exe then dies
# with a missing VCRUNTIME140.dll dialog). tiny, standard,
# machine-wide MS component.
if (-not (Test-Path (Join-Path $env:SystemRoot 'System32\vcruntime140.dll'))) {
if (-not (Ensure-Winget)) {
throw 'The Microsoft Visual C++ 2015-2022 Redistributable (x64) is required for PHP, but winget could not be installed.'
}
Step 'install Visual C++ runtime (needed by PHP)'
winget install -e --id Microsoft.VCRedist.2015+.x64 --source winget `
--accept-source-agreements --accept-package-agreements
}
$phpReady = $true
foreach ($required in 'php.exe', 'php.ini', 'ext\php_curl.dll', 'ext\php_mbstring.dll',
'ext\php_openssl.dll', 'ext\php_pdo_sqlite.dll', 'ext\php_sqlite3.dll', 'ext\php_sodium.dll') {
if (-not (Test-Path (Join-Path $phpDir $required))) {
$phpReady = $false
break
}
}
$releases = $null
try {
$releases = Invoke-RestMethod 'https://windows.php.net/downloads/releases/releases.json' -UseBasicParsing
} catch {
if (-not $phpReady) { throw }
}
if ($phpReady) {
# an install used to keep the php it got on day one forever,
# updates included. that's how CVE-2026-109618/109619 (RCE in
# php itself, fixed in 8.5.12 / 8.4.27) would've stayed on
# every old box. so every run compares against releases.json
# and swaps php when it's behind. no releases.json = keep it.
if (-not $releases) {
Warn_ "couldn't ask windows.php.net for the newest PHP, keeping the one in runtime\php"
return
}
$newest = $releases.PSObject.Properties | Sort-Object { [version]$_.Name } | Select-Object -Last 1
$have = ''
try { $have = "$(& $phpExe -r 'echo PHP_VERSION;' 2>$null)".Trim() } catch {}
$haveVersion = $null
if (-not [version]::TryParse(($have -replace '[^0-9.].*$', ''), [ref]$haveVersion)) { $haveVersion = [version]'0.0' }
# rebuilds come as 8.5.12-1, PHP_VERSION still says 8.5.12
$newestVersion = $newest.Value.version -replace '-.*$', ''
if ($haveVersion -ge [version]$newestVersion) { return }
Step "PHP $have is out of date, replacing it with $newestVersion"
# php.exe holds its own dlls open while she runs, and the swap
# below deletes that folder. so stop her first, the installer
# starts her again at the end anyway.
$starter = Join-Path (Get-Location) 'start.ps1'
if (Test-Path -LiteralPath $starter) {
$psExe = (Get-Process -Id $PID).MainModule.FileName
& $psExe -NoProfile -ExecutionPolicy Bypass -File $starter stop | Out-Null
}
} elseif (Test-Path $phpDir) {
Warn_ 'portable PHP is incomplete - repairing it'
}
Step 'download portable PHP'
$branch = ($releases.PSObject.Properties.Name | Sort-Object { [version]$_ } | Select-Object -Last 1)
$build = $releases.$branch.PSObject.Properties |
Where-Object { $_.Name -match '^nts-.*-x64$' } | Select-Object -First 1
if (-not $build) { throw "Couldn't find a 64-bit NTS PHP build in releases.json" }
$zipUrl = 'https://windows.php.net/downloads/releases/' + $build.Value.zip.path
$zipSha = $build.Value.zip.sha256
if (-not $zipSha) {
throw "releases.json lists no sha256 for $($build.Name) - not unpacking an unverified PHP."
}
$tmpDir = Join-Path $env:TEMP ('jun-php-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
$phpStage = $null
try {
$zip = Join-Path $tmpDir 'php.zip'
Invoke-WebRequest -Uri $zipUrl -OutFile $zip -UseBasicParsing
# the digest and the zip come from the same host, so this catches
# a mangled CDN copy or a half finished download, not a
# windows.php.net that's itself owned. it's the strongest check
# php.net offers.
if (-not (Test-Sha256 $zip $zipSha)) {
throw ("PHP download doesn't match the sha256 in releases.json (wanted {0}, got {1})." -f
$zipSha, (Get-FileHash -Algorithm SHA256 -LiteralPath $zip).Hash)
}
Ok 'PHP zip matches the sha256 in releases.json'
$phpParent = Split-Path -Parent $phpDir
New-Item -ItemType Directory -Force -Path $phpParent | Out-Null
$phpStage = Join-Path $phpParent ('php-staging-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $phpStage | Out-Null
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::ExtractToDirectory($zip, $phpStage)
if (-not (Test-Path (Join-Path $phpStage 'php.exe'))) {
throw 'The verified PHP archive did not contain php.exe.'
}
# a half extracted php must never be visible in $phpDir. so we
# unpack next door and rename in one shot. staging sits under the
# same parent on purpose, [IO.Directory]::Move across volumes
# just throws.
if (Test-Path $phpDir) {
Remove-Item -LiteralPath $phpDir -Recurse -Force
}
[IO.Directory]::Move($phpStage, $phpDir)
$phpStage = $null
# curl.se publishes the digest next to the bundle. a tampered CA
# bundle is worse than none at all, it makes PHP trust a CA you
# never chose, so on a mismatch we install nothing and PHP falls
# back to the OS store.
$cacert = Join-Path $phpDir 'cacert.pem'
try {
$caTmp = Join-Path $tmpDir 'cacert.pem'
Invoke-WebRequest -Uri 'https://curl.se/ca/cacert.pem' -OutFile $caTmp -UseBasicParsing
# to a file, pwsh 7 hands .Content back as bytes for application/x-pem-file
$caSha = Join-Path $tmpDir 'cacert.pem.sha256'
Invoke-WebRequest -Uri 'https://curl.se/ca/cacert.pem.sha256' -OutFile $caSha -UseBasicParsing
$caWant = ((Get-Content -Raw -LiteralPath $caSha) -split '\s+')[0]
if (-not (Test-Sha256 $caTmp $caWant)) { throw 'cacert.pem does not match its published sha256' }
[IO.File]::Copy($caTmp, $cacert, $true)
} catch {
Warn_ ("skipping cacert.pem ({0}); HTTPS from PHP (web fetch tool) may not work" -f $_.Exception.Message)
}
} finally {
if ($phpStage -and (Test-Path $phpStage)) {
Remove-Item -LiteralPath $phpStage -Recurse -Force -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
$ini = @(
"extension_dir=`"$phpDir\ext`""
'extension=curl'
'extension=mbstring'
'extension=openssl'
'extension=pdo_sqlite'
'extension=sqlite3'
'extension=sodium'
'post_max_size=30M'
'upload_max_filesize=30M'
'memory_limit=128M'
'max_execution_time=0'
'expose_php=Off'
'display_errors=Off'
'log_errors=On'
)
if (Test-Path $cacert) {
$ini += "curl.cainfo=`"$cacert`""
$ini += "openssl.cafile=`"$cacert`""
}
$ini | Set-Content (Join-Path $phpDir 'php.ini')
# capture BEFORE piping. Select-Object -First stops the pipeline
# early, leaving $LASTEXITCODE stale from some previous command.
$phpVersionOut = cmd /c "`"$phpExe`" -v 2>&1"
$phpRan = ($LASTEXITCODE -eq 0)
$phpVersionOut | Select-Object -First 1 | Write-Host
if (-not $phpRan) {
Warn_ 'php.exe did not run. If you saw a VCRUNTIME140.dll error, install the'
Warn_ 'Microsoft Visual C++ 2015-2022 Redistributable (x64) and re-run.'
} else {
Ok 'portable PHP ready'
}
}
# php -S can't speak TLS, so caddy sits in front of it and does
# the HTTPS (start.ps1 writes its config). the version AND its
# sha512 are pinned here. the checksums file lives on the same
# github release as the zip, so reading it at install time would
# only catch a broken download. a pinned hash catches a swapped
# release too. bump both together.
$CaddyVersion = '2.11.7'
$CaddySha512 = 'c308154504e53755958ffc62d8a2657ab17eefd6dd25c45dbb2c3d93d29519971f77ca652f7d0f2bcb7f980b9ba3822478865e29969d0272d7613550a43152b1'
function Install-Caddy {
$caddyDir = Join-Path (Get-Location) 'runtime\caddy'
$caddyExe = Join-Path $caddyDir 'caddy.exe'
if (Test-Path $caddyExe) {
$have = ''
try { $have = (& $caddyExe version 2>$null | Select-Object -First 1) } catch {}
if ("$have" -match ('^v' + [regex]::Escape($CaddyVersion) + '\b')) { return }
}
Step "download Caddy $CaddyVersion (the HTTPS in front of PHP)"
$tmpDir = Join-Path $env:TEMP ('jun-caddy-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
try {
$zip = Join-Path $tmpDir 'caddy.zip'
$url = "https://github.com/caddyserver/caddy/releases/download/v$CaddyVersion/caddy_${CaddyVersion}_windows_amd64.zip"
Invoke-WebRequest -Uri $url -OutFile $zip -UseBasicParsing
$got = (Get-FileHash -Algorithm SHA512 -LiteralPath $zip).Hash
if ($got -ine $CaddySha512) {
throw "Caddy download doesn't match the pinned sha512 (wanted $CaddySha512, got $got)."
}
Ok 'Caddy zip matches the pinned sha512'
Add-Type -AssemblyName System.IO.Compression.FileSystem
[IO.Compression.ZipFile]::ExtractToDirectory($zip, (Join-Path $tmpDir 'x'))
$unpacked = Join-Path $tmpDir 'x\caddy.exe'
if (-not (Test-Path $unpacked)) { throw 'The verified Caddy archive did not contain caddy.exe.' }
New-Item -ItemType Directory -Force -Path $caddyDir | Out-Null
[IO.File]::Copy($unpacked, $caddyExe, $true)
} finally {
Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
Ok 'Caddy ready'
}
# a JunOS.exe that's running is locked for writing, but windows
# still lets you rename it. so the old one steps aside and the new
# one takes its name. the .old goes on the next run.
function Set-ManagerExe([string]$source, [string]$exe) {
$old = "$exe.old"
Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $exe) { Move-Item -LiteralPath $exe -Destination $old -Force }
Copy-Item -LiteralPath $source -Destination $exe
Remove-Item -LiteralPath $old -Force -ErrorAction SilentlyContinue
}
# JunOS.exe is the start/stop/update window. JunSetup carries one
# from its own build and hands it over in JUN_MANAGER_EXE, that
# goes first. otherwise CI compiles it per release, so take the
# one from the release this checkout is on.
# on main (or any branch) the newest release's one is fine, it
# only drives start.ps1, install.ps1 and uninstall.ps1 next to it.
# checked against SHA256SUMS from the same release, which is
# immutable once published. returns the exe, or $null when
# there's none to get (releases before it, forks, offline) and
# the shortcut runs jun-manager.ps1 instead.
function Install-Manager {
$exe = Join-Path (Get-Location).ProviderPath 'JunOS.exe'
if ($env:JUN_MANAGER_EXE -and (Test-Path -LiteralPath $env:JUN_MANAGER_EXE)) {
try {
Set-ManagerExe $env:JUN_MANAGER_EXE $exe
Ok 'JunOS.exe (from JunSetup)'
return $exe
} catch {
Warn_ ("couldn't copy JunOS.exe from JunSetup: {0}" -f $_.Exception.Message)
}
}
$slug = Get-GitHubSlug $repo
$tag = (Invoke-Git describe --tags --exact-match HEAD) -join ''
if ($LASTEXITCODE -ne 0) { $tag = Get-ReleaseTag 'latest' }
if (-not $slug -or -not $tag) {
Note 'no release to take JunOS.exe from - using jun-manager.ps1 instead'
return $null
}
Step "download the Jun OS app ($tag)"
$tmpDir = Join-Path $env:TEMP ('jun-manager-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
try {
$base = "https://github.com/$slug/releases/download/$tag"
$tmpExe = Join-Path $tmpDir 'JunOS.exe'
$sums = Join-Path $tmpDir 'SHA256SUMS'
try {
Invoke-WebRequest -Uri "$base/JunOS.exe" -OutFile $tmpExe -UseBasicParsing
Invoke-WebRequest -Uri "$base/SHA256SUMS" -OutFile $sums -UseBasicParsing
} catch {
Note "release $tag has no JunOS.exe - using jun-manager.ps1 instead"
return $null
}
$want = Get-Content -LiteralPath $sums | ForEach-Object {
if ($_ -match '^([0-9a-fA-F]{64})\s+\*?JunOS\.exe$') { $matches[1] }
} | Select-Object -First 1
if (-not (Test-Sha256 $tmpExe $want)) {
Warn_ "JunOS.exe doesn't match the SHA256SUMS of $tag - not installing it"
return $null
}
Set-ManagerExe $tmpExe $exe
Ok "JunOS.exe $tag"
return $exe
} catch {
Warn_ ("couldn't set up JunOS.exe: {0} - using jun-manager.ps1 instead" -f $_.Exception.Message)
return $null
} finally {
Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# an .ico can just wrap a PNG, so Jun's app icon goes in as-is with
# the 22 byte header in front. 0x0 in the header means 256, the
# format only has one byte per side, so icon-512.png gets scaled.
# only for the shortcut that has no JunOS.exe to take its icon from.
function New-JunIcon([string]$root) {
$png = Join-Path $root 'webapp\icon-512.png'
$ico = Join-Path $root 'runtime\jun.ico'
if (-not (Test-Path -LiteralPath $png)) { return $null }
try {
Add-Type -AssemblyName System.Drawing
$src = [Drawing.Image]::FromFile($png)
$bmp = [Drawing.Bitmap]::new(256, 256)
$g = [Drawing.Graphics]::FromImage($bmp)
$g.InterpolationMode = [Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$g.DrawImage($src, 0, 0, 256, 256)
$g.Dispose(); $src.Dispose()
$ms = [IO.MemoryStream]::new()
$bmp.Save($ms, [Drawing.Imaging.ImageFormat]::Png)
$bmp.Dispose()
$bytes = $ms.ToArray()
$out = [IO.MemoryStream]::new()
$w = [IO.BinaryWriter]::new($out)
$w.Write([uint16]0); $w.Write([uint16]1); $w.Write([uint16]1)
$w.Write([byte]0); $w.Write([byte]0); $w.Write([byte]0); $w.Write([byte]0)
$w.Write([uint16]1); $w.Write([uint16]32)
$w.Write([uint32]$bytes.Length); $w.Write([uint32]22)
$w.Write($bytes)
$w.Flush()
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ico) | Out-Null
[IO.File]::WriteAllBytes($ico, $out.ToArray())
return $ico
} catch {
return $null
}
}
# per-user Desktop and Start menu, no admin needed. without the exe
# the shortcut runs jun-manager.ps1 through a hidden powershell, -STA
# because WPF won't start without it.
function New-JunShortcuts([string]$managerExe) {
$root = (Get-Location).ProviderPath
$icon = if ($managerExe) { "$managerExe,0" } else { New-JunIcon $root }
$shell = New-Object -ComObject WScript.Shell
foreach ($folder in @([Environment]::GetFolderPath('Desktop'), [Environment]::GetFolderPath('Programs'))) {
if (-not $folder -or -not (Test-Path -LiteralPath $folder)) { continue }
$lnk = $shell.CreateShortcut((Join-Path $folder 'Jun OS.lnk'))
if ($managerExe) {
$lnk.TargetPath = $managerExe
$lnk.Arguments = ''
} else {
$lnk.TargetPath = (Get-Process -Id $PID).MainModule.FileName
$lnk.Arguments = "-NoProfile -ExecutionPolicy Bypass -WindowStyle Hidden -STA -File `"$(Join-Path $root 'jun-manager.ps1')`""
}
if ($icon) { $lnk.IconLocation = $icon }
$lnk.WorkingDirectory = $root
$lnk.Description = 'Start, stop and update Jun OS'
$lnk.Save()
}
Ok 'Jun OS shortcut on the desktop and in the Start menu'
}
function Install-Tts([string]$Karaoke = 'off') {
$venv = Join-Path (Get-Location) 'runtime\tts-venv'
$py = Join-Path $venv 'Scripts\python.exe'
if (-not (Test-Path $py)) {
$python = Get-UsablePython -Min '3.10' -Below '3.13'
if (-not $python) {
Install-MachineTools @('python') -Optional
$python = Get-UsablePython -Min '3.10' -Below '3.13'
if (-not $python) {
Warn_ 'no Python 3.10, 3.11 or 3.12 found - skipping voice. 3.13 and newer do'
Warn_ 'not work here: kokoro has no wheel for them. install one of those and re-run.'
Set-EnvKey 'VOICE' 'off'
Set-EnvKey 'KARAOKE' 'off'
return
}
}
Step 'set up TTS voice engine (a few GB, one-time)'
& $python -m venv $venv
& $py -m pip install --upgrade pip
# CPU torch wheel first so the resolver doesn't drag CUDA builds
# in as a transitive dep. both voice models hit real-time on CPU
# anyway.
& $py -m pip install 'torch>=2.5,<2.15' --index-url https://download.pytorch.org/whl/cpu
& $py -m pip install -r (Join-Path (Get-Location) 'tts\requirements.txt')
if ($LASTEXITCODE -ne 0) {
Warn_ 'TTS setup failed - continuing text-only. Re-run install.ps1 to retry.'
Set-EnvKey 'VOICE' 'off'
Set-EnvKey 'KARAOKE' 'off'
return
}
Ok 'TTS voice engine ready'
} else {
# an update re-runs this on the old venv, so the pins have to land here too
& $py -m pip install --disable-pip-version-check --quiet -r (Join-Path (Get-Location) 'tts\requirements.txt')
if ($LASTEXITCODE -ne 0) {
Warn_ 'could not refresh the TTS packages - keeping the ones already there. Re-run install.ps1 to retry.'
}
}
if ($Karaoke -ne 'on') { return }
# bare metal serves both roles from one process, so karaoke is an
# extra layer on the same venv. docker splits them into two
# containers instead, which is where GPU separation lives. this
# venv is CPU-only. probe through cmd, a failed import writes a
# traceback to stderr and $ErrorActionPreference='Stop' would
# turn that into a terminating error.
cmd /c "`"$py`" -c `"import demucs`" >nul 2>nul"
if ($LASTEXITCODE -eq 0) { return }
Step 'set up karaoke stem separation (a few GB, one-time)'
& $py -m pip install 'torch>=2.5,<2.15' torchaudio --index-url https://download.pytorch.org/whl/cpu
& $py -m pip install -r (Join-Path (Get-Location) 'tts\requirements-karaoke.txt')
if ($LASTEXITCODE -ne 0) {
Warn_ 'karaoke setup failed - the rest still works. Re-run install.ps1 to retry.'
Set-EnvKey 'KARAOKE' 'off'
} else {
Ok 'karaoke ready'
}
}
function Install-AssetRecovery {
$python = Get-UsablePython
if (-not $python) {
# the user explicitly asked for extraction, so python is required
# here instead of being treated as an optional voice dependency.
Install-MachineTools @('python')
$python = Get-UsablePython
if (-not $python) {
throw 'Python 3.10 or newer is required for asset recovery. Re-run install.ps1 after installing it.'
}
}
$venv = Join-Path (Get-Location) 'runtime\asset-recovery-venv'
$recoveryPython = Join-Path $venv 'Scripts\python.exe'
# a venv whose pip never bootstrapped is worse than no venv:
# python.exe is there so a Test-Path check passes, and the
# install dies on "No module named pip" one line later. check for
# pip, rebuild when it's missing.
$hasPip = $false
if (Test-Path $recoveryPython) {
& $recoveryPython -m pip --version *> $null
$hasPip = ($LASTEXITCODE -eq 0)
}
if (-not $hasPip) {
Step 'set up local asset-recovery environment'
& $python -m venv --clear $venv
if ($LASTEXITCODE -ne 0) { throw 'Could not create the asset-recovery virtual environment.' }
& $recoveryPython -m pip --version *> $null
if ($LASTEXITCODE -ne 0) {
& $recoveryPython -m ensurepip --upgrade *> $null
& $recoveryPython -m pip --version *> $null
if ($LASTEXITCODE -ne 0) { throw 'The asset-recovery virtual environment has no pip.' }
}
}
Step 'install UnityPy + Pillow'
& $recoveryPython -m pip install --disable-pip-version-check --quiet `
-r (Join-Path (Get-Location) 'tools\requirements-recovery.txt')
if ($LASTEXITCODE -ne 0) { throw 'Could not install UnityPy and Pillow for asset recovery.' }
$recoveryArgs = @('tools/recover_assets.py')
if ($env:JUN_GAME_DIR) { $recoveryArgs += @('--game', $env:JUN_GAME_DIR) }
& $recoveryPython @recoveryArgs
if ($LASTEXITCODE -eq 0) {
Ok 'assets extracted to webapp/assets (local use only)'
return
}
# honor supplied paths. interactive Express and Custom can ask
# for a fallback. JUN_YES without $expressInteractive is
# headless: installer-gui.ps1 redirects stdout with no window, so
# Read-Host would hang invisibly. fail instead.
if ($env:JUN_GAME_DIR -or (-not $interactive -and -not $script:expressInteractive)) {
Warn_ "Couldn't extract - she'll use placeholder art for now. Set JUN_GAME_DIR to the game folder and re-run with JUN_EXTRACT=1 for her real model."
return
}
Warn_ "Couldn't find the game in its usual locations."
while ($true) {
$selection = Read-Styled " ${OK}▸${R} paste the game folder or drag My Dystopian Robot Girlfriend.exe here ${DIM}[Enter = skip]${R} ${ACCENT}›${R} "
if ([string]::IsNullOrWhiteSpace($selection)) {
Note 'asset extraction skipped.'
return
}
# windows terminals wrap drag-and-drop paths in quotes. a file
# input means its containing folder, a folder input is used
# as-is.
$path = $selection.Trim().Trim('"').Trim("'")
try {
$item = Get-Item -LiteralPath $path -ErrorAction Stop
} catch {
Warn_ "path not found: $path"
continue
}
$gameDir = if ($item.PSIsContainer) { $item.FullName } else { $item.Directory.FullName }
$dataDir = Join-Path $gameDir 'My Dystopian Robot Girlfriend_Data'
if (-not (Test-Path -LiteralPath $dataDir -PathType Container)) {
Warn_ "that location does not contain My Dystopian Robot Girlfriend_Data"
continue
}
& $recoveryPython tools/recover_assets.py --game $gameDir
if ($LASTEXITCODE -eq 0) {
Ok 'assets extracted to webapp/assets (local use only)'
return
}
Warn_ 'Asset extraction failed from that location. Try another path or press Enter to skip.'
}
}
$GameName = 'My Dystopian Robot Girlfriend'
function Find-GameDir {
$marker = "${GameName}_Data"
$cands = @($env:JUN_GAME_DIR, (Get-EnvValue 'JUN_GAME_DIR'))
$libs = @((Get-ItemProperty 'HKCU:\Software\Valve\Steam' -ErrorAction SilentlyContinue).SteamPath,
(Join-Path ${env:ProgramFiles(x86)} 'Steam')) | Where-Object { $_ }
foreach ($lib in @($libs)) {
$vdf = Join-Path $lib 'steamapps\libraryfolders.vdf'
if (Test-Path -LiteralPath $vdf) {
$libs += [regex]::Matches((Get-Content -LiteralPath $vdf -Raw), '"path"\s+"([^"]+)"') |
ForEach-Object { $_.Groups[1].Value.Replace('\\', '\') }
}
}
$cands += $libs | ForEach-Object { Join-Path $_ "steamapps\common\$GameName" }
foreach ($c in $cands) {
if ($c -and (Test-Path -LiteralPath (Join-Path $c $marker))) { return $c }
}
# the itch zip unpacks as factorial-omega-windows-64 wherever they put it
foreach ($root in @((Join-Path $env:APPDATA 'itch\apps'), (Join-Path $env:USERPROFILE 'Downloads'),
(Join-Path $env:USERPROFILE 'Desktop'), (Join-Path $env:USERPROFILE 'Documents'), 'C:\Games')) {
if (-not (Test-Path -LiteralPath $root)) { continue }
$hit = Get-ChildItem -LiteralPath $root -Directory -Recurse -Depth 2 -Filter $marker -ErrorAction SilentlyContinue | Select-Object -First 1
if ($hit) { return $hit.Parent.FullName }
}
return ''
}
function Install-BridgeMod([string]$gameDir) {
if (-not (Test-Path -LiteralPath (Join-Path $gameDir 'version.dll'))) {
Step 'install MelonLoader'
$zip = Join-Path $env:TEMP ('melonloader-' + [guid]::NewGuid().ToString('N') + '.zip')
try {
Invoke-WebRequest -Uri 'https://github.com/LavaGang/MelonLoader/releases/download/v0.7.3/MelonLoader.x64.zip' -OutFile $zip -UseBasicParsing
Expand-Archive -LiteralPath $zip -DestinationPath $gameDir -Force
} finally {
Remove-Item -LiteralPath $zip -Force -ErrorAction SilentlyContinue
}
if (-not (Test-Path -LiteralPath (Join-Path $gameDir 'version.dll'))) { throw 'MelonLoader did not unpack into the game folder' }
Ok 'MelonLoader'
}
$slug = Get-GitHubSlug $repo
$tag = (Invoke-Git describe --tags --exact-match HEAD) -join ''
if ($LASTEXITCODE -ne 0) { $tag = Get-ReleaseTag 'latest' }
if (-not $slug -or -not $tag) { throw 'no release to take JunBridge.dll from' }
$tmpDir = Join-Path $env:TEMP ('junbridge-' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Force -Path $tmpDir | Out-Null
try {
$base = "https://github.com/$slug/releases/download/$tag"
$dll = Join-Path $tmpDir 'JunBridge.dll'
$sums = Join-Path $tmpDir 'SHA256SUMS'
Invoke-WebRequest -Uri "$base/JunBridge-$tag.dll" -OutFile $dll -UseBasicParsing
Invoke-WebRequest -Uri "$base/SHA256SUMS" -OutFile $sums -UseBasicParsing
$want = Get-Content -LiteralPath $sums | ForEach-Object {
if ($_ -match "^([0-9a-fA-F]{64})\s+\*?JunBridge-$([regex]::Escape($tag))\.dll$") { $matches[1] }
} | Select-Object -First 1
if (-not (Test-Sha256 $dll $want)) { throw "JunBridge-$tag.dll doesn't match the SHA256SUMS of $tag" }
$mods = Join-Path $gameDir 'Mods'
New-Item -ItemType Directory -Force -Path $mods | Out-Null
Copy-Item -LiteralPath $dll -Destination (Join-Path $mods 'JunBridge.dll') -Force
Ok "JunBridge $tag"
} finally {
Remove-Item -LiteralPath $tmpDir -Recurse -Force -ErrorAction SilentlyContinue
}
}
# MelonLoader builds its Il2CppAssemblies on the game's first start,
# a minute or two. done here so their first real start is a normal one
function Initialize-MelonLoader([string]$gameDir) {
$exe = Join-Path $gameDir "$GameName.exe"
$log = Join-Path $gameDir 'MelonLoader\Latest.log'
if (-not (Test-Path -LiteralPath $exe)) { return }
if (Test-Path -LiteralPath (Join-Path $gameDir 'MelonLoader\Il2CppAssemblies\Assembly-CSharp.dll')) { return }
if (Get-Process -Name $GameName -ErrorAction SilentlyContinue) {
Note 'the game is open, MelonLoader sets itself up the next time it starts'
return
}
Step 'set up MelonLoader'
Note 'the game opens once for a minute or two and closes on its own. leave it be'
Remove-Item -LiteralPath $log -Force -ErrorAction SilentlyContinue
Start-Process -FilePath $exe -WorkingDirectory $gameDir | Out-Null
$deadline = (Get-Date).AddMinutes(10)
$ready = $false
$gone = 0
while ((Get-Date) -lt $deadline -and $gone -lt 5) {
Start-Sleep -Seconds 3
try {
if ((Test-Path -LiteralPath $log) -and (Select-String -LiteralPath $log -Pattern '\d+ Mods? loaded' -Quiet)) { $ready = $true; break }
} catch { }
# steam can relaunch the exe through itself, a few misses in a row is a real exit
$gone = if (Get-Process -Name $GameName -ErrorAction SilentlyContinue) { 0 } else { $gone + 1 }
}
Start-Sleep -Seconds 3
Get-Process -Name $GameName -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
if ($ready) {
Ok 'MelonLoader is set up'
} else {
Warn_ "MelonLoader didn't finish, the game's next start takes a minute longer. why: $log"
}
}
function Install-GameBridge {
# set before the first save too, the boot sync skips a folder that isn't there yet
$saveDir = Join-Path $env:USERPROFILE "AppData\LocalLow\IncontinentCell\$GameName"
Set-EnvKey 'GAME_BRIDGE' 'on'
Set-EnvKey 'GAME_SAVE_DIR' $saveDir
if (Test-Path -LiteralPath $saveDir -PathType Container) {
Ok "game saves: $saveDir"
} else {
Note "no saves yet, she reads them from $saveDir once you've played"
}
$gameDir = Find-GameDir
if (-not $gameDir -and $interactive) {
$p = Read-Styled " ${OK}▸${R} paste the game folder or drag $GameName.exe here ${DIM}[Enter = skip the mod]${R} ${ACCENT}›${R} "
$p = $p.Trim().Trim('"').Trim("'")
if ($p -and (Test-Path -LiteralPath $p -PathType Leaf)) { $p = Split-Path -Parent $p }
if ($p -and (Test-Path -LiteralPath (Join-Path $p "${GameName}_Data"))) { $gameDir = $p }
}
if (-not $gameDir) {
Note 'no game folder, so no in-game mod. the boot sync still works'
return
}
Set-EnvKey 'JUN_GAME_DIR' $gameDir
try {
Install-BridgeMod $gameDir
Initialize-MelonLoader $gameDir
Note 'once she is up, start the game. it asks you to sign in with your Jun OS account'
} catch {
Warn_ ("in-game mod not installed: {0}. the boot sync still works" -f $_.Exception.Message)
}
}
# markers, not the folder name. JUN_DIR lets people call it
# whatever they want, and "Jun" on its own could be anything.
function Test-JunCheckout([string]$path) {
if (-not $path) { return $false }
return (Test-Path (Join-Path $path 'start.ps1')) -and
(Test-Path (Join-Path $path 'docker-compose.yml')) -and
(Test-Path (Join-Path $path 'webapp'))
}
# people re-run the install line from inside the checkout they
# already have (or from webapp\ two levels down). without this we
# clone Jun\Jun next to it and set up a second copy fighting the
# first one for :80. so: look at $dir, then walk up from here. an
# explicit JUN_DIR is a decision, it wins.
function Find-JunInstall {
if (Test-JunCheckout $dir) { return (Resolve-Path -LiteralPath $dir).Path }
if ($env:JUN_DIR) { return $null }
$d = (Get-Location).Path
while ($d) {
if (Test-JunCheckout $d) { return $d }
$d = Split-Path -Parent $d
}
# where Resolve-FreshDir puts her when the terminal sat somewhere
# bad. a re-run from any other folder still has to find her.
$inHome = Join-Path $HOME $dir
if (Test-JunCheckout $inHome) { return $inHome }
return $null
}
function Test-UnderPath([string]$path, [string[]]$roots) {
foreach ($r in $roots) {
if (-not $r) { continue }
$r = $r.TrimEnd('\')
if ($path -ieq $r -or $path.StartsWith($r + '\', [StringComparison]::OrdinalIgnoreCase)) { return $true }
}
return $false
}
function Test-Writable([string]$path) {
$probe = Join-Path $path ('.jun-write-test-' + [guid]::NewGuid().ToString('N'))
try {
[IO.File]::WriteAllText($probe, '')
Remove-Item -LiteralPath $probe -Force
return $true
} catch {
return $false
}
}
# where a fresh clone goes. JUN_DIR is a decision and wins.
# otherwise next to wherever the terminal is, unless that's
# somewhere she can't live. an admin terminal opens in System32,
# Program Files wants admin for every write, and OneDrive syncs
# the live SQLite file and GBs of runtime while she runs (locked
# DB, full cloud). those go to the home folder instead.
function Resolve-FreshDir {
$syncRoots = @($env:OneDrive, $env:OneDriveConsumer, $env:OneDriveCommercial)
if ($env:JUN_DIR) {
$full = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($dir)
if (Test-UnderPath $full $syncRoots) {
Warn_ "$full is inside OneDrive. her database and runtime will sync while she runs."
Note 'it works, but a folder outside OneDrive (like your home folder) is the safer pick.'
}
return $full
}
$here = (Get-Location).ProviderPath
$system = @($env:SystemRoot, $env:ProgramFiles, ${env:ProgramFiles(x86)}, $env:ProgramData)
if (-not (Test-UnderPath $here ($system + $syncRoots)) -and (Test-Writable $here)) {
return (Join-Path $here $dir)
}
$target = Join-Path $HOME $dir
Note "not installing inside $here - using $target instead (JUN_DIR picks another folder)"
return $target
}
# one cheap request before anything big. without it a dead
# connection shows up three steps later as a git or winget error
# nobody can read.
function Test-GitHub {
try {
Invoke-WebRequest -Uri 'https://github.com' -Method Head -UseBasicParsing -TimeoutSec 20 | Out-Null
return $true
} catch {
return $false
}
}
# models land in the profile (ollama keeps them in
# %USERPROFILE%\.ollama), runtime and venvs in the install folder,
# so both drives get looked at. ~20 GB is Ollama + the 12B model +
# voice + karaoke with a bit of room, E2B gets by on less. rough
# numbers, source: trust me bro.
function Confirm-FreeSpace([string]$installPath) {
$low = @{}
foreach ($p in @($installPath, $HOME)) {
$root = [IO.Path]::GetPathRoot($p)
if (-not $root -or $low.ContainsKey($root)) { continue }
try { $free = ([IO.DriveInfo]::new($root)).AvailableFreeSpace } catch { continue }
if ($free -lt 20GB) { $low[$root] = $free }
}
if ($low.Count -eq 0) { return }
foreach ($root in $low.Keys) {
Warn_ ("only {0:N1} GB free on {1} - a full local install wants about 20 GB" -f ($low[$root] / 1GB), $root)
}
Note 'a model download that runs out of space fails halfway and has to start over.'
if (-not ($interactive -or $script:expressInteractive)) { return }
$a = Read-Styled " ${OK}▸${R} carry on anyway? ${DIM}[y/N]${R} ${ACCENT}›${R} "
if ($a -notmatch '^(y|yes)$') {
Note 'free some space (or set JUN_DIR to a roomier drive) and run this again.'
Exit-Install 1
}
}
# the checks that turn "it broke somewhere" into one clear line
# before anything gets installed.
function Test-Preflight {
if ([Environment]::OSVersion.Version.Build -lt 17763) {
Warn_ 'this Windows is older than Windows 10 1809. winget and current Ollama need'
Warn_ '1809 or newer, so expect the next steps to fail. Windows Update first.'
}
$me = [Security.Principal.WindowsIdentity]::GetCurrent()
$admin = ([Security.Principal.WindowsPrincipal]$me).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)
if (-not $admin) { return }
# elevated as yourself is fine. elevated as a DIFFERENT account
# (typed the admin password of another user at the UAC prompt)
# installs Ollama, the models and her folder into that other
# profile, and none of it is there when you log in normally.
$console = ''
try { $console = [string](Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).UserName } catch {}
if (-not $console -or $console -ieq $me.Name) { return }
Warn_ "this terminal runs as $($me.Name), but $console is the one logged in."
Note "she would get installed for $($me.Name) and not show up for $console."
Note 'no admin terminal needed - a normal PowerShell window works.'
if (-not ($interactive -or $script:expressInteractive)) { return }
$a = Read-Styled " ${OK}▸${R} install for $($me.Name) anyway? ${DIM}[y/N]${R} ${ACCENT}›${R} "
if ($a -notmatch '^(y|yes)$') { Exit-Install 1 }
}
# stable = newest full release, latest = newest release of any
# kind (pre-releases count), experimental = main.
function ConvertTo-Channel([string]$name) {
switch -Regex ($name.Trim().ToLower()) {
'^(stable|release)$' { return 'stable' }
'^(latest|pre-?release|beta|preview)$' { return 'latest' }
'^(experimental|main|dev|nightly)$' { return 'experimental' }
}
return ''
}
# JUN_CHANNEL, else what the install recorded in its .env, else
# stable. Custom asks, with that as the default.
function Resolve-Channel([string]$existing) {
if ($env:JUN_CHANNEL) {
$c = ConvertTo-Channel $env:JUN_CHANNEL
if ($c) { return $c }
Warn_ "JUN_CHANNEL='$($env:JUN_CHANNEL)' isn't a channel (stable, latest or experimental)"
}
$current = 'stable'
if ($existing) {
$saved = ConvertTo-Channel (Get-EnvValue 'JUN_CHANNEL' (Join-Path $existing '.env'))
if ($saved) {
$current = $saved
} elseif (Test-Path (Join-Path $existing '.git')) {
# installs from before channels all sit on main. moving them
# to a release now could mean OLDER code than they run, so they
# stay experimental until somebody picks otherwise.
Invoke-Git -C $existing symbolic-ref -q HEAD | Out-Null
if ($LASTEXITCODE -eq 0) { $current = 'experimental' }
}
}
if (-not $interactive) { return $current }
Write-Host ''
Write-Host " ${B}which release channel?${R}"
Write-Host " ${ACCENT}[1]${R} Stable ${DIM}- the latest full release${R}"
Write-Host " ${ACCENT}[2]${R} Latest ${DIM}- the newest release, pre-releases included${R}"
Write-Host " ${ACCENT}[3]${R} Experimental ${DIM}- straight from main, can break any day${R}"
$ans = Read-Styled " ${OK}▸${R} choice ${DIM}[Enter = $current]${R} ${ACCENT}›${R} "
switch -Regex ($ans) {
'^$' { return $current }
'^1$' { return 'stable' }
'^2$' { return 'latest' }
'^3$' { return 'experimental' }
}
$c = ConvertTo-Channel $ans
if ($c) { return $c }
Warn_ "unrecognized choice, staying on $current"
return $current
}
function Get-GitHubSlug([string]$url) {
if ($url -match '^https://github\.com/([^/]+)/([^/]+?)(\.git)?/?$') { return "$($matches[1])/$($matches[2])" }
return ''
}
# the tag a release channel points at right now. '' = no release
# of that kind exists, $null = GitHub couldn't be asked (offline,
# rate limited at 60 an hour per IP, not a github URL). anonymous
# requests never see drafts, so a draft never gets installed.
function Get-ReleaseTag([string]$channel) {
$slug = Get-GitHubSlug $repo
if (-not $slug) { return $null }
$api = "https://api.github.com/repos/$slug/releases"
$headers = @{ 'User-Agent' = 'JunOS-installer'; 'Accept' = 'application/vnd.github+json' }
try {
if ($channel -eq 'stable') {
try {
return [string](Invoke-RestMethod -Uri "$api/latest" -Headers $headers -UseBasicParsing).tag_name
} catch {
if ([int]$_.Exception.Response.StatusCode -ne 404) { throw }
}
}
# assigned first on purpose. 5.1 hands a JSON array down the
# pipeline as ONE object, piping it straight on would filter
# the array as a whole.
$list = Invoke-RestMethod -Uri "$api`?per_page=10" -Headers $headers -UseBasicParsing
$newest = $list | Where-Object { -not $_.draft } | Select-Object -First 1
if (-not $newest) { return '' }
if ($channel -eq 'stable') { Note "no full release yet - using the newest pre-release, $($newest.tag_name)" }
return [string]$newest.tag_name
} catch {
# the API said no. on latest that means a full release where a
# newer pre-release might exist, which is still a release and
# never main. Update-JunRepo won't step back to it either.
$tag = Get-WebLatestTag $slug
if ($tag) { Note "GitHub's API didn't answer - going by the newest full release, $tag" }
return $tag
}
}
# the website, not the API, so no 60-an-hour cap. /releases/latest
# 302s to /releases/tag/<newest full release>, or to plain
# /releases when there's no full release. it never sees drafts or
# pre-releases. (releases.atom is NOT a fallback, it lists bare
# tags too, draft ones included)
function Get-WebLatestTag([string]$slug) {
try {
$req = [Net.HttpWebRequest]::Create("https://github.com/$slug/releases/latest")
$req.AllowAutoRedirect = $false
$req.UserAgent = 'JunOS-installer'
$resp = $req.GetResponse()
try { $loc = [string]$resp.Headers['Location'] } finally { $resp.Close() }
} catch {
return $null
}
if ($loc -match '/releases/tag/([^/?#]+)$') { return $matches[1] }
return $null
}
# the ref to put on disk, or $null for "leave it alone".
function Resolve-TargetRef([string]$channel, [bool]$fresh) {
if ($channel -eq 'experimental') { return 'main' }
$tag = Get-ReleaseTag $channel
if ($tag) { return $tag }
# main is NEVER the fallback. it's whatever got pushed today,
# and the only way onto it is asking for experimental.
if ($null -ne $tag) {
if (-not $fresh) { Note "no $channel release published yet - keeping the code that's on disk"; return $null }
Fail_ 'no release of Jun is published yet'
} else {
Warn_ "couldn't ask GitHub which release is current"
if (-not $fresh) { Note "keeping the code that's on disk"; return $null }
Note 'offline, or rate limited (GitHub answers 60 of these an hour per IP). give it a bit and run this again.'
}
Note 'JUN_REF=<tag> installs a release you name, JUN_CHANNEL=experimental installs main on purpose.'
Exit-Install 1
}
# semver-ish, -1/0/1. v1.2.0 beats v1.2.0-beta, two pre-releases
# of the same version compare as plain strings. a -security tag
# sorts right above the same tag without it, or an install on
# v1.0.4 would read v1.0.4-security as a pre-release, stay put,
# and start.ps1 would refuse to boot it forever.
function Compare-JunVersion([string]$a, [string]$b) {
$pa = Split-JunVersion $a
$pb = Split-JunVersion $b
for ($i = 0; $i -lt 4; $i++) {
if ($pa.Core[$i] -ne $pb.Core[$i]) { return [Math]::Sign($pa.Core[$i] - $pb.Core[$i]) }
}
if ($pa.Pre -ne $pb.Pre) {
if (-not $pa.Pre) { return 1 }
if (-not $pb.Pre) { return -1 }
return [Math]::Sign([string]::CompareOrdinal($pa.Pre, $pb.Pre))
}
return [Math]::Sign([int]$pa.Security - [int]$pb.Security)
}
function Split-JunVersion([string]$v) {
$v = $v -replace '^v', ''
$security = $v -match '-security$'
$core, $pre = ($v -replace '-security$', '') -split '-', 2
$nums = @($core -split '\.' | ForEach-Object { $n = 0; [void][int]::TryParse($_, [ref]$n); $n }) + @(0, 0, 0, 0)
return @{ Core = $nums[0..3]; Pre = "$pre"; Security = $security }
}
# into a side folder first, renamed once git is done. a clone that
# died halfway (Ctrl+C, wifi, antivirus) otherwise leaves a folder
# that isn't a checkout but isn't empty either, and every re-run
# after that trips over it.
function Copy-JunRepo([string]$target, [string]$into) {
if (Test-Path -LiteralPath $into) {
if (@(Get-ChildItem -LiteralPath $into -Force).Count -gt 0) {
Fail_ "$into already exists and isn't a Jun install"
Note 'move or rename that folder (or set JUN_DIR to another one), then run this again.'
Exit-Install 1
}
Remove-Item -LiteralPath $into -Force
}
$staging = "$into.partial"
if (Test-Path -LiteralPath $staging) { Remove-Item -LiteralPath $staging -Recurse -Force }
$parent = Split-Path -Parent $into
if ($parent -and -not (Test-Path -LiteralPath $parent)) { New-Item -ItemType Directory -Force -Path $parent | Out-Null }
Note "downloading $target from $repo"
# core.longpaths because a deep install folder plus the repo's
# own nesting can pass windows' 260 character MAX_PATH, and then
# git fails the checkout halfway.
$out = Invoke-Git -c core.longpaths=true -c advice.detachedHead=false clone -q --depth 1 --branch $target $repo $staging
if ($LASTEXITCODE -ne 0) {
$out | ForEach-Object { Write-Host " $_" }
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
Fail_ "couldn't download Jun ($target)"
Note 'check the connection (and any VPN, proxy or antivirus in the way), then run this again.'
Exit-Install 1
}
if (-not (Test-PinnedRev $staging 'HEAD')) {
Remove-Item -LiteralPath $staging -Recurse -Force -ErrorAction SilentlyContinue
Exit-Install 1
}
Invoke-Git -C $staging config core.longpaths true | Out-Null
Move-Item -LiteralPath $staging -Destination $into
Ok "Jun $target"
}
# moves an existing checkout to $target. a tree with edits in it
# stays exactly where it is, same as the old pull --ff-only did.
function Update-JunRepo([string]$path, [string]$target, [bool]$isTag) {
$dirty = Invoke-Git -C $path status --porcelain --untracked-files=no
if ($LASTEXITCODE -ne 0 -or $dirty) {
Warn_ "files in $path were changed by hand - keeping the code that's on disk"
Note "see what changed with: git -C `"$path`" status"
return
}
$branch = (Invoke-Git -C $path symbolic-ref -q --short HEAD) -join ''
if ($LASTEXITCODE -ne 0) { $branch = '' }
if ($target -eq 'main' -and $branch) {
Invoke-Git -C $path pull -q --ff-only | Out-Null
if ($LASTEXITCODE -eq 0) {
Ok "up to date with $branch"
} else {
# local commits or a branch of their own. not a reason to
# stop, the rest still fixes .env, deps and the stack.
Warn_ "couldn't fast-forward $branch - keeping the code that's on disk"
Note "pull it yourself with: git -C `"$path`" pull"
}
return
}
$current = (Invoke-Git -C $path describe --tags --exact-match HEAD) -join ''
if ($LASTEXITCODE -ne 0) { $current = '' }
# the tag NAME matching isn't enough for a pinned exe. a local tag
# can be stale (a draft's tag that got moved before release), and
# then only a fetch gets the commit the pin wants.
$headSha = ((Invoke-Git -C $path rev-parse HEAD) -join '').Trim().ToLower()
if ($current -and $current -eq $target -and (-not $pinSha -or $headSha -eq $pinSha)) {
Ok "already on $target"
return
}
if ($isTag -and $current -and (Compare-JunVersion $target $current) -lt 0) {
$what = if ($script:channel) { "$($script:channel) ($target)" } else { $target }
Ok "on $current, newer than $what - staying until it catches up"
return
}
if ($isTag -and $branch) {
Warn_ "moving from $branch to the $target release - that can be older code than you had."
Note 'your chats and settings stay. JUN_CHANNEL=experimental takes you back.'
}
if ($target -eq 'main') {
# a tag clone only fetches that one tag. point origin at main
# again so a later plain `git pull` works too.
Invoke-Git -C $path config --replace-all remote.origin.fetch '+refs/heads/main:refs/remotes/origin/main' | Out-Null
$out = Invoke-Git -C $path fetch -q --depth 1 origin
if ($LASTEXITCODE -eq 0) { $out = Invoke-Git -C $path checkout -q -B main origin/main }
if ($LASTEXITCODE -eq 0) { $out = Invoke-Git -C $path branch -q --set-upstream-to=origin/main main }
} elseif ($isTag) {
$out = Invoke-Git -C $path fetch -q --depth 1 origin "+refs/tags/${target}:refs/tags/${target}"
if ($LASTEXITCODE -eq 0 -and -not (Test-PinnedRev $path "refs/tags/$target")) { return }
if ($LASTEXITCODE -eq 0) { $out = Invoke-Git -C $path -c advice.detachedHead=false checkout -q "refs/tags/$target" }
} else {
$out = Invoke-Git -C $path fetch -q --depth 1 origin $target
if ($LASTEXITCODE -eq 0 -and -not (Test-PinnedRev $path 'FETCH_HEAD')) { return }
if ($LASTEXITCODE -eq 0) { $out = Invoke-Git -C $path -c advice.detachedHead=false checkout -q --detach FETCH_HEAD }
}
if ($LASTEXITCODE -ne 0) {
$out | ForEach-Object { Write-Host " $_" }
Warn_ "couldn't move to $target - keeping the code that's on disk"
return
}
Ok "now on $target"
}
# checked BEFORE a checkout or the rename into place, so code that
# isn't the pinned commit never lands where start.ps1 would run it.
function Test-PinnedRev([string]$path, [string]$rev) {
if (-not $pinSha) { return $true }
$got = ((Invoke-Git -C $path rev-parse "$rev^{commit}") -join '').Trim().ToLower()
if ($got -eq $pinSha) { return $true }
Fail_ "$ref should be commit $pinSha, but GitHub handed over $got"
Note 'not installing code this installer was not built for.'
Note 'get JunSetup again from the releases page, or tell us in an issue if it keeps happening.'
return $false
}
# a pinned run ends on the pinned commit or not at all. that
# covers every way Update-JunRepo can leave the old code in place
# (edited files, a newer release already there, a failed fetch).
function Assert-PinnedCheckout([string]$path) {
if (-not $pinSha) { return }
$head = ''
if (Test-Path (Join-Path $path '.git')) { $head = ((Invoke-Git -C $path rev-parse HEAD) -join '').Trim().ToLower() }
if ($head -eq $pinSha) { Ok "commit $($pinSha.Substring(0, 12)) matches this installer"; return }
Fail_ "this installer only sets up $ref, and $path is on something else"
Note 'a newer version there? use a newer JunSetup, or the install line from the README.'
Exit-Install 1
}
# the installer that came with the code finishes the job, not
# whichever copy got started (main's one-liner, an older exe). a
# release's start.ps1 and .env keys match ITS install.ps1. the
# child skips the banner, the questions already answered and the
# git step. installers from before JUN_HANDOFF existed don't know
# any of that, those we don't hand to.
function Invoke-Handoff([string]$path) {
if ($handoff) { return }
$theirs = Join-Path $path 'install.ps1'
# an installer from before Cloud reads JUN_YES=1 as plain
# Express, installs ollama and pulls her onto this PC. 8 GB, for nothing
if ($script:cloud -and -not ((Test-Path -LiteralPath $theirs) -and
(Select-String -LiteralPath $theirs -Pattern 'JUN_CLOUD' -SimpleMatch -Quiet))) {
Fail_ "the Jun this installs doesn't know cloud installs yet"
Note "re-run with `$env:JUN_CHANNEL='experimental', or pick Express or Custom"
Exit-Install 1
}
if (-not (Test-Path -LiteralPath $theirs)) { return }
if (-not (Select-String -LiteralPath $theirs -Pattern 'JUN_HANDOFF' -SimpleMatch -Quiet)) { return }
# an older one reads our JUN_YES=1 as Express and redoes .env
if ($script:reuse -and -not (Select-String -LiteralPath $theirs -Pattern 'JUN_REUSE' -SimpleMatch -Quiet)) { return }
if ($PSCommandPath -and (Test-Path -LiteralPath $PSCommandPath) -and
(Get-FileHash -LiteralPath $PSCommandPath).Hash -eq (Get-FileHash -LiteralPath $theirs).Hash) { return }
$env:JUN_HANDOFF = '1'
$env:JUN_DIR = $path
if ($script:channel) { $env:JUN_CHANNEL = $script:channel }
$psExe = (Get-Process -Id $PID).MainModule.FileName
& $psExe -NoProfile -ExecutionPolicy Bypass -File $theirs
Exit-Install $LASTEXITCODE
}
# the x86 PowerShell is still in the start menu. from there
# System32 quietly means SysWOW64: the VC++ runtime check finds
# the 32 bit dll and PHP dies anyway, nvidia-smi isn't found so
# she gets the CPU model. hop to the 64 bit one.
if ([Environment]::Is64BitOperatingSystem -and -not [Environment]::Is64BitProcess) {
$native = Join-Path $env:SystemRoot 'Sysnative\WindowsPowerShell\v1.0\powershell.exe'
if ($PSCommandPath -and (Test-Path -LiteralPath $native)) {
& $native -NoProfile -ExecutionPolicy Bypass -File $PSCommandPath
exit $LASTEXITCODE
}
Fail_ 'this is the 32-bit (x86) PowerShell. open the normal "Windows PowerShell" and run the install line there.'
Exit-Install 1
}
if (-not [Environment]::Is64BitOperatingSystem) {
Fail_ 'Jun needs 64-bit Windows. PHP, Caddy and Ollama have no 32-bit builds.'
Exit-Install 1
}
# the manager's Configure switches. the code doesn't move
$only = @("$env:JUN_ONLY" -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($only) {
if (-not (Test-JunCheckout (Get-Location).Path) -or -not (Test-Path .env)) {
Fail_ "run JUN_ONLY=$($env:JUN_ONLY) from an installed Jun folder"
Exit-Install 1
}
if ('voice' -in $only) {
$karaoke = if ((Get-EnvValue 'KARAOKE') -match '^(on|1|true|yes)$') { 'on' } else { 'off' }
Install-Tts $karaoke
}
if ('bridge' -in $only) {
Step 'game bridge'
Install-GameBridge
}
Exit-Install 0
}
if (-not $handoff) { Show-Banner }
$script:reuse = $false
$existing = Find-JunInstall
Choose-Reuse $existing
if (-not $script:reuse) { Choose-InstallMode }
# nothing gets cloned when she's already here, so the fork
# warning has nothing to warn about.
if (-not $existing) { Confirm-RepoSource }
if (-not $handoff) { Test-Preflight }
Step 'check dependencies'
if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
Warn_ 'git not found'
Install-MachineTools @('git')
}
Assert-OnPath git
Ok 'git found'
$script:channel = ''
if ($handoff) {
$script:channel = ConvertTo-Channel "$env:JUN_CHANNEL"
} elseif (-not $ref) {
$script:channel = Resolve-Channel $existing
Ok "channel $($script:channel)"
}
if ($existing) {
$dir = $existing
Step 'existing install'
Ok "found Jun in $dir - updating instead of cloning"
if ($handoff) {
Ok 'code already updated'
} elseif (-not (Test-Path (Join-Path $dir '.git'))) {
Note 'not a git checkout, nothing to pull - re-running setup on what is here'
} elseif (-not (Test-GitHub)) {
Warn_ "can't reach github.com - keeping the code that's on disk"
} else {
$target = if ($ref) { $ref } else { Resolve-TargetRef $script:channel $false }
if ($target) { Update-JunRepo $dir $target ((-not $ref -and $target -ne 'main') -or [bool]$pinSha) }
}
if (-not $handoff) { Assert-PinnedCheckout $dir }
} else {
$dir = Resolve-FreshDir
if (-not (Test-GitHub)) {
Fail_ "can't reach github.com"
Note 'check the internet connection, then any VPN, proxy or firewall, and that the'
Note "PC's date and time are right (a wrong clock breaks every https download)."
Exit-Install 1
}
Confirm-FreeSpace $dir
$target = if ($ref) { $ref } else { Resolve-TargetRef $script:channel $true }
Step 'download Jun'
Copy-JunRepo $target $dir
}
Set-Location -LiteralPath $dir
if (-not (Test-Path .env)) { Copy-Item .env.example .env }
# every run, not just the first. an .env from an older install is
# exactly the one still sitting there with the folder's inherited
# ACL on it.
Protect-EnvFile
if ($script:channel) { Set-EnvKey 'JUN_CHANNEL' $script:channel }
Invoke-Handoff (Get-Location).ProviderPath
$cfg = Configure-Jun
$voice = $cfg.voice
$missing = @()
if ($cfg.needsOllama -and -not (Get-Command ollama -ErrorAction SilentlyContinue)) { $missing += 'ollama' }
if ($cfg.needsLlamacpp -and -not (Test-LlamaServerBuild)) { $missing += 'llamacpp' }
Install-MachineTools $missing
if ($cfg.needsOllama) { Assert-OnPath ollama }
if ($cfg.needsLlamacpp) { Assert-OnPath llama-server }
Install-Php
Install-Caddy
if ($voice -eq 'on') { Install-Tts $cfg.karaoke }
$managerExe = Install-Manager
if ($cfg.shortcuts -eq 'on') { New-JunShortcuts $managerExe }
Step 'asset policy'
Warn_ "Jun's Live2D model & textures belong to the creator of"
Warn_ 'My Dystopian Robot Girlfriend. tools/recover_assets.py rebuilds'
Warn_ 'them from YOUR game copy, for personal use only - do NOT'
Warn_ 'republish them (public fork, release, mirror). See the NOTICE file.'
# extract only from the user's own game install. nothing
# downloads or leaves the box. Express enables it, Custom asks.
# JUN_EXTRACT=1 forces it, 0 opts out. no game means
# recover_assets.py leaves placeholders and install continues.
$extract = $false
switch -Regex ($env:JUN_EXTRACT) {
'^(1|on|yes|true)$' { $extract = $true }
'^(0|off|no|false)$' { $extract = $false }
default {
if ($script:reuse) {
$extract = -not (Test-Path 'webapp\assets')
} elseif ($env:JUN_YES -eq '1') {
$extract = $true
} elseif ($interactive) {
$e = Read-Styled " ${OK}▸${R} extract them now from your game install? ${DIM}[y/N]${R} ${ACCENT}›${R} "
$extract = $e -match '^(y|yes)$'
}
}
}
if ($extract) {
# Missing Python, venv creation errors and pip failures are
# nonfatal here. Continue with placeholder assets.
try {
Install-AssetRecovery
} catch {
Warn_ ("asset recovery stopped: {0}" -f $_.Exception.Message)
Note 'carrying on with placeholder art - re-run install.ps1 with $env:JUN_EXTRACT=1 to try again.'
}
} else {
Note 'skipped - re-run install.ps1 with JUN_EXTRACT=1 anytime to extract.'
}
Step 'game bridge'
# JUN_BRIDGE=1 forces it, 0 opts out. otherwise only when asked
$bridge = $false
switch -Regex ($env:JUN_BRIDGE) {
'^(1|on|yes|true)$' { $bridge = $true }
'^(0|off|no|false)$' { $bridge = $false }
default {
if ($script:reuse) {
$bridge = (Get-EnvValue 'GAME_BRIDGE') -match '^(on|1|true|yes)$'
} elseif ($interactive -or $script:expressInteractive) {
$b = Read-Styled " ${OK}▸${R} bridge her with your game save (affection, outfit, what happened)? ${DIM}[y/N]${R} ${ACCENT}›${R} "
$bridge = $b -match '^(y|yes)$'
}
}
}
if ($bridge) { Install-GameBridge } else { Note 'skipped - re-run install.ps1 with JUN_BRIDGE=1 anytime.' }
Write-Host ''
Step 'install summary'
$loc = Get-Location
Note "in this folder (${loc}): webapp, PHP, Caddy, TTS venv, models, chat data"
$machineWide = 'git'
if ($cfg.needsOllama) { $machineWide += ', Ollama' }
if ($cfg.needsLlamacpp) { $machineWide += ', llama.cpp' }
if ($voice -eq 'on' -or $extract) { $machineWide += ', possibly Python' }
Note "machine-wide (Settings > Apps): ${machineWide}"
if ($cfg.shortcuts -eq 'on') {
Note 'to start, stop or update her later: the Jun OS shortcut on the desktop'
} elseif ($managerExe) {
Note "to start, stop or update her later: $managerExe"
}
Note 'to remove everything later: ./uninstall.ps1'
if ($script:cloud) { Note "the notebook's URL and key change every run. Start in the manager asks for the new lines when the old ones stop answering" }
$regKey = Get-EnvValue 'OMEGA_REGISTRATION_KEY'
Write-Host ''
Step 'access keys'
if ($regKey) {
Ok "registration $regKey"
Note 'give this to the people you invite - empty OMEGA_REGISTRATION_KEY in .env to open signup to anyone.'
} else {
Note 'registration key is empty, anyone who reaches the page can sign up.'
}
# her cert is signed by a CA made on this PC, for local names and
# private addresses only. start.ps1 does the trusting, because the
# CA doesn't exist until its first run. Express says yes, windows
# still shows its own "install this certificate?" dialog.
# JUN_TRUST_CA=1 forces it, 0 opts out.
$trust = $true
switch -Regex ($env:JUN_TRUST_CA) {
'^(1|on|yes|true)$' { $trust = $true }
'^(0|off|no|false)$' { $trust = $false }
default {
if ($env:JUN_YES -ne '1' -and $interactive) {
$t = Read-Styled " ${OK}▸${R} trust her certificate on this PC, so the browser stops warning? ${DIM}[Y/n]${R} ${ACCENT}›${R} "
$trust = $t -notmatch '^(n|no)$'
}
}
}
# 0 on an update keeps whatever trust start.ps1 already set up, no dialog
if ($script:reuse -and -not $env:JUN_TRUST_CA) { $trust = $false; $env:JUN_TRUST_CA = '0' }
$env:JUN_TRUST_CA = if ($trust) { '1' } else { '0' }
if (-not $trust -and -not $script:reuse) { Note 'certificate not trusted - ./start.ps1 trust does it anytime.' }
Write-Host ''
Write-Host " ${OK}▸${R} ${B}${OK}starting${R} ${DIM}-${R} launching start.ps1"
# re-launch via the same PowerShell with Bypass so the machine's
# execution policy can't block start.ps1. this script may have
# arrived through `iex`.
$psExe = (Get-Process -Id $PID).MainModule.FileName
& $psExe -NoProfile -ExecutionPolicy Bypass -File (Resolve-Path './start.ps1').Path
$startCode = $LASTEXITCODE
# has to run here and not in Configure-Jun. every row of it is a
# real generation, so the models have to be pulled and the stack
# has to be up.
if ($script:mtpAutotune -and $startCode -eq 0) {
Step 'tune multi-token prediction'
& $psExe -NoProfile -ExecutionPolicy Bypass -File (Resolve-Path './mtp-autotune.ps1').Path
if ($LASTEXITCODE -ne 0) {
Warn_ 'autotune failed - drafting 1 token ahead, re-run .\mtp-autotune.ps1 anytime'
}
}
Exit-Install $startCode
# -
# ==:
# :=--. ---
# :--==- :=:
# .:::::::=-==-:
# ::::::::::-==---:-==-=:
# .-=====-::-:-==--==-==--==-=-
# :-=---=--=====--==-==-==-==--==-=
# -=-===-==--=--==--==--=--==-==-:-=-:
# =-==--==-==-==--. =-==-==-=--==-== -=-
# -==-==--=--==-= ::==--==-==-==--=--= ::-
# ::-=--=:-==--=::-::=:=--=--=-==-==-==: ::-
# ::::::-::--==- -:: =- .--==-=--==-=:::: ::.
# :::::::::---= :--.::.= ::::::::::::::::: ::
# :::::::::::-- --:: :: @* :::::::::::::::: ::.
# :::::::::::::.:::::: @@: :::::::::::::::: ::
# ::::::::::::::::: : @%%%@@# ::::::::::::::: ::
# .::::::::::::.:::: %@@. %@@:.:::::::..:::: ::
# ::::::::::::.::::=% :+:## %@%%* : . # : . ::
# :::::::::::::::::% @@== @*%@@%@@% @=.@ :::::
# -====== :::::::::::: ::. -+===-@@%%@@%#:=:= % .:::: :
# :==== ------ :::::.:::::::-.:- %@%%@@%@@#- %** :::::
# :====== :------. :::: ::::: -:*@*%%%@@%@@%%@%:@%@%@ :::::
# -========:. .:- :: :::: .-=**@@@%@@%%@@%@@@%@# :::::
# ================. ::: : -*%%@%%@#%*=:*%@@ :::::.
# ------------======== ::. **- *@@%@@@%@@: ::::::::
# .==-::---==================: ***** : =# ::: ::
# ===-==============. .=======: . ::
# .=============: ========: -======. :: =.
# ==========-- .============ = -=== :: ===
# .=======--: ===============:=####* - : =====
# --------: :=================-:#%%%%:#% -=-===.
# :----: :=============-=====- #%%%%%%% =.=====
# .--:-::=================:==== %%%%%%%% ======:
# :- =====-:============== -= %%%%%%%* :-=======
# ======-:-================: *%%%%%%:::::::-====
# =======:-===================- *%%%% ::=========
# ========- ::==================== %%* ::::-=====-.
# ======:==: :::==================-:#= .:-============-
# =======:==:..:::-==============**#= ::::::-==========
# =========.-:: :-:::==========%%===#%%==- ::::::::-=======: