-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.ps1
More file actions
executable file
·1330 lines (1214 loc) · 57.3 KB
/
Copy pathinstall.ps1
File metadata and controls
executable file
·1330 lines (1214 loc) · 57.3 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
$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' }
$ref = if ($env:JUN_REF) { $env:JUN_REF } else { 'main' }
# 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 { return [Console]::ReadLine() }
catch { return (Read-Host) }
}
# 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' }
$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'
}
$models = @{
'12b' = 'hf.co/efficiencyx/Jun-LoRA-12B-GGUF:Q4_K_M'
'e4b' = 'hf.co/efficiencyx/Jun-LoRA-E4B-GGUF:Q4_K_M'
'e2b' = 'hf.co/efficiencyx/Jun-LoRA-E2B-GGUF:Q4_K_M'
}
function Resolve-Model([string]$a) {
switch -Regex ($a.ToLower()) {
'^(12b|jun|best)$' { return $models['12b'] }
'^(e4b|balanced|fast)$' { return $models['e4b'] }
'^(e2b|fastest|cpu)$' { return $models['e2b'] }
default { return $a }
}
}
# 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' { return 5000 }
'E2B.*Q6_K' { return 4500 }
'E2B' { return 3400 }
default { return 0 }
}
}
# no nvidia-smi equivalent on the AMD side, so we ask Windows.
# AdapterRAM is a 32 bit field and anything past 4GB comes back
# wrong, which is why the registry's qwMemorySize goes first and
# AdapterRAM is only the fallback. same order and same keys as
# installer-gui.ps1.
function Get-AmdMemoryMb {
try {
$controllers = @(Get-CimInstance Win32_VideoController -ErrorAction Stop |
Where-Object { $_.Name -match '(?i)AMD|Radeon' })
if ($controllers.Count -eq 0) { return @() }
$names = @($controllers | ForEach-Object { $_.Name } | Sort-Object -Unique)
$registryMemory = @(Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Video\*\0000' -ErrorAction SilentlyContinue |
Where-Object { $names -contains [string]$_.'HardwareInformation.AdapterString' } |
ForEach-Object {
$memory = $_.'HardwareInformation.qwMemorySize'
if ($memory -is [byte[]] -and $memory.Length -ge 8) {
[BitConverter]::ToUInt64($memory, 0)
} elseif ($null -ne $memory) {
[uint64]$memory
}
} | Where-Object { $_ -gt 0 } | Sort-Object -Unique)
if ($registryMemory.Count -gt 0) {
return @($registryMemory | ForEach-Object { [int]($_ / 1MB) })
}
return @($controllers | Where-Object { $_.AdapterRAM -gt 0 } |
ForEach-Object { [int]([uint64]$_.AdapterRAM / 1MB) })
} catch {
return @()
}
}
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 }
}
return @(Get-AmdMemoryMb)
}
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 23500) { return 'hf.co/efficiencyx/Jun-LoRA-12B-GGUF:Q8_0' }
if ($mb -ge 15500) { return 'hf.co/efficiencyx/Jun-LoRA-12B-GGUF:Q6_K' }
if ($mb -ge 11500) { return $models['12b'] }
if ($mb -ge 9500) { return 'hf.co/efficiencyx/Jun-LoRA-E4B-GGUF:Q8_0' }
if ($mb -ge 7500) { return $models['e4b'] }
if ($mb -ge 5500) { return 'hf.co/efficiencyx/Jun-LoRA-E2B-GGUF:Q6_K' }
return $models['e2b']
}
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) {
if (-not (Test-Path .env)) { return '' }
$line = Get-Content .env | 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
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 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 1
}
$a = Read-Styled " ${OK}▸${R} clone from it anyway? ${DIM}[y/N]${R} ${ACCENT}›${R} "
if ($a -notmatch '^(y|yes)$') { Fail_ 'aborted.'; exit 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. JUN_EXPRESS=1 picks
# Express before we even ask.
function Choose-InstallMode {
if ($env:JUN_YES -eq '1') { return }
if ($env:JUN_EXPRESS -match '^(1|on|yes|true)$') {
$env:JUN_YES = '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}"
$ans = Read-Styled " ${OK}▸${R} choice ${DIM}[Enter = Express]${R} ${ACCENT}›${R} "
if ($ans -match '^(2|custom)$') {
Note "custom install - I'll ask about each option below"
} else {
$env:JUN_YES = '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 { $models['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 }
default { Warn_ 'unrecognized choice, using recommended model'; $alias = $rec }
}
} else {
$alias = $rec
}
}
return Resolve-Model $alias
}
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) { 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' })
}
# 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
}
function Configure-Jun {
$provider = if ($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} Ollama ${DIM}- local, 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} llama.cpp ${DIM}- local llama-server${R}"
$ans = Read-Styled " ${OK}▸${R} choice ${DIM}[Enter = Ollama]${R} ${ACCENT}›${R} "
$provider = switch ($ans) {
'2' { 'openrouter' }
'3' { 'llamacpp' }
default { 'ollama' }
}
} else {
$provider = 'ollama'
}
}
if ($provider -notin 'ollama', 'openrouter', 'llamacpp') {
Warn_ "unknown provider '$provider', using Ollama"
$provider = 'ollama'
}
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 -eq 'openrouter') { '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
}
}
}
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) {
$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"
Add-EnvKeyIfMissing 'OMEGA_REGISTRATION_KEY'
Add-EnvKeyIfMissing 'SIDECAR_SECRET'
Ok 'registration key ready'
return @{ provider = $provider; voice = $voice; karaoke = $karaoke
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 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 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.
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 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 1 }
}
$answer = Read-Styled (" ${OK}▸${R} install {0} now? ${DIM}[y/N]${R} ${ACCENT}›${R} " -f ($missing -join ' and '))
$proceed = $answer -match '^(y|yes)$'
}
if (-not $proceed) {
Note 'okay, leaving it to you - install the tools above and re-run this installer.'
if ($Optional) { return } else { exit 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.
winget install -e --id $wingetIds[$c] --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.9', [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
}
}
if ($phpReady) { return }
if (Test-Path $phpDir) { Warn_ 'portable PHP is incomplete - repairing it' }
Step 'download portable PHP'
$releases = Invoke-RestMethod 'https://windows.php.net/downloads/releases/releases.json' -UseBasicParsing
$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
$caWant = ((Invoke-WebRequest -Uri 'https://curl.se/ca/cacert.pem.sha256' -UseBasicParsing).Content -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=512K'
'upload_max_filesize=1M'
'memory_limit=128M'
'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.4'
$CaddySha512 = 'cd5ccfd86a4b40732cf715890d0dca5bf3f63adefec5a7914de85adf240c60ce7e5d2791631b88ef9758e46b23bb1730e020b9c5d696889740b284ffd4788e35'
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'
}
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