File size: 250,781 Bytes
f76c374 | 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 1001 1002 1003 1004 1005 1006 1007 1008 1009 1010 1011 1012 1013 1014 1015 1016 1017 1018 1019 1020 1021 1022 1023 1024 1025 1026 1027 1028 1029 1030 1031 1032 1033 1034 1035 1036 1037 1038 1039 1040 1041 1042 1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 1212 1213 1214 1215 1216 1217 1218 1219 1220 1221 1222 1223 1224 1225 1226 1227 1228 1229 1230 1231 1232 1233 1234 1235 1236 1237 1238 1239 1240 1241 1242 1243 1244 1245 1246 1247 1248 1249 1250 1251 1252 1253 1254 1255 1256 1257 1258 1259 1260 1261 1262 1263 1264 1265 1266 1267 1268 1269 1270 1271 1272 1273 1274 1275 1276 1277 1278 1279 1280 1281 1282 1283 1284 1285 1286 1287 1288 1289 1290 1291 1292 1293 1294 1295 1296 1297 1298 1299 1300 1301 1302 1303 1304 1305 1306 1307 1308 1309 1310 1311 1312 1313 1314 1315 1316 1317 1318 1319 1320 1321 1322 1323 1324 1325 1326 1327 1328 1329 1330 1331 1332 1333 1334 1335 1336 1337 1338 1339 1340 1341 1342 1343 1344 1345 1346 1347 1348 1349 1350 1351 1352 1353 1354 1355 1356 1357 1358 1359 1360 1361 1362 1363 1364 1365 1366 1367 1368 1369 1370 1371 1372 1373 1374 1375 1376 1377 1378 1379 1380 1381 1382 1383 1384 1385 1386 1387 1388 1389 1390 1391 1392 1393 1394 1395 1396 1397 1398 1399 1400 1401 1402 1403 1404 1405 1406 1407 1408 1409 1410 1411 1412 1413 1414 1415 1416 1417 1418 1419 1420 1421 1422 1423 1424 1425 1426 1427 1428 1429 1430 1431 1432 1433 1434 1435 1436 1437 1438 1439 1440 1441 1442 1443 1444 1445 1446 1447 1448 1449 1450 1451 1452 1453 1454 1455 1456 1457 1458 1459 1460 1461 1462 1463 1464 1465 1466 1467 1468 1469 1470 1471 1472 1473 1474 1475 1476 1477 1478 1479 1480 1481 1482 1483 1484 1485 1486 1487 1488 1489 1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 1571 1572 1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 1634 1635 1636 1637 1638 1639 1640 1641 1642 1643 1644 1645 1646 1647 1648 1649 1650 1651 1652 1653 1654 1655 1656 1657 1658 1659 1660 1661 1662 1663 1664 1665 1666 1667 1668 1669 1670 1671 1672 1673 1674 1675 1676 1677 1678 1679 1680 1681 1682 1683 1684 1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 1785 1786 1787 1788 1789 1790 1791 1792 1793 1794 1795 1796 1797 1798 1799 1800 1801 1802 1803 1804 1805 1806 1807 1808 1809 1810 1811 1812 1813 1814 1815 1816 1817 1818 1819 1820 1821 1822 1823 1824 1825 1826 1827 1828 1829 1830 1831 1832 1833 1834 1835 1836 1837 1838 1839 1840 1841 1842 1843 1844 1845 1846 1847 1848 1849 1850 1851 1852 1853 1854 1855 1856 1857 1858 1859 1860 1861 1862 1863 1864 1865 1866 1867 1868 1869 1870 1871 1872 1873 1874 1875 1876 1877 1878 1879 1880 1881 1882 1883 1884 1885 1886 1887 1888 1889 1890 1891 1892 1893 1894 1895 1896 1897 1898 1899 1900 1901 1902 1903 1904 1905 1906 1907 1908 1909 1910 1911 1912 1913 1914 1915 1916 1917 1918 1919 1920 1921 1922 1923 1924 1925 1926 1927 1928 1929 1930 1931 1932 1933 1934 1935 1936 1937 1938 1939 1940 1941 1942 1943 1944 1945 1946 1947 1948 1949 1950 1951 1952 1953 1954 1955 1956 1957 1958 1959 1960 1961 1962 1963 1964 1965 1966 1967 1968 1969 1970 1971 1972 1973 1974 1975 1976 1977 1978 1979 1980 1981 1982 1983 1984 1985 1986 1987 1988 1989 1990 1991 1992 1993 1994 1995 1996 1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 2070 2071 2072 2073 2074 2075 2076 2077 2078 2079 2080 2081 2082 2083 2084 2085 2086 2087 2088 2089 2090 2091 2092 2093 2094 2095 2096 2097 2098 2099 2100 2101 2102 2103 2104 2105 2106 2107 2108 2109 2110 2111 2112 2113 2114 2115 2116 2117 2118 2119 2120 2121 2122 2123 2124 2125 2126 2127 2128 2129 2130 2131 2132 2133 2134 2135 2136 2137 2138 2139 2140 2141 2142 2143 2144 2145 2146 2147 2148 2149 2150 2151 2152 2153 2154 2155 2156 2157 2158 2159 2160 2161 2162 2163 2164 2165 2166 2167 2168 2169 2170 2171 2172 2173 2174 2175 2176 2177 2178 2179 2180 2181 2182 2183 2184 2185 2186 2187 2188 2189 2190 2191 2192 2193 2194 2195 2196 2197 2198 2199 2200 2201 2202 2203 2204 2205 2206 2207 2208 2209 2210 2211 2212 2213 2214 2215 2216 2217 2218 2219 2220 2221 2222 2223 2224 2225 2226 2227 2228 2229 2230 2231 2232 2233 2234 2235 2236 2237 2238 2239 2240 2241 2242 2243 2244 2245 2246 2247 2248 2249 2250 2251 2252 2253 2254 2255 2256 2257 2258 2259 2260 2261 2262 2263 2264 2265 2266 2267 2268 2269 2270 2271 2272 2273 2274 2275 2276 2277 2278 2279 2280 2281 2282 2283 2284 2285 2286 2287 2288 2289 2290 2291 2292 2293 2294 2295 2296 2297 2298 2299 2300 2301 2302 2303 2304 2305 2306 2307 2308 2309 2310 2311 2312 2313 2314 2315 2316 2317 2318 2319 2320 2321 2322 2323 2324 2325 2326 2327 2328 2329 2330 2331 2332 2333 2334 2335 2336 2337 2338 2339 2340 2341 2342 2343 2344 2345 2346 2347 2348 2349 2350 2351 2352 2353 2354 2355 2356 2357 2358 2359 2360 2361 2362 2363 2364 2365 2366 2367 2368 2369 2370 2371 2372 2373 2374 2375 2376 2377 2378 2379 2380 2381 2382 2383 2384 2385 2386 2387 2388 2389 2390 2391 2392 2393 2394 2395 2396 2397 2398 2399 2400 2401 2402 2403 2404 2405 2406 2407 2408 2409 2410 2411 2412 2413 2414 2415 2416 2417 2418 2419 2420 2421 2422 2423 2424 2425 2426 2427 2428 2429 2430 2431 2432 2433 2434 2435 2436 2437 2438 2439 2440 2441 2442 2443 2444 2445 2446 2447 2448 2449 2450 2451 2452 2453 2454 2455 2456 2457 2458 2459 2460 2461 2462 2463 2464 2465 2466 2467 2468 2469 2470 2471 2472 2473 2474 2475 2476 2477 2478 2479 2480 2481 2482 2483 2484 2485 2486 2487 2488 2489 2490 2491 2492 2493 2494 2495 2496 2497 2498 2499 2500 2501 2502 2503 2504 2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 2604 2605 2606 2607 2608 2609 2610 2611 2612 2613 2614 2615 2616 2617 2618 2619 2620 2621 2622 2623 2624 2625 2626 2627 2628 2629 2630 2631 2632 2633 2634 2635 2636 2637 2638 2639 2640 2641 2642 2643 2644 2645 2646 2647 2648 2649 2650 2651 2652 2653 2654 2655 2656 2657 2658 2659 2660 2661 2662 2663 2664 2665 2666 2667 2668 2669 2670 2671 2672 2673 2674 2675 2676 2677 2678 2679 2680 2681 2682 2683 2684 2685 2686 2687 2688 2689 2690 2691 2692 2693 2694 2695 2696 2697 2698 2699 2700 2701 2702 2703 2704 2705 2706 2707 2708 2709 2710 2711 2712 2713 2714 2715 2716 2717 2718 2719 2720 2721 2722 2723 2724 2725 2726 2727 2728 2729 2730 2731 2732 2733 2734 2735 2736 2737 2738 2739 2740 2741 2742 2743 2744 2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 2814 2815 2816 2817 2818 2819 2820 2821 2822 2823 2824 2825 2826 2827 2828 2829 2830 2831 2832 2833 2834 2835 2836 2837 2838 2839 2840 2841 2842 2843 2844 2845 2846 2847 2848 2849 2850 2851 2852 2853 2854 2855 2856 2857 2858 2859 2860 2861 2862 2863 2864 2865 2866 2867 2868 2869 2870 2871 2872 2873 2874 2875 2876 2877 2878 2879 2880 2881 2882 2883 2884 2885 2886 2887 2888 2889 2890 2891 2892 2893 2894 2895 2896 2897 2898 2899 2900 2901 2902 2903 2904 2905 2906 2907 2908 2909 2910 2911 2912 2913 2914 2915 2916 2917 2918 2919 2920 2921 2922 2923 2924 2925 2926 2927 2928 2929 2930 2931 2932 2933 2934 2935 2936 2937 2938 2939 2940 2941 2942 2943 2944 2945 2946 2947 2948 2949 2950 2951 2952 2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 3050 3051 3052 3053 3054 3055 3056 3057 3058 3059 3060 3061 3062 3063 3064 3065 3066 3067 3068 3069 3070 3071 3072 3073 3074 3075 3076 3077 3078 3079 3080 3081 3082 3083 3084 3085 3086 3087 3088 3089 3090 3091 3092 3093 3094 3095 3096 3097 3098 3099 3100 3101 3102 3103 3104 3105 3106 3107 3108 3109 3110 3111 3112 3113 3114 3115 3116 3117 3118 3119 3120 3121 3122 3123 3124 3125 3126 3127 3128 3129 3130 3131 3132 3133 3134 3135 3136 3137 3138 3139 3140 3141 3142 3143 3144 3145 3146 3147 3148 3149 3150 3151 3152 3153 3154 3155 3156 3157 3158 3159 3160 3161 3162 3163 3164 3165 3166 3167 3168 3169 3170 3171 3172 3173 3174 3175 3176 3177 3178 3179 3180 3181 3182 3183 3184 3185 3186 3187 3188 3189 3190 3191 3192 3193 3194 3195 3196 3197 3198 3199 3200 3201 3202 3203 3204 3205 3206 3207 3208 3209 3210 3211 3212 3213 3214 3215 3216 3217 3218 3219 3220 3221 3222 3223 3224 3225 3226 3227 3228 3229 3230 3231 3232 3233 3234 3235 3236 3237 3238 3239 3240 3241 3242 3243 3244 3245 3246 3247 3248 3249 3250 3251 3252 3253 3254 3255 3256 3257 3258 3259 3260 3261 3262 3263 3264 3265 3266 3267 3268 3269 3270 3271 3272 3273 3274 3275 3276 3277 3278 3279 3280 3281 3282 3283 3284 3285 3286 3287 3288 3289 3290 3291 3292 3293 3294 3295 3296 3297 3298 3299 3300 3301 3302 3303 3304 3305 3306 3307 3308 3309 3310 3311 3312 3313 3314 3315 3316 3317 3318 3319 3320 3321 3322 3323 3324 3325 3326 3327 3328 3329 3330 3331 3332 3333 3334 3335 3336 3337 3338 3339 3340 3341 3342 3343 3344 3345 3346 3347 3348 3349 3350 3351 3352 3353 3354 3355 3356 3357 3358 3359 3360 3361 3362 3363 3364 3365 3366 3367 3368 3369 3370 3371 3372 3373 3374 3375 3376 3377 3378 3379 3380 3381 3382 3383 3384 3385 3386 3387 3388 3389 3390 3391 3392 3393 3394 3395 3396 3397 3398 3399 3400 3401 3402 3403 3404 3405 3406 3407 3408 3409 3410 3411 3412 3413 3414 3415 3416 3417 3418 3419 3420 3421 3422 3423 3424 3425 3426 3427 3428 3429 3430 3431 3432 3433 3434 3435 3436 3437 3438 3439 3440 3441 3442 3443 3444 3445 3446 3447 3448 3449 3450 3451 3452 3453 3454 3455 3456 3457 3458 3459 3460 3461 3462 3463 3464 3465 3466 3467 3468 3469 3470 3471 3472 3473 3474 3475 3476 3477 3478 3479 3480 3481 3482 3483 3484 3485 3486 3487 3488 3489 3490 3491 3492 3493 3494 3495 3496 3497 3498 3499 3500 3501 3502 3503 3504 3505 3506 3507 3508 3509 3510 3511 3512 3513 3514 3515 3516 3517 3518 3519 3520 3521 3522 3523 3524 3525 3526 3527 3528 3529 3530 3531 3532 3533 3534 3535 3536 3537 3538 3539 3540 3541 3542 3543 3544 3545 3546 3547 3548 3549 3550 3551 3552 3553 3554 3555 3556 3557 3558 3559 3560 3561 3562 3563 3564 3565 3566 3567 3568 3569 3570 3571 3572 3573 3574 3575 3576 3577 3578 3579 3580 3581 3582 3583 3584 3585 3586 3587 3588 3589 3590 3591 3592 3593 3594 3595 3596 3597 3598 3599 3600 3601 3602 3603 3604 3605 3606 3607 3608 3609 3610 3611 3612 3613 3614 3615 3616 3617 3618 3619 3620 3621 3622 3623 3624 3625 3626 3627 3628 3629 3630 3631 3632 3633 3634 3635 3636 3637 3638 3639 3640 3641 3642 3643 3644 3645 3646 3647 3648 3649 3650 3651 3652 3653 3654 3655 3656 3657 3658 3659 3660 3661 3662 3663 3664 3665 3666 3667 3668 3669 3670 3671 3672 3673 3674 3675 3676 3677 3678 3679 3680 3681 3682 3683 3684 3685 3686 3687 3688 3689 3690 3691 3692 3693 3694 3695 3696 3697 3698 3699 3700 3701 3702 3703 3704 3705 3706 3707 3708 3709 3710 3711 3712 3713 3714 3715 3716 3717 3718 3719 3720 3721 3722 3723 3724 3725 3726 3727 3728 3729 3730 3731 3732 3733 3734 3735 3736 3737 3738 3739 3740 3741 3742 3743 3744 3745 3746 3747 3748 3749 3750 3751 3752 3753 3754 3755 3756 3757 3758 3759 3760 3761 3762 3763 3764 3765 3766 3767 3768 3769 3770 3771 3772 3773 3774 3775 3776 3777 3778 3779 3780 3781 3782 3783 3784 3785 3786 3787 3788 3789 3790 3791 3792 3793 3794 3795 3796 3797 3798 3799 3800 3801 3802 3803 3804 3805 3806 3807 3808 3809 3810 3811 3812 3813 3814 3815 3816 3817 3818 3819 3820 3821 3822 3823 3824 3825 3826 3827 3828 3829 3830 3831 3832 3833 3834 3835 3836 3837 3838 3839 3840 3841 3842 3843 3844 3845 3846 3847 3848 3849 3850 3851 3852 3853 3854 3855 3856 3857 3858 3859 3860 3861 3862 3863 3864 3865 3866 3867 3868 3869 3870 3871 3872 3873 3874 3875 3876 3877 3878 3879 3880 3881 3882 3883 3884 3885 3886 3887 3888 3889 3890 3891 3892 3893 3894 3895 3896 3897 3898 3899 3900 3901 3902 3903 3904 3905 3906 3907 3908 3909 3910 3911 3912 3913 3914 3915 3916 3917 3918 3919 3920 3921 3922 3923 3924 3925 3926 3927 3928 3929 3930 3931 3932 3933 3934 3935 3936 3937 3938 3939 3940 3941 3942 3943 3944 3945 3946 3947 3948 3949 3950 3951 3952 3953 3954 3955 3956 3957 3958 3959 3960 3961 3962 3963 3964 3965 3966 3967 3968 3969 3970 3971 3972 3973 3974 3975 3976 3977 3978 3979 3980 3981 3982 3983 3984 3985 3986 3987 3988 3989 3990 3991 3992 3993 3994 3995 3996 3997 3998 3999 4000 4001 4002 4003 4004 4005 4006 4007 4008 4009 4010 4011 4012 4013 4014 4015 4016 4017 4018 4019 4020 4021 4022 4023 4024 4025 4026 4027 4028 4029 4030 4031 4032 4033 4034 4035 4036 4037 4038 4039 4040 4041 4042 4043 4044 4045 4046 4047 4048 4049 4050 4051 4052 4053 4054 4055 4056 4057 4058 4059 4060 4061 4062 4063 4064 4065 4066 4067 4068 4069 4070 4071 4072 4073 4074 4075 4076 4077 4078 4079 4080 4081 4082 4083 4084 4085 4086 4087 4088 4089 4090 4091 4092 4093 4094 4095 4096 4097 4098 4099 4100 4101 4102 4103 4104 4105 4106 4107 4108 4109 4110 4111 4112 4113 4114 4115 4116 4117 4118 4119 4120 4121 4122 4123 4124 4125 4126 4127 4128 4129 4130 4131 4132 4133 4134 4135 4136 4137 4138 4139 4140 4141 4142 4143 4144 4145 4146 4147 4148 4149 4150 4151 4152 4153 4154 4155 4156 4157 4158 4159 4160 4161 4162 4163 4164 4165 4166 4167 4168 4169 4170 4171 4172 4173 4174 4175 4176 4177 4178 4179 4180 4181 4182 4183 4184 4185 4186 4187 4188 4189 4190 4191 4192 4193 4194 4195 4196 4197 4198 4199 4200 4201 4202 4203 4204 4205 4206 4207 4208 4209 4210 4211 4212 4213 4214 4215 4216 4217 4218 4219 4220 4221 4222 4223 4224 4225 4226 4227 4228 4229 4230 4231 4232 4233 4234 4235 4236 4237 4238 4239 4240 4241 4242 4243 4244 4245 4246 4247 4248 4249 4250 4251 4252 4253 4254 4255 4256 4257 4258 4259 4260 4261 4262 4263 4264 4265 4266 4267 4268 4269 4270 4271 4272 4273 4274 4275 4276 4277 4278 4279 4280 4281 4282 4283 4284 4285 4286 4287 4288 4289 4290 4291 4292 4293 4294 4295 4296 4297 4298 4299 4300 4301 4302 4303 4304 4305 4306 4307 4308 4309 4310 4311 4312 4313 4314 4315 4316 4317 4318 4319 4320 4321 4322 4323 4324 4325 4326 4327 4328 4329 4330 4331 4332 4333 4334 4335 4336 4337 4338 4339 4340 4341 4342 4343 4344 4345 4346 4347 4348 4349 4350 4351 4352 4353 4354 4355 4356 4357 4358 4359 4360 4361 4362 4363 4364 4365 4366 4367 4368 4369 4370 4371 4372 4373 4374 4375 4376 4377 4378 4379 4380 4381 4382 4383 4384 4385 4386 4387 4388 4389 4390 4391 4392 4393 4394 4395 4396 4397 4398 4399 4400 4401 4402 4403 4404 4405 4406 4407 4408 4409 4410 4411 4412 4413 4414 4415 4416 4417 4418 4419 4420 4421 4422 4423 4424 4425 4426 4427 4428 4429 4430 4431 4432 4433 4434 4435 4436 4437 4438 4439 4440 4441 4442 4443 4444 4445 4446 4447 4448 4449 4450 4451 4452 4453 4454 4455 4456 4457 4458 4459 4460 4461 4462 4463 4464 4465 4466 4467 4468 4469 4470 4471 4472 4473 4474 4475 4476 4477 4478 4479 4480 4481 4482 4483 4484 4485 4486 4487 4488 4489 4490 4491 4492 4493 4494 4495 4496 4497 4498 4499 4500 4501 4502 4503 4504 4505 4506 4507 4508 4509 4510 4511 4512 4513 4514 4515 4516 4517 4518 4519 4520 4521 4522 4523 4524 4525 4526 4527 4528 4529 4530 4531 4532 4533 4534 4535 4536 4537 4538 4539 4540 4541 4542 4543 4544 4545 4546 4547 4548 4549 4550 4551 4552 4553 4554 4555 4556 4557 4558 4559 4560 4561 4562 4563 4564 4565 4566 4567 4568 4569 4570 4571 4572 4573 4574 4575 4576 4577 4578 4579 4580 4581 4582 4583 4584 4585 4586 4587 4588 4589 4590 4591 4592 4593 4594 4595 4596 4597 4598 4599 4600 4601 4602 4603 4604 4605 4606 4607 4608 4609 4610 4611 4612 4613 4614 4615 4616 4617 4618 4619 4620 4621 4622 4623 4624 4625 4626 4627 4628 4629 4630 4631 4632 4633 4634 4635 4636 4637 4638 4639 4640 4641 4642 4643 4644 4645 4646 4647 4648 4649 4650 4651 4652 4653 4654 4655 4656 4657 4658 4659 4660 4661 4662 4663 4664 4665 4666 4667 4668 4669 4670 4671 4672 4673 4674 4675 4676 4677 4678 4679 4680 4681 4682 4683 4684 4685 4686 4687 4688 4689 4690 4691 4692 4693 4694 4695 4696 4697 4698 4699 4700 4701 4702 4703 4704 4705 4706 4707 4708 4709 4710 4711 4712 4713 4714 4715 4716 4717 4718 4719 4720 4721 4722 4723 4724 4725 4726 4727 4728 4729 4730 4731 4732 4733 4734 4735 4736 4737 4738 4739 4740 4741 4742 4743 4744 4745 4746 4747 4748 4749 4750 4751 4752 4753 4754 4755 4756 4757 4758 4759 4760 4761 4762 4763 4764 4765 4766 4767 4768 4769 4770 4771 4772 4773 4774 4775 4776 4777 4778 4779 4780 4781 4782 4783 4784 4785 4786 4787 4788 4789 4790 4791 4792 4793 4794 4795 4796 4797 4798 4799 4800 4801 4802 4803 4804 4805 4806 4807 4808 4809 4810 4811 4812 4813 4814 4815 4816 4817 4818 4819 4820 4821 4822 4823 4824 4825 4826 4827 4828 4829 4830 4831 4832 4833 4834 4835 4836 4837 4838 4839 4840 4841 4842 4843 4844 4845 4846 4847 4848 4849 4850 4851 4852 4853 4854 4855 4856 4857 4858 4859 4860 4861 4862 4863 4864 4865 4866 4867 4868 4869 4870 4871 4872 4873 4874 4875 4876 4877 4878 4879 4880 4881 4882 4883 4884 4885 4886 4887 4888 4889 4890 4891 4892 4893 4894 4895 4896 4897 4898 4899 4900 4901 4902 4903 4904 4905 4906 4907 4908 4909 4910 4911 4912 4913 4914 4915 4916 4917 4918 4919 4920 4921 4922 4923 4924 4925 4926 4927 4928 4929 4930 4931 4932 4933 4934 4935 4936 4937 4938 4939 4940 4941 4942 4943 4944 4945 4946 4947 4948 4949 4950 4951 4952 4953 4954 4955 4956 4957 4958 4959 4960 4961 4962 4963 4964 4965 4966 4967 4968 4969 4970 4971 4972 4973 4974 4975 4976 4977 4978 4979 4980 4981 4982 4983 4984 4985 4986 4987 4988 4989 4990 4991 4992 4993 4994 4995 4996 4997 4998 4999 5000 5001 5002 5003 5004 5005 5006 5007 5008 5009 5010 5011 5012 5013 5014 5015 5016 5017 5018 5019 5020 5021 5022 5023 5024 5025 5026 5027 5028 5029 5030 5031 5032 5033 5034 5035 5036 5037 5038 5039 5040 5041 5042 5043 5044 5045 5046 5047 5048 5049 5050 5051 5052 5053 5054 5055 5056 5057 5058 5059 5060 5061 5062 5063 5064 | # ============================================================================
# Hermes Agent Installer for Windows
# ============================================================================
# Installation script for Windows (PowerShell).
# Uses uv for fast Python provisioning and package management.
#
# Usage:
# iex (irm https://hermes-agent.nousresearch.com/install.ps1)
#
# Or download and run with options:
# .\install.ps1 -NoVenv -SkipSetup
#
# ============================================================================
param(
[switch]$NoVenv,
[switch]$SkipSetup,
[switch]$SkipComputerUse,
[string]$Branch = "main",
# -Commit and -Tag are higher-precedence variants of -Branch for users
# who need reproducible installs (desktop installer pinning, CI, release
# bundles). When set, the repository stage clones $Branch (faster than
# cloning the full default-branch history) and then `git checkout`s the
# exact ref. Precedence: Commit > Tag > Branch.
[string]$Commit = "",
# Apply -Commit even when it would roll an existing install BACKWARDS.
# Without this the repository stage skips a pin that is already an ancestor
# of HEAD, so a stale baked-in BUILD_PIN_COMMIT can't downgrade a current
# checkout. Reproducible/CI installs that genuinely want an older SHA on an
# existing tree pass -ForceCommit.
[switch]$ForceCommit,
[string]$Tag = "",
[string]$HermesHome = $(if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }),
[string]$InstallDir = $(if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" }),
# --- Stage protocol (additive; default invocation behaves as before) ----
# See the "Stage protocol" section near the bottom of the file for the
# full contract. Intended for programmatic drivers (the desktop GUI's
# onboarding wizard, CI, future install.sh parity, etc.). CLI users
# running the canonical `irm | iex` one-liner never touch these flags.
[switch]$Manifest,
[string]$Stage,
[switch]$ProtocolVersion,
[switch]$NonInteractive,
[switch]$Json,
# Print the paths this install would use, as JSON, and exit without
# touching anything. The first question on any "installer says a path
# doesn't exist" report is which paths it actually resolved -- especially
# on profiles Windows exposes through an 8.3 alias, where what the user
# sees in Explorer and what the installer receives differ.
#
# powershell -File install.ps1 -ShowResolvedPaths
[switch]$ShowResolvedPaths,
# --- Ensure mode (dep_ensure.py entry point) ---
[string]$Ensure = "",
[switch]$PostInstall,
# --- Desktop GUI build (opt-in) ---
# When set, install.ps1 includes Stage-Desktop in the manifest and
# builds apps/desktop into a launchable Hermes.exe.
#
# Why opt-in:
# * Hermes-Setup.exe (the signed Tauri bootstrap installer) passes
# -IncludeDesktop so a user who installed via the GUI ends up
# with a launchable desktop binary.
# * The Electron desktop's own bootstrap-runner.ts runs install.ps1
# from inside an already-launched Hermes.exe; if THAT recursively
# built apps/desktop it would try to overwrite the live Hermes.exe
# on disk and fail. The recursive path omits the flag.
# * The canonical CLI one-liner (irm | iex) omits the flag too;
# terminal users don't need a desktop binary built for them, and
# `hermes desktop` already builds on demand.
[switch]$IncludeDesktop
)
$ErrorActionPreference = "Stop"
# Suppress Invoke-WebRequest's per-chunk progress bar. Windows PowerShell
# 5.1's progress UI repaints synchronously on every received byte, which
# pegs CPU on a single core and throttles downloads by 10-100x (a 57MB
# PortableGit grab can take 5 minutes with progress on vs 20 seconds
# with progress off, on the same network). Every IWR call in this
# script is fire-and-forget so we never need to see the bar. Restored
# automatically when the script exits.
$ProgressPreference = "SilentlyContinue"
# Force the console to UTF-8 so non-ASCII output from native commands
# (e.g. playwright's box-drawing progress bars and download banners,
# git's bullet glyphs, npm's check marks) renders correctly instead of
# as IBM437/Windows-1252 mojibake (sequences like 0xE2 0x95 0x94 box-
# drawing chars decoded under the legacy DOS codepage). This is a
# DISPLAY-only fix; the underlying bytes are already correct. We do
# NOT change the file's own encoding (it remains pure ASCII for PS 5.1
# parser compatibility; see comments at the top of the entry-point
# dispatch). This affects only what the user sees in their terminal
# during this install run, and reverts automatically when the script
# exits and the host's console encoding is restored.
try {
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
} catch {
# Some constrained PowerShell hosts disallow encoding mutation.
# Mojibake on output is then cosmetic-only, install still works.
}
# ============================================================================
# 8.3 short-path normalization
# ============================================================================
# Windows generates an 8.3 short alias for a user-profile folder whose name
# contains a space ("First Last" -> FIRST~1.LAS), a dot ("Stone.ZEN8" ->
# STONE~1.ZEN), or an accented character ("Ruben" spelled with an acute e ->
# RUBN~1). It can then expose %TEMP%, %TMP%, %LOCALAPPDATA%, %APPDATA% and
# %USERPROFILE% -- plus everything derived from them, including the default
# HERMES_HOME and InstallDir -- in that short form:
# C:\Users\FIRST~1.LAS\AppData\Local\Temp
#
# PowerShell's FileSystem provider mishandles the aliased component when such a
# path reaches a provider cmdlet (`Tee-Object -FilePath`, `Out-File`,
# `New-Item`, `Test-Path`), throwing "An object at the specified path
# C:\Users\FIRST~1.LAS does not exist" -- localized on non-English hosts.
# Every Node/Electron stage streams its build log to %TEMP% via Tee-Object and
# the desktop stage probes the binary it produced under the profile-derived
# InstallDir, so the bootstrap aborts even though the artifact built fine.
# The Python/uv stages, which never hand a %TEMP% path to a provider cmdlet,
# sail through -- which is why the failure looks Node-specific.
#
# Expanding every profile-rooted path back to long form once, up front, lets
# every downstream cmdlet and child process see something the provider can
# resolve. Three resolvers, tried in order, because no single one covers every
# host:
#
# 1. kernel32!GetLongPathNameW -- expands any 8.3 component regardless of
# locale, including the accented-username aliases the COM resolver misses.
# 2. Scripting.FileSystemObject -- fallback for hosts where P/Invoke is
# blocked.
# 3. Profile-root substitution -- when the volume has 8.3 generation disabled
# or the alias is stale, neither resolver can expand the name because it
# no longer maps to anything on disk. The aliased component is always the
# profile folder itself (everything below it was created long), so swap in
# a profile root we can prove is long and reattach the tail.
#
# All three degrade to returning the input untouched, so a host where none of
# them apply -- including non-Windows -- behaves exactly as it did before.
$script:LongProfileRoot = $null
function Write-PathDiag {
# Diagnostics for this block go to stderr, never stdout: the stage protocol
# hands drivers a single line of JSON on stdout and a stray note would break
# anything parsing it.
#
# Suppressed entirely under -ShowResolvedPaths, which is a machine-readable
# query: Windows PowerShell 5.1 wraps any native-command stderr in a
# NativeCommandError and folds it back into the caller's own stream, so a
# child writing here at all is enough to corrupt a 5.1 caller's capture.
# The JSON already carries everything these lines say.
#
# [Console]::Error.WriteLine specifically -- verified reaching a caller on a
# windows-latest runner. $host.UI.WriteErrorLine was tried and silently
# produced nothing there under a non-interactive host.
param([string]$Message)
if ($ShowResolvedPaths) { return }
[Console]::Error.WriteLine("[hermes] $Message")
}
function Get-LongProfileRoot {
# The user's profile directory in long form, or '' when every source we
# can reach is itself aliased. Cached: this runs per env var.
if ($null -ne $script:LongProfileRoot) { return $script:LongProfileRoot }
$script:LongProfileRoot = ''
# %USERPROFILE% first: it is what the rest of the install derives from, and
# on a host handing us aliased paths the .NET known-folder lookup tends to
# be aliased in exactly the same way. Then the HOMEDRIVE/HOMEPATH pair, then
# the profile's parent (C:\Users never carries an alias) plus %USERNAME%,
# which stays the long account name even when every path is short.
$envProfile = [Environment]::GetEnvironmentVariable('USERPROFILE')
$shellProfile = [Environment]::GetFolderPath('UserProfile')
$candidates = @($envProfile, $shellProfile, "$env:HOMEDRIVE$env:HOMEPATH")
foreach ($anchor in @($envProfile, $shellProfile)) {
if ($anchor -and $env:USERNAME) {
$parent = Split-Path -Parent $anchor.TrimEnd('\', '/')
if ($parent) { $candidates += (Join-Path $parent $env:USERNAME) }
}
}
foreach ($candidate in $candidates) {
if ([string]::IsNullOrWhiteSpace($candidate)) { continue }
# Trailing separators make Split-Path -Parent return the directory
# itself, which would silently break the ancestry check downstream.
$candidate = $candidate.TrimEnd('\', '/')
if (-not $candidate) { continue }
if ($candidate -match '~\d') { continue }
try {
if (Test-Path -LiteralPath $candidate -PathType Container) {
$script:LongProfileRoot = $candidate
break
}
} catch {
# Unreadable candidate (denied, malformed): try the next one.
}
}
# Say which root we landed on. When someone reports "still broken" this is
# the first thing worth knowing, and it costs one line on the rare path
# where an alias actually showed up.
if ($script:LongProfileRoot) {
Write-PathDiag "long profile root: $script:LongProfileRoot"
} else {
Write-PathDiag "no long profile root found; 8.3 paths left as-is (tried: $($candidates -join ', '))"
}
return $script:LongProfileRoot
}
function Expand-ShortProfileRoot {
# Rebuild $Path onto a known-long profile root when its aliased component
# is the profile folder. Returns $Path unchanged when it isn't, so a custom
# TEMP on another volume (D:\SHORT~1\Temp) is never rewritten.
param([string]$Path)
$longRoot = Get-LongProfileRoot
if (-not $longRoot) { return $Path }
$longRootParent = Split-Path -Parent $longRoot
if (-not $longRootParent) { return $Path }
$node = $Path
$tail = ''
while ($node -and ($node -match '~\d')) {
$leaf = Split-Path -Leaf $node
$parent = Split-Path -Parent $node
if (-not $parent) { return $Path }
if ($leaf -match '~\d') {
# Candidate profile folder. Only substitute when it sits in the
# same directory as the real profile (both C:\Users).
if ($parent -ne $longRootParent) { return $Path }
if ($tail) { return (Join-Path $longRoot $tail) }
return $longRoot
}
$tail = if ($tail) { Join-Path $leaf $tail } else { $leaf }
$node = $parent
}
return $Path
}
function ConvertTo-LongPath {
param([string]$Path)
if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
# Only 8.3 short names carry a tilde+digit ("~1"); skip every resolver for
# ordinary long paths, which is the overwhelmingly common case.
if ($Path -notmatch '~\d') {
$script:LastResolver = 'skipped-long-path'
return $Path
}
# 1. kernel32. Compiled on first use only, so a normal profile never pays
# the Add-Type cost (this file is re-entered once per install stage).
try {
if (-not ([System.Management.Automation.PSTypeName]'HermesInstall.LongPath').Type) {
Add-Type -Namespace 'HermesInstall' -Name 'LongPath' -MemberDefinition @'
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
public static extern int GetLongPathNameW(string lpszShortPath, System.Text.StringBuilder lpszLongPath, int cchBuffer);
'@
}
$buffer = New-Object System.Text.StringBuilder 4096
$length = [HermesInstall.LongPath]::GetLongPathNameW($Path, $buffer, $buffer.Capacity)
if ($length -gt $buffer.Capacity) {
$buffer = New-Object System.Text.StringBuilder $length
$length = [HermesInstall.LongPath]::GetLongPathNameW($Path, $buffer, $buffer.Capacity)
}
if ($length -gt 0) {
$expanded = $buffer.ToString()
if ($expanded -and $expanded -notmatch '~\d') {
$script:LastResolver = 'kernel32'
return $expanded
}
}
} catch {
# Not Windows, or P/Invoke denied by policy: try the next resolver.
}
# 2. COM. Validate the result the same way the kernel32 branch does: this
# resolver can report success and still hand back a path that carries the
# alias (observed on a windows-latest runner, where it "resolved"
# C:\Users\FIRST~1.LAS\... to itself). Accepting that silently is what let a
# short path reach the provider cmdlets in the first place, so an
# unexpanded result counts as failure and falls through.
try {
$fso = New-Object -ComObject Scripting.FileSystemObject
$resolved = $null
if ($fso.FolderExists($Path)) { $resolved = $fso.GetFolder($Path).Path }
elseif ($fso.FileExists($Path)) { $resolved = $fso.GetFile($Path).Path }
if ($resolved -and $resolved -notmatch '~\d') {
$script:LastResolver = 'com'
return $resolved
}
} catch {
# COM unavailable / locked-down host: try the next resolver.
}
# 3. The alias resolves to nothing. Rebuild from a long profile root.
$rebuilt = Expand-ShortProfileRoot $Path
$script:LastResolver = if ($rebuilt -ne $Path) { 'profile-root' } else { 'none' }
return $rebuilt
}
function Set-LongProfileEnvVars {
# Normalize every profile-rooted variable the install reads, not just
# %TEMP%: the desktop stage derives InstallDir from %LOCALAPPDATA%, and a
# short root there fails the post-build probe after a successful build.
# Returns $true when anything was rewritten.
$rewrote = $false
$script:NormalizedPathRewrites = @{}
foreach ($name in @('TEMP', 'TMP', 'LOCALAPPDATA', 'APPDATA', 'USERPROFILE')) {
$current = [Environment]::GetEnvironmentVariable($name)
if (-not $current) { continue }
$expanded = ConvertTo-LongPath $current
if ($expanded -and $expanded -ne $current) {
Set-Item -Path "Env:$name" -Value $expanded
$rewrote = $true
$script:NormalizedPathRewrites[$name] = $expanded
# Rewriting a profile path is rare and corrective; say so. Every
# report of this bug class arrived as a bare "does not exist" with
# no hint that a short alias was involved. stderr, so the stage
# protocol's stdout JSON stays parseable.
Write-PathDiag "expanded 8.3 short path in %$name%: $current -> $expanded"
}
}
return $rewrote
}
# ConvertTo-LongPath only assigns $script:LastResolver when a ~\d short path
# actually needs expansion, so an ordinary long profile leaves it unset -- and
# the ResolvedPathReport below reads it unconditionally, which is fatal under
# Set-StrictMode before any stage starts. 'none' is the resolver's own value
# for "nothing ran".
$script:LastResolver = 'none'
$script:NormalizedProfilePaths = Set-LongProfileEnvVars
# Re-derive the install paths now that the env vars behind their defaults are
# long. An explicitly passed -HermesHome / -InstallDir is normalized in place
# rather than replaced, so a caller's choice is never overwritten by a default.
# $PSBoundParameters is only meaningful at script scope, so this stays inline.
if ($PSBoundParameters.ContainsKey('HermesHome')) {
$HermesHome = ConvertTo-LongPath $HermesHome
} else {
$HermesHome = ConvertTo-LongPath $(
if ($env:HERMES_HOME) { $env:HERMES_HOME } else { "$env:LOCALAPPDATA\hermes" }
)
}
if ($PSBoundParameters.ContainsKey('InstallDir')) {
$InstallDir = ConvertTo-LongPath $InstallDir
} else {
$InstallDir = ConvertTo-LongPath $(
if ($env:HERMES_HOME) { "$env:HERMES_HOME\hermes-agent" } else { "$env:LOCALAPPDATA\hermes\hermes-agent" }
)
}
if ($script:NormalizedProfilePaths) {
# Which paths the install actually settled on. Absent from every report of
# this bug class, and the whole question once a short alias is in play.
Write-PathDiag "resolved install paths: HermesHome=$HermesHome InstallDir=$InstallDir"
}
# Captured here, where the values are final, and emitted from the entry-point
# dispatch at the bottom (alongside -ProtocolVersion / -Manifest) so
# -ShowResolvedPaths exits before any stage runs.
#
# The report goes to STDOUT as JSON: on Windows a child's stderr does not
# reliably reach a parent process -- three separate capture mechanisms each came
# back empty on a windows-latest runner while stdout arrived intact -- and the
# first question on any "installer says a path doesn't exist" report is which
# paths it actually resolved.
$script:ResolvedPathReport = @{
long_profile_root = (Get-LongProfileRoot)
normalized = $script:NormalizedPathRewrites
resolver = $script:LastResolver
temp = $env:TEMP
hermes_home = $HermesHome
install_dir = $InstallDir
}
# ============================================================================
# Configuration
# ============================================================================
$RepoUrlSsh = "git@github.com:NousResearch/hermes-agent.git"
$RepoUrlHttps = "https://github.com/NousResearch/hermes-agent.git"
$PythonVersion = "3.11"
# Minor versions the installer accepts when the requested $PythonVersion isn't
# available, in preference order. Only checkout-private uv-managed interpreters
# are eligible. Single source of truth shared by Test-Python's fallback and
# Resolve-AvailablePythonVersion.
$PythonFallbackVersions = @("3.12", "3.13", "3.10")
$PythonFindTimeoutMs = 30000
$NodeVersion = "22"
# The npm range the root package.json pins in `engines.npm`. A constant rather
# than a manifest read like the POSIX side does: Test-Node runs BEFORE the repo
# is cloned, so there is usually no package.json on disk yet (and none at all
# when install.ps1 is piped straight from the web). Keep this fallback in sync
# with package.json; Get-NpmRange prefers the manifest once a checkout exists.
$NpmRange = "<11.10.0 || >=11.17.0"
# Stage-protocol version. Bumped only for genuinely breaking changes to the
# manifest schema, stage-name set semantics, or stdout JSON shape. Adding a
# new stage does NOT bump this -- drivers iterate the manifest dynamically.
$InstallStageProtocolVersion = 1
# ============================================================================
# Helper functions
# Return the real OS processor architecture as a lowercase string suitable for
# Node.js / electron download URL slugs: "arm64", "x64", or "x86".
#
# Why not just trust [Environment]::Is64BitOperatingSystem or
# [RuntimeInformation]::OSArchitecture? On Windows on ARM, when this script
# is invoked from Windows PowerShell 5.1 (the default `powershell.exe`) or
# any x64 PowerShell host, the process runs under Prism x64 emulation and
# BOTH of those APIs report `X64` -- they describe the emulated view, not
# the real OS. We've seen this concretely on Snapdragon X1 hardware: an
# ARM64-based Surface Laptop returns OSArchitecture=X64 from an emulated
# PowerShell session.
#
# Win32_Processor.Architecture is invariant to emulation. Values:
# 0=x86, 5=ARM, 9=AMD64/x64, 12=ARM64. We fall back to
# PROCESSOR_ARCHITEW6432 (set on WoW64 with the real OS arch) and then
# PROCESSOR_ARCHITECTURE so we still produce a sensible answer if CIM
# isn't available (locked-down WMI, container, etc.).
function Get-WindowsArch {
try {
$proc = Get-CimInstance -ClassName Win32_Processor -ErrorAction Stop |
Select-Object -First 1
switch ([int]$proc.Architecture) {
12 { return "arm64" }
9 { return "x64" }
0 { return "x86" }
5 { return "arm" }
}
} catch {
# CIM unavailable -- fall through to env-var path
}
$envArch = if ($env:PROCESSOR_ARCHITEW6432) {
$env:PROCESSOR_ARCHITEW6432
} else {
$env:PROCESSOR_ARCHITECTURE
}
switch ($envArch) {
"ARM64" { return "arm64" }
"AMD64" { return "x64" }
"x86" { return "x86" }
default {
# Last-resort: respect 64-bitness so we don't ship a 32-bit
# toolchain to anyone.
if ([Environment]::Is64BitOperatingSystem) { return "x64" } else { return "x86" }
}
}
}
# ============================================================================
function Write-Banner {
Write-Host ""
Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
Write-Host "| * Hermes Agent Installer |" -ForegroundColor Magenta
Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
Write-Host "| An open source AI agent by Nous Research. |" -ForegroundColor Magenta
Write-Host "+---------------------------------------------------------+" -ForegroundColor Magenta
Write-Host ""
}
function Write-Info {
param([string]$Message)
Write-Host "-> $Message" -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host "[OK] $Message" -ForegroundColor Green
}
function Write-Warn {
param([string]$Message)
Write-Host "[!] $Message" -ForegroundColor Yellow
}
function Write-Err {
param([string]$Message)
Write-Host "[X] $Message" -ForegroundColor Red
}
function Invoke-NativeWithRelaxedErrorAction {
param([scriptblock]$Script)
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $Script
} finally {
$ErrorActionPreference = $prevEAP
}
}
function Discard-LockfileChurn {
param([string]$Repo = $InstallDir)
if (-not $Repo -or -not (Test-Path (Join-Path $Repo ".git"))) { return }
try {
$diff = & git -c windows.appendAtomically=false -C $Repo diff --name-only 2>$null
if ($LASTEXITCODE -ne 0 -or -not $diff) { return }
$dirtyPackageDirs = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase
)
foreach ($path in $diff) {
if ($path -like "*package.json") {
$null = $dirtyPackageDirs.Add((Split-Path $path -Parent))
}
}
$dirtyLocks = [System.Collections.Generic.List[string]]::new()
foreach ($path in $diff) {
if ($path -notlike "*package-lock.json") { continue }
$lockDir = Split-Path $path -Parent
if ($dirtyPackageDirs.Contains($lockDir)) { continue }
$dirtyLocks.Add($path)
}
if ($dirtyLocks.Count -eq 0) { return }
& git -c windows.appendAtomically=false -C $Repo checkout -- @($dirtyLocks) 2>$null
if ($LASTEXITCODE -eq 0) {
Write-Info "Discarded npm lockfile churn ($($dirtyLocks.Count) file(s))"
}
} catch {
# Best-effort only; never let cleanup block the installer update path.
}
}
# Inspect npm output for a TLS-trust failure and, if found, print actionable
# remediation. npm/Node surface corporate MITM proxies and missing root CAs as
# "unable to get local issuer certificate" / "self-signed certificate in
# certificate chain" / UNABLE_TO_GET_ISSUER_CERT_LOCALLY -- most commonly while
# Electron's install.js postinstall downloads the Electron binary. The reporter
# usually misreads this as an admin-rights or generic install failure (see
# issue #38016), so detect it once here and route every npm stage through this
# hint. Returns $true when a cert error was detected (caller may adjust its own
# messaging), $false otherwise.
function Show-NpmCertHint {
param([string]$NpmOutput)
if (-not $NpmOutput) { return $false }
$isCertError = $NpmOutput -match "unable to get local issuer certificate" `
-or $NpmOutput -match "self.signed certificate" `
-or $NpmOutput -match "UNABLE_TO_GET_ISSUER_CERT_LOCALLY" `
-or $NpmOutput -match "SELF_SIGNED_CERT_IN_CHAIN" `
-or $NpmOutput -match "CERT_HAS_EXPIRED"
if (-not $isCertError) { return $false }
Write-Warn "This looks like a TLS certificate-trust failure, not a permissions problem."
Write-Info " A corporate proxy or antivirus is likely intercepting HTTPS and presenting a"
Write-Info " certificate Node.js doesn't trust. To fix, point Node at your org's root CA:"
Write-Info " 1. Get the corporate root CA as a .pem/.crt from your IT team."
Write-Info " 2. setx NODE_EXTRA_CA_CERTS `"C:\path\to\corp-ca.pem`""
Write-Info " 3. Open a NEW terminal (so the env var takes effect) and re-run the installer."
Write-Info " Quick (less secure) alternative -- disable TLS verification just for the install:"
Write-Info " npm config set strict-ssl false (re-enable afterwards: npm config set strict-ssl true)"
return $true
}
function Write-NpmDebugLogTail {
# On failure npm prints only a terse summary to stdout/stderr; the real
# evidence (postinstall script stderr like Electron's install.js, network
# traces, EBUSY retries) lives in npm's own debug log under
# <npm-cache>\_logs\<timestamp>-debug-0.log. The bootstrap installer's
# streaming sink only captures what WE emit, so on any npm failure this
# helper locates that debug log and replays its tail into our output
# stream -- making the bootstrap log a self-contained diagnosis instead
# of "exit 1, details in a file on a VM nobody can reach".
param(
[string]$NpmOutput,
[int]$TailLines = 200
)
$logPath = $null
# Preferred: npm names the exact file in its failure summary.
if ($NpmOutput -and $NpmOutput -match "A complete log of this run can be found in:\s*(?<path>[^\r\n]+)") {
$candidate = $Matches['path'].Trim()
if (Test-Path -LiteralPath $candidate) { $logPath = $candidate }
}
# Fallback (covers --silent runs, truncated output): newest debug log in
# npm's cache _logs directory.
if (-not $logPath) {
try {
$npm = Resolve-NpmCmd
if ($npm) {
$prevEAPLocal = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$cacheDir = (& $npm config get cache 2>$null | Select-Object -Last 1)
$ErrorActionPreference = $prevEAPLocal
if ($cacheDir) {
$logsDir = Join-Path ("$cacheDir").Trim() "_logs"
if (Test-Path -LiteralPath $logsDir) {
$newest = Get-ChildItem -LiteralPath $logsDir -Filter "*-debug-*.log" -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($newest) { $logPath = $newest.FullName }
}
}
}
} catch { }
}
if (-not $logPath) {
Write-Warn "npm debug log could not be located -- no further npm detail available"
return
}
$tail = $null
try {
$tail = Get-Content -LiteralPath $logPath -Tail $TailLines -ErrorAction Stop
} catch {
Write-Warn "Could not read npm debug log ${logPath}: $($_.Exception.Message)"
return
}
Write-Warn "---- npm debug log: last $TailLines lines of $logPath ----"
foreach ($line in $tail) { Write-Host " $line" -ForegroundColor DarkGray }
Write-Warn "---- end npm debug log ----"
}
# --- Ensure-mode helpers ---
function Resolve-NpmCmd {
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npmCmd) { return $null }
$npmExe = $npmCmd.Source
if ($npmExe -like "*.ps1") {
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
if (Test-Path $npmCmdSibling) { return $npmCmdSibling }
}
return $npmExe
}
function Find-SystemBrowser {
# Honor ONLY an explicit, user-set AGENT_BROWSER_EXECUTABLE_PATH override.
#
# We no longer scan well-known install locations for a system browser.
# Auto-detection silently bound the install to an arbitrary binary instead
# of the bundled Playwright Chromium, which made the browser tool behave
# differently across hosts (and, on Linux, picked up a sandboxed Snap
# Chromium that hangs every browser_navigate). Every install now uses the
# bundled Chromium unless the user explicitly points elsewhere.
$override = $env:AGENT_BROWSER_EXECUTABLE_PATH
if ([string]::IsNullOrWhiteSpace($override)) { return $null }
if (Test-Path $override) { return $override }
return $null
}
function Write-BrowserEnv {
param([string]$BrowserPath)
if (-not (Test-Path $HermesHome)) {
New-Item -ItemType Directory -Force -Path $HermesHome | Out-Null
}
$envFile = Join-Path $HermesHome ".env"
if (-not (Test-Path $envFile)) {
Set-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8
return
}
$content = Get-Content $envFile -Raw -ErrorAction SilentlyContinue
if ($content -and $content -match "AGENT_BROWSER_EXECUTABLE_PATH=") { return }
Add-Content -Path $envFile -Value "AGENT_BROWSER_EXECUTABLE_PATH=$BrowserPath" -Encoding UTF8
}
function Install-AgentBrowser {
$npm = Resolve-NpmCmd
if (-not $npm) {
Write-Err "npm not found -- install Node.js first"
throw "npm not found"
}
# agent-browser itself is intentionally NOT installed here (#43564 /
# PR #44772 review): it resolves lazily via `npx agent-browser` instead,
# which every consumer (tools/browser_tool.py, `hermes update`'s npx
# cache warm) already goes through. Eagerly npm-installing a second,
# separately version-pinned copy here -- only reachable via this
# explicit -Ensure browser fallback in the first place -- was redundant
# complexity and an extra credential/supply-chain surface for a path
# npx already covers.
Write-Info "Installing camofox browser server..."
$prefixDir = Join-Path $HermesHome "node"
if (-not (Test-Path $prefixDir)) {
New-Item -ItemType Directory -Path $prefixDir -Force | Out-Null
}
$npmLog = [System.IO.Path]::GetTempFileName()
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $npm install -g --prefix $prefixDir --silent --ignore-scripts "@askjo/camofox-browser@^1.5.2" 2>&1 | Tee-Object -FilePath $npmLog | Out-Null
$npmExit = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($npmExit -ne 0) {
$npmDetail = Get-Content $npmLog -Raw -ErrorAction SilentlyContinue
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
Write-Err "npm install -g failed (exit $npmExit): $npmDetail"
Show-NpmCertHint $npmDetail | Out-Null
# This install runs with --silent, so $npmDetail is often near-empty;
# npm's debug log is the only place the real error survives.
Write-NpmDebugLogTail -NpmOutput $npmDetail
throw "npm install failed"
}
Remove-Item $npmLog -Force -ErrorAction SilentlyContinue
$sysBrowser = Find-SystemBrowser
if ($sysBrowser) {
Write-BrowserEnv -BrowserPath $sysBrowser
Write-Info "Explicit browser override set -- Chromium download will be skipped when agent-browser installs on demand"
}
Write-Success "Agent-browser ready"
}
# ============================================================================
# Dependency checks
# ============================================================================
# Resolve the PowerShell host executable used to spawn child PowerShell
# processes (the astral uv installer below). We must NOT hardcode the bare
# name `powershell`: it names *Windows PowerShell* and only resolves when its
# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+
# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare
# `powershell` spawn dies with "The term 'powershell' is not recognized",
# aborting uv installation (field report: Windows install stuck, uv install
# failed with exactly that message). Prefer the absolute path of the host we
# are already running in (PATH-independent), then fall back to whichever of
# powershell/pwsh is resolvable, and only then to the bare name.
function Get-PowerShellHostExe {
try {
$hostExe = (Get-Process -Id $PID).Path
if ($hostExe -and (Test-Path $hostExe)) {
$leaf = Split-Path $hostExe -Leaf
# Only trust the current host when it is a real PowerShell CLI
# (not e.g. powershell_ise.exe or an embedded host that can't take
# `-ExecutionPolicy`/`-Command`).
if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe }
}
} catch { }
foreach ($candidate in @("powershell", "pwsh")) {
$cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($cmd -and $cmd.Source) { return $cmd.Source }
}
# Last-ditch: hand back the bare name so the spawn surfaces its own error.
return "powershell"
}
function Install-Uv {
# Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there --
# no PATH probing, no conda guards, no multi-location resolution chains.
# The runtime update path (hermes_cli/managed_uv.py) looks in the same
# place, so install.ps1 and `hermes update` stay in sync.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv found ($version)"
return $true
}
Write-Info "Installing managed uv into $HermesHome\bin ..."
New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null
# UV_INSTALL_DIR tells the astral installer to place the binary
# directly into $HermesHome\bin instead of ~/.local/bin.
$prevEAP = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
$env:UV_INSTALL_DIR = Join-Path $HermesHome "bin"
# Spawn via the resolved host exe (see Get-PowerShellHostExe) rather
# than a bare `powershell`, which isn't guaranteed to be on PATH under
# PowerShell 7 / pwsh-only setups.
$psHostExe = Get-PowerShellHostExe
# Rungs 1 + 2: run the uv installer -- astral.sh first, then the
# byte-identical copy published on GitHub releases. Corporate
# proxies and AV products frequently block astral.sh while
# github.com is reachable (issue #69216), so a second source turns
# a hard failure into a working install. Capture the installer
# output (Tee-Object) instead of discarding it: when every source
# fails, the real error (download blocked, AV quarantine,
# permissions) must reach the user instead of only the generic
# "installed but not found" message.
$installerOutput = @()
$astralOut = @()
& $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Tee-Object -Variable astralOut | Out-Null
$installerOutput += "--- uv installer source: astral.sh ---"
$installerOutput += @($astralOut | ForEach-Object { "$_" })
if (Test-Path $managedUv) {
Write-Info "uv installer succeeded via astral.sh"
} else {
Write-Info "astral.sh uv installer did not produce $managedUv; trying GitHub releases mirror ..."
$ghOut = @()
& $psHostExe -ExecutionPolicy ByPass -c "irm https://github.com/astral-sh/uv/releases/latest/download/uv-installer.ps1 | iex" 2>&1 | Tee-Object -Variable ghOut | Out-Null
$installerOutput += "--- uv installer source: GitHub releases ---"
$installerOutput += @($ghOut | ForEach-Object { "$_" })
if (Test-Path $managedUv) {
Write-Info "uv installer succeeded via GitHub releases"
}
}
# Rung 3: salvage an existing uv.exe. When the installer cannot run
# at all (network fully blocked) but a working uv already exists --
# on PATH, or at ~/.local/bin (the astral default location when
# UV_INSTALL_DIR was ignored by an older installer) -- copy it into
# the managed location so the managed-first invariant holds
# (hermes_cli/managed_uv.py looks only at $HermesHome\bin\uv.exe).
if (-not (Test-Path $managedUv)) {
$existingUv = $null
$uvOnPath = Get-Command uv -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($uvOnPath -and $uvOnPath.Source -and (Test-Path $uvOnPath.Source)) {
$existingUv = $uvOnPath.Source
}
if (-not $existingUv) {
$defaultUv = Join-Path $env:USERPROFILE ".local\bin\uv.exe"
if (Test-Path $defaultUv) { $existingUv = $defaultUv }
}
if ($existingUv) {
Write-Info "Salvaging existing uv from $existingUv"
try {
Copy-Item $existingUv $managedUv -Force
# Verify the salvaged binary actually runs before
# trusting it as the managed uv.
$null = & $managedUv --version
} catch {
Write-Info "Existing uv at $existingUv could not be salvaged: $_"
Remove-Item $managedUv -Force -ErrorAction SilentlyContinue
}
}
}
$ErrorActionPreference = $prevEAP
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
$version = & $managedUv --version
Write-Success "Managed uv installed ($version)"
return $true
}
Write-Err "uv installed but not found at $managedUv"
if ($installerOutput.Count -gt 0) {
Write-Info "uv installer output (last 15 lines):"
$installerOutput | Select-Object -Last 15 | ForEach-Object { Write-Info " $_" }
}
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Err "Failed to install uv: $_"
Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/"
return $false
}
}
# Refresh $env:Path from the User + Machine registry hives. Stage drivers
# invoke each stage in a fresh powershell process, but those processes
# inherit env from the parent driver shell, NOT from the registry. When
# an earlier stage (Stage-Git, Stage-Node, ...) installs a binary and
# pushes its directory into User PATH, the next child process's $env:Path
# is stale and the binary appears missing. This helper re-reads PATH
# from the registry so every Invoke-Stage starts from a fresh, up-to-date
# PATH view. Cheap (registry reads, no I/O elsewhere) and idempotent.
function Sync-EnvPath {
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
}
# npm lifecycle scripts on Windows spawn ``cmd.exe /d /s /c node <script>``.
# PowerShell can resolve ``node`` via Get-Command while the child cmd process
# still sees a PATH without node.exe's directory (nvm4w shims, App Paths
# aliases, stale cross-process PATH). Prepend the resolved node.exe parent
# directory so postinstall hooks (electron-winstaller, native modules, etc.)
# can find ``node``. Regression for #48130.
function Ensure-NodeExeOnPath {
$nodeCmd = Get-Command node -ErrorAction SilentlyContinue
if (-not $nodeCmd) { return $false }
$nodeExeDir = Split-Path $nodeCmd.Source -Parent
if (-not $nodeExeDir) { return $false }
$pathParts = $env:Path -split ";"
if ($pathParts -notcontains $nodeExeDir) {
$env:Path = "$nodeExeDir;$env:Path"
}
return $true
}
# Put the Hermes-managed Node dir at the FRONT of the persisted User PATH.
#
# Appending is not enough: it leaves a pre-existing system Node ahead of the
# bundled one in every new shell, so anything launched without a curated
# environment (a standalone hermes-setup.exe run, a user typing `npm`) silently
# resolves the wrong Node. Bundled must win.
#
# Move-to-front rather than add-if-missing, because installs made by an older
# install.ps1 already have this dir in User PATH -- at the tail. An
# add-if-missing check sees it present and leaves the broken ordering in place
# forever, so the very users the ordering bug hurt would never be repaired.
#
# Unrelated entries keep their relative order, including empty segments (a
# trailing ';' is legal and common in a real User PATH; Install-Git's splitting
# preserves them too, so this must not quietly rewrite them). Duplicate
# occurrences of the managed dir collapse into the single leading entry.
# PowerShell's -ne is case-insensitive for strings, which is the right
# comparison on Windows. Persists only when the resulting string differs, so
# an already-correct PATH costs one registry read and no write.
function Set-ManagedNodeFirstOnUserPath {
param([string]$NodeDir)
if (-not $NodeDir) { return }
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$items = if ($userPath) { @($userPath -split ";") } else { @() }
$rest = @($items | Where-Object { $_ -ne $NodeDir })
$updated = (@($NodeDir) + $rest) -join ";"
if ($updated -ne $userPath) {
[Environment]::SetEnvironmentVariable("Path", $updated, "User")
}
}
# The npm range to install into the managed Node tree. Prefers the checkout's
# root package.json so the installer and the manifest cannot drift; falls back
# to the $NpmRange constant, which is the common case here because Test-Node
# runs before the repo is cloned.
function Get-NpmRange {
$manifest = Join-Path $InstallDir "package.json"
if (Test-Path $manifest) {
try {
$engines = (Get-Content $manifest -Raw | ConvertFrom-Json).engines
if ($engines -and $engines.npm) { return [string]$engines.npm }
} catch { }
}
return $NpmRange
}
# Convert the numeric core of an npm version or range operand into a stable
# three-component System.Version. npm reports semantic versions, but the
# installer only needs the numeric core for the comparator ranges authored in
# package.json (for example, <11.10.0 || >=11.17.0).
function ConvertTo-NpmVersion {
param([string]$Version)
if (-not $Version) { return $null }
$core = ($Version.Trim() -replace '^v', '' -replace '-.*$', '')
$parts = @($core -split '\.')
if ($parts.Count -lt 1 -or $parts.Count -gt 3) { return $null }
foreach ($part in $parts) {
if ($part -notmatch '^\d+$') { return $null }
}
while ($parts.Count -lt 3) { $parts += '0' }
try {
return [version]($parts -join '.')
} catch {
return $null
}
}
# Evaluate the comparator-only npm ranges used by the root manifest and the
# pre-clone fallback. Alternatives are separated with || and each alternative
# may contain one or more whitespace-separated <, <=, >, or >= comparators.
# Unknown range syntax fails closed so an incompatible system npm cannot reach
# npm ci and fail later with EBADENGINE.
function Test-NpmVersionOk {
param(
[string]$Version,
[string]$Range = (Get-NpmRange)
)
$actual = ConvertTo-NpmVersion $Version
if (-not $actual -or -not $Range) { return $false }
foreach ($alternative in @($Range -split '\s*\|\|\s*')) {
$clause = $alternative.Trim()
if (-not $clause) { continue }
$comparators = [regex]::Matches(
$clause,
'(?:^|\s)(<=|>=|<|>)\s*(\d+(?:\.\d+){0,2})(?=\s|$)'
)
if ($comparators.Count -eq 0) { continue }
$remainder = [regex]::Replace(
$clause,
'(?:^|\s)(?:<=|>=|<|>)\s*\d+(?:\.\d+){0,2}(?=\s|$)',
''
).Trim()
if ($remainder) { continue }
$matchesClause = $true
foreach ($comparator in $comparators) {
$target = ConvertTo-NpmVersion $comparator.Groups[2].Value
if (-not $target) {
$matchesClause = $false
break
}
$matchesComparator = switch ($comparator.Groups[1].Value) {
'<' { $actual -lt $target }
'<=' { $actual -le $target }
'>' { $actual -gt $target }
'>=' { $actual -ge $target }
default { $false }
}
if (-not $matchesComparator) {
$matchesClause = $false
break
}
}
if ($matchesClause) { return $true }
}
return $false
}
# Upgrade the Hermes-managed Node tree's bundled npm into $NpmRange when
# needed. Managed Node trees survive updates, so their bundled npm can drift
# outside a newer root package.json engine range. The repo .npmrc sets
# `engine-strict=true`, making that mismatch fatal at the first `npm ci`.
# Provision the right npm here instead of reacting to EBADENGINE later.
#
# Three details are load-bearing, mirroring _nb_ensure_bundled_npm_range in
# scripts/lib/node-bootstrap.sh and upgrade_managed_npm in
# hermes_cli/npm_engine.py:
# - a temp cwd, so the checkout's own .npmrc (engine-strict,
# min-release-age) does not gate the very upgrade meant to satisfy it;
# - npm_config_min_release_age=0, which also neutralises a user ~/.npmrc;
# - an explicit --prefix at the managed tree, so the upgrade rewrites the
# tree's own npm rather than installing a second copy elsewhere.
#
# Best-effort: a failure leaves a working Node with an old npm, which beats no
# Node at all, and npm_engine.py still covers the EBADENGINE that follows.
function Update-ManagedNpm {
param([string]$NodeDir)
$npmCmd = Join-Path $NodeDir "npm.cmd"
if (-not (Test-Path $npmCmd)) { return $false }
$range = Get-NpmRange
# Skip the network round-trip when the bundled npm already satisfies the
# same range used by the system-Node acceptance gate.
try {
$have = (& $npmCmd --version 2>$null | Select-Object -First 1)
if ($have -and (Test-NpmVersionOk $have $range)) { return $true }
} catch { }
# In-app updates run while the desktop app's Node processes are alive.
# The managed npm lives inside the very tree they execute from, so an
# in-place upgrade would hit WinError 5 (Access denied) on npm.cmd
# (#80926). Defer; the next update with the app closed retries.
if (Test-ManagedNodeInUse $NodeDir) {
Write-Warn "Hermes-managed Node.js is in use by a running app; skipping the bundled npm upgrade (applies on a later update with the app closed)."
return $false
}
Write-Info "Upgrading bundled npm to satisfy $range ..."
$tmpCwd = Join-Path $env:TEMP ("hermes-npm-upgrade-" + [Guid]::NewGuid().ToString("N"))
New-Item -ItemType Directory -Force -Path $tmpCwd | Out-Null
$prevAge = $env:npm_config_min_release_age
$prevCI = $env:CI
$prevEAP = $ErrorActionPreference
Push-Location $tmpCwd
try {
$env:npm_config_min_release_age = "0"
$env:CI = "1"
# Relax EAP=Stop so npm's stderr lines don't get wrapped as
# ErrorRecords and short-circuit before $LASTEXITCODE is checked.
# Same pattern as Install-Uv.
$ErrorActionPreference = "Continue"
& $npmCmd install --global --prefix $NodeDir "npm@$range" `
--no-fund --no-audit --progress=false 2>&1 | Out-Null
$exit = $LASTEXITCODE
} catch {
$exit = 1
} finally {
$ErrorActionPreference = $prevEAP
Pop-Location
$env:npm_config_min_release_age = $prevAge
$env:CI = $prevCI
Remove-Item -Recurse -Force $tmpCwd -ErrorAction SilentlyContinue
}
if ($exit -ne 0) {
Write-Warn "Could not upgrade bundled npm to $range -- ``npm ci`` may fail with EBADENGINE."
Write-Info "Fix manually: npm install -g --prefix `"$NodeDir`" npm@`"$range`""
return $false
}
Write-Success "npm $(& $npmCmd --version 2>$null) installed"
return $true
}
function Test-ManagedNodeInUse {
param([string]$NodeDir)
# Windows locks files that running processes execute from. During an
# in-app update the desktop app's Node processes may hold the managed
# tree open, and rewriting it then fails with WinError 5 (Access denied)
# on npm.cmd (#80926). Cheap pre-check used to skip destructive steps;
# the rename/move itself remains the authoritative guard.
#
# Check the executable path AND the command line: a cmd.exe wrapper
# running npm.cmd from the tree reports its own exe (cmd.exe lives in
# System32) while the tree path appears only in the command line.
# Win32_Process.CommandLine is available on Windows PowerShell 5.1 and
# 7+ (the Get-Process .CommandLine ETS property is 7.4+ only), and a
# single CIM query beats a per-process property access loop.
return @(
Get-CimInstance Win32_Process -ErrorAction SilentlyContinue |
Where-Object {
($_.ExecutablePath -like "$NodeDir\*") -or
($_.CommandLine -like "*$NodeDir*")
}
).Count -gt 0
}
# Re-discover uv without re-installing it. Cross-process stage drivers
# (the desktop GUI's onboarding wizard, CI step-runners) invoke each stage
# in a fresh powershell process, so $script:UvCmd set by Install-Uv in a
# prior process is not visible here. Later stages (Test-Python,
# Install-Venv, Install-Dependencies, Install-PlatformSdks) call this
# at the top to populate $script:UvCmd from the managed location.
# Throws if uv is not findable -- the caller's stage then surfaces a
# clean error via the stage-driver's try/catch.
function Resolve-UvCmd {
# Already resolved (default invocation path: Install-Uv ran earlier
# in the same process and set $script:UvCmd).
if ($script:UvCmd) {
if ($script:UvCmd -eq "uv") {
# "uv" on PATH -- verify it's still resolvable (PATH could have
# changed mid-session; cheap to recheck).
if (Get-Command uv -ErrorAction SilentlyContinue) { return }
} elseif (Test-Path $script:UvCmd) {
return
}
# Stale; fall through to re-discover.
}
# Check the managed location first -- this is where Install-Uv puts it.
$managedUv = Join-Path $HermesHome "bin\uv.exe"
if (Test-Path $managedUv) {
$script:UvCmd = $managedUv
return
}
# Fall back to PATH (covers edge cases where the installer ran in a
# sibling process and HERMES_HOME wasn't propagated).
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
}
# Refresh PATH from registry in case the current process started before
# Install-Uv updated User PATH.
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Get-Command uv -ErrorAction SilentlyContinue) {
$script:UvCmd = "uv"
return
}
throw "uv is not installed. Run install.ps1 -Stage uv first."
}
function Initialize-ManagedPythonEnvironment {
# Python used by Hermes belongs to the checkout, never to another
# application or a user-level uv configuration. Keep this aligned with
# hermes_cli.managed_uv.managed_python_env(), which owns the update path.
foreach ($name in @(
"CONDA_DEFAULT_ENV", "CONDA_PREFIX", "UV_PROJECT_ENVIRONMENT",
"UV_NO_MANAGED_PYTHON", "UV_PYTHON", "UV_PYTHON_DOWNLOADS",
"UV_SYSTEM_PYTHON", "VIRTUAL_ENV", "PYTHONHOME", "PYTHONPATH"
)) {
Remove-Item -Path "Env:$name" -ErrorAction SilentlyContinue
}
$managedRoot = Join-Path $InstallDir ".hermes-runtime\python"
New-Item -ItemType Directory -Force -Path $managedRoot | Out-Null
$env:UV_MANAGED_PYTHON = "1"
$env:UV_NO_CONFIG = "1"
$env:UV_PYTHON_INSTALL_BIN = "0"
$env:UV_PYTHON_INSTALL_DIR = $managedRoot
$env:UV_PYTHON_INSTALL_REGISTRY = "0"
return [System.IO.Path]::GetFullPath($managedRoot)
}
function Resolve-AvailablePythonVersion {
# Return the path and minor version of the first Hermes-managed interpreter
# uv can find, preferring the requested version and then fallback minors.
# System and application-owned interpreters are deliberately ineligible.
#
# Under Hermes-Setup.exe each stage runs in a fresh powershell.exe. The
# venv stage therefore re-resolves both version and provenance rather than
# relying on state selected by the earlier Python stage (#50769).
[string]$managedRoot = Initialize-ManagedPythonEnvironment
$managedPrefix = $managedRoot.TrimEnd('\') + '\'
$candidates = @($PythonVersion) + $PythonFallbackVersions
$seen = @{}
foreach ($ver in $candidates) {
if (-not $ver -or $seen.ContainsKey($ver)) { continue }
$seen[$ver] = $true
$process = $null
try {
# PowerShell 5.1 can lose a nested native command's stdout when
# this installer itself is redirected by the desktop bootstrapper.
# Capture uv directly through ProcessStartInfo instead of relying
# on the native-command pipeline for the interpreter path.
$process = New-Object System.Diagnostics.Process
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $UvCmd
$startInfo.Arguments = "python find $ver --managed-python --no-config"
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process.StartInfo = $startInfo
if (-not $process.Start()) { continue }
$stdoutTask = $process.StandardOutput.ReadToEndAsync()
$stderrTask = $process.StandardError.ReadToEndAsync()
if (-not $process.WaitForExit($PythonFindTimeoutMs)) {
try { $process.Kill() } catch { }
$process.WaitForExit()
throw "uv python find $ver timed out after $PythonFindTimeoutMs ms"
}
$stdout = $stdoutTask.Result
$stderrTask.Result | Out-Null
if ($process.ExitCode -ne 0) { continue }
[string]$foundPath = ($stdout.Trim() -split "`r?`n") | Select-Object -Last 1
if ($foundPath) {
$absolute = [System.IO.Path]::GetFullPath($foundPath)
if ($absolute.StartsWith($managedPrefix, [System.StringComparison]::OrdinalIgnoreCase)) {
return [PSCustomObject]@{
Path = $absolute
Version = $ver
}
}
}
} catch {
throw "Failed to resolve Hermes-managed Python $ver`: $_"
} finally {
if ($process) { $process.Dispose() }
}
}
return $null
}
function Test-Python {
Initialize-ManagedPythonEnvironment | Out-Null
Write-Info "Checking Python $PythonVersion..."
# Only a checkout-private uv-managed interpreter satisfies this stage.
try {
$resolvedPython = Resolve-AvailablePythonVersion
if ($resolvedPython) {
$ver = & $resolvedPython.Path --version 2>$null
Write-Success "Python found: $ver"
return $true
}
} catch { }
# Python not found -- use uv to install it (no admin needed!)
Write-Info "Python $PythonVersion not found, installing via uv..."
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value (see Install-Uv for the full rationale).
$prevEAP = $ErrorActionPreference
try {
# Temporarily relax ErrorActionPreference: uv writes download progress
# ("Downloading cpython-3.11.15-windows-x86_64-none (24.5MiB)") to
# stderr. With $ErrorActionPreference = "Stop" (set at the top of this
# script) PowerShell wraps stderr lines from native commands as
# ErrorRecord objects when captured via 2>&1, then throws a terminating
# exception on the first one -- even though uv exits 0 and Python was
# installed successfully. Verify success via `uv python find`
# afterwards, which is the reliable signal regardless of exit-code
# semantics or stderr noise. This fix was previously landed as
# commit ec1714e71 and then lost in a release squash; reapplied here.
$ErrorActionPreference = "Continue"
$uvOutput = & $UvCmd python install $PythonVersion --no-bin --no-registry --no-config 2>&1
$uvExitCode = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
# Check if Python is now available (more reliable than exit code
# since uv may return non-zero due to "already installed" etc.)
$resolvedPython = Resolve-AvailablePythonVersion
if ($resolvedPython) {
$ver = & $resolvedPython.Path --version 2>$null
Write-Success "Python installed: $ver"
return $true
}
# uv ran but Python still not findable -- show what happened
if ($uvExitCode -ne 0) {
Write-Warn "uv python install output:"
Write-Host $uvOutput -ForegroundColor DarkGray
}
} catch {
# Restore EAP in case the try block threw before the assignment
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Warn "uv python install error: $_"
}
# Preserve the established minor-version fallback contract, but provision
# every fallback into the same private store instead of borrowing a system
# interpreter. This path is reached only when the preferred install failed.
foreach ($fallbackVer in $PythonFallbackVersions) {
try {
Write-Info "Trying managed Python fallback $fallbackVer..."
$previousFallbackEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $UvCmd python install $fallbackVer --no-bin --no-registry --no-config 2>&1 | Out-Null
$ErrorActionPreference = $previousFallbackEAP
$resolvedPython = Resolve-AvailablePythonVersion
if ($resolvedPython) {
$ver = & $resolvedPython.Path --version 2>$null
Write-Success "Python fallback installed: $ver"
return $true
}
} catch {
if ($previousFallbackEAP) { $ErrorActionPreference = $previousFallbackEAP }
}
}
Write-Err "Failed to install Python $PythonVersion"
Write-Info "Check network access to uv's managed Python downloads, then retry."
return $false
}
$script:GitInstallFailureReason = $null
$script:GitBashPath = $null
$script:GitBashProbeOutput = $null
function Test-GitBashCompatibility {
<#
.SYNOPSIS
Verify that Git Bash can launch external MSYS programs, not just evaluate
shell builtins. Mandatory ASLR can allow bash.exe itself to start while
every child linked to msys-2.0.dll fails during fork/spawn.
#>
param([Parameter(Mandatory = $true)][string]$BashPath)
$script:GitBashProbeOutput = $null
if (-not (Test-Path -LiteralPath $BashPath)) {
$script:GitBashProbeOutput = "bash.exe was not found at $BashPath"
return $false
}
$process = New-Object System.Diagnostics.Process
try {
$startInfo = New-Object System.Diagnostics.ProcessStartInfo
$startInfo.FileName = $BashPath
$startInfo.Arguments = '--noprofile --norc -c "/usr/bin/true; /usr/bin/cat --version >/dev/null"'
$startInfo.UseShellExecute = $false
$startInfo.CreateNoWindow = $true
$startInfo.RedirectStandardOutput = $true
$startInfo.RedirectStandardError = $true
$process.StartInfo = $startInfo
if (-not $process.Start()) {
$script:GitBashProbeOutput = "bash.exe did not start"
return $false
}
if (-not $process.WaitForExit(15000)) {
try { $process.Kill() } catch { }
$script:GitBashProbeOutput = "Git Bash compatibility probe timed out"
return $false
}
$stdout = $process.StandardOutput.ReadToEnd()
$stderr = $process.StandardError.ReadToEnd()
$script:GitBashProbeOutput = ("$stdout`n$stderr").Trim()
return ($process.ExitCode -eq 0)
} catch {
$script:GitBashProbeOutput = $_.Exception.Message
return $false
} finally {
$process.Dispose()
}
}
function Test-MandatoryAslrEnabled {
<# Return true only when Windows reports system-wide ForceRelocateImages=ON. #>
try {
$cmd = Get-Command Get-ProcessMitigation -ErrorAction SilentlyContinue
if (-not $cmd) { return $false }
$mitigations = & $cmd -System
$value = $mitigations.Aslr.ForceRelocateImages
return ($null -ne $value -and $value.ToString().ToUpperInvariant() -eq "ON")
} catch {
return $false
}
}
function Get-GitRootFromBashPath {
param([Parameter(Mandatory = $true)][string]$BashPath)
$binDir = Split-Path -Path $BashPath -Parent
if ((Split-Path -Path $binDir -Leaf) -ine "bin") {
return (Split-Path -Path $binDir -Parent)
}
$parent = Split-Path -Path $binDir -Parent
if ((Split-Path -Path $parent -Leaf) -ieq "usr") {
return (Split-Path -Path $parent -Parent)
}
return $parent
}
function New-GitBashAslrFailureReason {
param([Parameter(Mandatory = $true)][string]$BashPath)
$gitRoot = Get-GitRootFromBashPath -BashPath $BashPath
$escapedRoot = $gitRoot -replace "'", "''"
return @(
"Git Bash at $BashPath cannot launch required MSYS child processes because Windows Mandatory ASLR (ForceRelocateImages) is enabled system-wide. Reinstalling Git will not change this policy."
"Open PowerShell as Administrator and run:"
"`$gitRoot = '$escapedRoot'"
'Get-Item "$gitRoot\bin\bash.exe", "$gitRoot\usr\bin\*.exe" -ErrorAction SilentlyContinue | ForEach-Object { Set-ProcessMitigation -Name $_.FullName -Disable ForceRelocateImages }'
"Then rerun Hermes setup. If the override is blocked or later re-applied, ask your Windows administrator to allow this per-program exception."
) -join [Environment]::NewLine
}
function Install-Git {
<#
.SYNOPSIS
Ensure Git (and Git Bash) are installed. Git for Windows bundles bash.exe
which Hermes uses to run shell commands.
Priority order (deliberately simple -- no winget, no registry, no system
package manager):
1. Existing ``git`` on PATH -- use it as-is (the common fast path).
2. Download **PortableGit** from the official git-for-windows GitHub
release (self-extracting 7z.exe) and unpack it to
``%LOCALAPPDATA%\hermes\git`` -- never touches system Git, never
requires admin, works even on locked-down machines and machines
with a broken system Git install.
**Why PortableGit, not MinGit:** MinGit is the minimal-automation
distribution and ships ONLY ``git.exe`` -- no bash, no POSIX utilities.
Hermes needs ``bash.exe`` to run shell commands. PortableGit is the
full Git for Windows distribution without the installer UI; it ships
``git.exe`` + ``bash.exe`` + ``sh``, ``awk``, ``sed``, ``grep``, ``curl``,
``ssh``, etc. in ``usr\bin\``.
We deliberately skip winget because it fails badly when the system Git
install is in a half-installed state (partially registered, or uninstall-
blocked). Owning the Hermes copy of Git ourselves is predictable and
recoverable: if it ever breaks, ``Remove-Item %LOCALAPPDATA%\hermes\git``
and re-running this installer fully recovers.
After install we locate ``bash.exe`` and persist the path in
``HERMES_GIT_BASH_PATH`` (User scope) so Hermes can find it in a fresh
shell without a second PATH refresh.
#>
$script:GitInstallFailureReason = $null
Write-Info "Checking Git..."
if (Get-Command git -ErrorAction SilentlyContinue) {
$version = git --version
Write-Success "Git found ($version)"
Set-GitBashEnvVar
if ($script:GitBashPath -and (Test-GitBashCompatibility -BashPath $script:GitBashPath)) {
Write-Success "Git Bash can launch MSYS programs"
return $true
}
if ($script:GitBashPath -and (Test-MandatoryAslrEnabled)) {
$script:GitInstallFailureReason = New-GitBashAslrFailureReason -BashPath $script:GitBashPath
Write-Err $script:GitInstallFailureReason
return $false
}
if ($script:GitBashPath) {
$probeDetail = if ($script:GitBashProbeOutput) { ": $script:GitBashProbeOutput" } else { "" }
Write-Warn "System Git Bash could not launch required MSYS programs$probeDetail"
} else {
Write-Warn "Git is on PATH, but its Git Bash installation could not be located."
}
Write-Info "Trying a Hermes-managed PortableGit install instead..."
}
# Download PortableGit into $HermesHome\git. Always works as long as
# we can reach github.com -- no admin, no winget, no reliance on the
# user's possibly-broken system Git install.
Write-Info "Git not found -- downloading PortableGit to $HermesHome\git\ ..."
Write-Info "(no admin rights required; isolated from any system Git install)"
try {
$arch = Get-WindowsArch
if ($arch -eq 'arm64') {
$assetTag = 'arm64'
$downloadIsZip = $false
} elseif ($arch -eq 'x64') {
$assetTag = '64-bit'
$downloadIsZip = $false
} else {
# PortableGit does not ship 32-bit / arm builds -- fall back to MinGit
# 32-bit with a warning that bash-based features will be unavailable.
$assetTag = '32-bit-mingit'
$downloadIsZip = $true
}
# Pinned git-for-windows release. We deliberately do NOT hit
# api.github.com/repos/.../releases/latest here: that endpoint
# is rate-limited to 60 requests/hour/IP for unauthenticated
# callers, and users behind CGNAT / corporate NAT / dorm WiFi
# routinely hit the limit, breaking the installer.
# Static github.com/.../releases/download/<tag>/<asset> URLs
# are not subject to the API rate limit.
$gitTag = "v2.54.0.windows.1"
$gitVer = "2.54.0"
$gitVerTag = "$gitVer.windows.1"
if ($arch -eq "32-bit-mingit") {
Write-Warn "32-bit Windows detected -- PortableGit is 64-bit only. Installing MinGit 32-bit as a last resort; bash-dependent Hermes features (terminal tool, agent-browser) will not work on this machine."
$assetName = "MinGit-$gitVer-32-bit.zip"
$downloadIsZip = $true
} elseif ($arch -eq "arm64") {
$assetName = "PortableGit-$gitVer-arm64.7z.exe"
$downloadIsZip = $false
} else {
$assetName = "PortableGit-$gitVer-64-bit.7z.exe"
$downloadIsZip = $false
}
$downloadUrl = "https://github.com/git-for-windows/git/releases/download/$gitTag/$assetName"
$downloadExt = if ($downloadIsZip) { "zip" } else { "7z.exe" }
$tmpFile = "$env:TEMP\$assetName"
$gitDir = "$HermesHome\git"
Write-Info "Downloading $assetName (Git for Windows $gitVerTag)..."
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpFile -UseBasicParsing
if (Test-Path $gitDir) {
Write-Info "Removing previous Git install at $gitDir ..."
Remove-Item -Recurse -Force $gitDir
}
New-Item -ItemType Directory -Path $gitDir -Force | Out-Null
if ($downloadIsZip) {
Expand-Archive -Path $tmpFile -DestinationPath $gitDir -Force
} else {
# PortableGit is a self-extracting 7z archive. Invoke it with
# `-o<target> -y` (silent) to extract to $gitDir. No 7z install
# required; it's fully self-contained.
Write-Info "Extracting PortableGit to $gitDir ..."
$extractProc = Start-Process -FilePath $tmpFile `
-ArgumentList "-o`"$gitDir`"", "-y" `
-NoNewWindow -Wait -PassThru
if ($extractProc.ExitCode -ne 0) {
throw "PortableGit extraction failed (exit code $($extractProc.ExitCode))"
}
}
Remove-Item -Force $tmpFile -ErrorAction SilentlyContinue
# PortableGit layout: cmd\git.exe + bin\bash.exe + usr\bin\ (coreutils)
# MinGit layout: cmd\git.exe + usr\bin\bash.exe (if present)
$gitExe = "$gitDir\cmd\git.exe"
if (-not (Test-Path $gitExe)) {
throw "Git extraction did not produce git.exe at $gitExe"
}
# Add to session PATH so the rest of this install run can use git.
$env:Path = "$gitDir\cmd;$env:Path"
# Persist to User PATH so fresh shells see it. PortableGit needs
# cmd\ (for git.exe), bin\ (for bash.exe + core tools), and
# usr\bin\ (for perl, ssh, curl, and other POSIX coreutils).
$newPathEntries = @(
"$gitDir\cmd",
"$gitDir\bin",
"$gitDir\usr\bin"
)
$userPath = [Environment]::GetEnvironmentVariable("Path", "User")
$userPathItems = if ($userPath) { $userPath -split ";" } else { @() }
$changed = $false
foreach ($entry in $newPathEntries) {
if ($userPathItems -notcontains $entry) {
$userPathItems += $entry
$changed = $true
}
}
if ($changed) {
[Environment]::SetEnvironmentVariable("Path", ($userPathItems -join ";"), "User")
}
$version = & $gitExe --version
Write-Success "Git $version installed to $gitDir (portable, user-scoped)"
Set-GitBashEnvVar
if (-not $script:GitBashPath) {
throw "PortableGit extraction did not produce a usable bash.exe"
}
if (-not (Test-GitBashCompatibility -BashPath $script:GitBashPath)) {
if (Test-MandatoryAslrEnabled) {
$script:GitInstallFailureReason = New-GitBashAslrFailureReason -BashPath $script:GitBashPath
} else {
$probeDetail = if ($script:GitBashProbeOutput) { " Probe output: $script:GitBashProbeOutput" } else { "" }
$script:GitInstallFailureReason = "Git Bash at $script:GitBashPath exists but cannot launch required MSYS programs.$probeDetail"
}
throw $script:GitInstallFailureReason
}
Write-Success "Git Bash can launch MSYS programs"
return $true
} catch {
if ($script:GitInstallFailureReason) {
Write-Err $script:GitInstallFailureReason
return $false
}
Write-Err "Could not install portable Git: $_"
Write-Info ""
Write-Info "Fallback: install Git manually from https://git-scm.com/download/win"
Write-Info "then re-run this installer. Hermes needs Git Bash on Windows to run"
Write-Info "shell commands (same as Claude Code and other coding agents)."
return $false
}
}
function Set-GitBashEnvVar {
<#
.SYNOPSIS
Locate ``bash.exe`` from an already-installed Git and persist the path in
``HERMES_GIT_BASH_PATH`` (User env scope) so Hermes can find it even before
PATH propagation completes in a newly-spawned shell.
#>
$script:GitBashPath = $null
$candidates = @()
# Our own portable Git install is ALWAYS checked first, so a broken
# system Git doesn't hijack us. If the user had a working system Git
# we'd have returned early from Install-Git's fast path and never called
# this with a system-Git-only installation anyway.
#
# Layouts:
# PortableGit (our default): $HermesHome\git\bin\bash.exe
# MinGit (32-bit fallback): $HermesHome\git\usr\bin\bash.exe
$candidates += "$HermesHome\git\bin\bash.exe" # PortableGit layout (primary)
$candidates += "$HermesHome\git\usr\bin\bash.exe" # MinGit / PortableGit usr\bin fallback
# git.exe on PATH can tell us where the install root is
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
if ($gitCmd) {
$gitExe = $gitCmd.Source
# Git for Windows (full installer): <root>\cmd\git.exe + <root>\bin\bash.exe
# MinGit: <root>\cmd\git.exe + <root>\usr\bin\bash.exe
$gitRoot = Split-Path (Split-Path $gitExe -Parent) -Parent
$candidates += "$gitRoot\bin\bash.exe"
$candidates += "$gitRoot\usr\bin\bash.exe"
}
# Standard system install locations as a final fallback. Note:
# ProgramFiles(x86) can't be referenced via ${env:...} string interpolation
# because of the parens -- use [Environment]::GetEnvironmentVariable().
$candidates += "${env:ProgramFiles}\Git\bin\bash.exe"
$pf86 = [Environment]::GetEnvironmentVariable("ProgramFiles(x86)")
if ($pf86) { $candidates += "$pf86\Git\bin\bash.exe" }
$candidates += "${env:LocalAppData}\Programs\Git\bin\bash.exe"
foreach ($candidate in $candidates) {
if ($candidate -and (Test-Path $candidate)) {
[Environment]::SetEnvironmentVariable("HERMES_GIT_BASH_PATH", $candidate, "User")
$env:HERMES_GIT_BASH_PATH = $candidate
$script:GitBashPath = $candidate
Write-Info "Set HERMES_GIT_BASH_PATH=$candidate"
return
}
}
Write-Warn "Could not locate bash.exe -- Hermes may not find Git Bash."
Write-Info "If needed, set HERMES_GIT_BASH_PATH manually to your bash.exe path."
}
# The dependency tree supports Node 22.22+, 24.11+, and 26+. nanoid 6 excludes
# Node 23 and 25 while its >=26 arm accepts later releases, and @babel/* 8.x
# requires ^22.18.0 || >=24.11.0 -- so accepting 23/25 or an early Node 24
# only defers the failure to `npm ci` under engine-strict. Keep this in sync
# with the root package.json.
function Test-NodeVersionOk {
param([string]$Version)
if ($Version -match '-') { return $false }
try {
$v = [version]($Version -replace '^v', '')
} catch {
return $false
}
if ($v.Major -eq 22) { return ($v.Minor -ge 22) }
if ($v.Major -eq 24) { return ($v.Minor -ge 11) }
return ($v.Major -ge 26)
}
# Accept a system Node only when its companion npm also satisfies the same
# range used to provision the Hermes-managed tree. Keeping this probe separate
# lets the initial PATH check and the post-winget check share one authority.
function Test-SystemNodeReady {
if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false }
$version = node --version
if (Test-NodeVersionOk $version) {
Ensure-NodeExeOnPath | Out-Null
} else {
Write-Warn "Node.js $version is unsupported (Hermes requires Node 22.22+, 24.11+, or 26+)"
return $false
}
$npmRange = Get-NpmRange
$npmCmd = Get-Command npm.cmd -ErrorAction SilentlyContinue
if (-not $npmCmd) {
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
}
$npmVersion = $null
if ($npmCmd) {
try {
$npmVersion = (& $npmCmd --version 2>$null | Select-Object -First 1)
} catch { }
}
if ($npmVersion -and (Test-NpmVersionOk $npmVersion $npmRange)) {
Write-Success "Node.js $version with npm $npmVersion found"
return $true
}
if ($npmVersion) {
Write-Warn "Node.js $version uses npm $npmVersion, which does not satisfy Hermes requirement $npmRange"
} else {
Write-Warn "Node.js $version was found, but npm is missing or could not report its version"
}
return $false
}
function Test-Node {
Write-Info "Checking Node.js (for browser tools)..."
if (Test-SystemNodeReady) {
$script:HasNode = $true
return $true
}
Write-Info "Using a Hermes-managed Node.js installation instead..."
# Prefer a Hermes-managed Node from a previous run over a too-old system one.
$managedNode = "$HermesHome\node\node.exe"
if ((Test-Path $managedNode) -and (Test-NodeVersionOk (& $managedNode --version))) {
$version = & $managedNode --version
$env:Path = "$HermesHome\node;$env:Path"
Set-ManagedNodeFirstOnUserPath "$HermesHome\node"
Write-Success "Node.js $version found (Hermes-managed)"
# A tree from an older install still has that Node major's bundled
# npm, which is below the current engines.npm floor. No-ops when the
# npm is already in range, so reruns cost one --version probe.
Update-ManagedNpm "$HermesHome\node" | Out-Null
$script:HasNode = $true
return $true
}
Write-Info "Installing Hermes-managed Node.js $NodeVersion LTS..."
# Try the portable-zip path FIRST -- no UAC, no admin, no winget MSI.
# winget install OpenJS.NodeJS.LTS triggers a system-wide MSI install
# which prompts UAC (the dialog often appears minimized in the taskbar
# and the install silently waits for consent, looking like a hang).
# The portable zip path drops node.exe + npm into $HermesHome\node\
# which is user-scoped and identical to how Install-Git handles
# PortableGit. Same UX guarantee: works on locked-down enterprise
# machines with no admin rights.
Write-Info "Downloading portable Node.js $NodeVersion to $HermesHome\node\ ..."
Write-Info "(no admin rights required; isolated from any system Node install)"
try {
$arch = Get-WindowsArch
$indexUrl = "https://nodejs.org/dist/latest-v${NodeVersion}.x/"
$indexPage = Invoke-WebRequest -Uri $indexUrl -UseBasicParsing
$zipName = ($indexPage.Content | Select-String -Pattern "node-v${NodeVersion}\.\d+\.\d+-win-${arch}\.zip" -AllMatches).Matches[0].Value
if ($zipName) {
$downloadUrl = "${indexUrl}${zipName}"
$tmpZip = "$env:TEMP\$zipName"
$tmpDir = "$env:TEMP\hermes-node-extract"
Invoke-WebRequest -Uri $downloadUrl -OutFile $tmpZip -UseBasicParsing
if (Test-Path $tmpDir) { Remove-Item -Recurse -Force $tmpDir }
Expand-Archive -Path $tmpZip -DestinationPath $tmpDir -Force
$extractedDir = Get-ChildItem $tmpDir -Directory | Select-Object -First 1
if ($extractedDir) {
# Rename-swap instead of delete-then-move: the live tree is
# never removed before its replacement is fully extracted.
# Windows permits renaming a tree with running executables,
# but if a process holds it without FILE_SHARE_DELETE the
# rename fails with WinError 5 -- that refusal means the tree
# is in use, so defer instead of forcing the write (#80926).
# Best-effort sweep of staging/backup litter from interrupted
# runs; locked files simply stay for the next attempt. Only
# dirs older than 10 minutes are removed so a concurrent
# heal's in-flight swap is never disturbed.
Get-ChildItem "$HermesHome" -Directory -Filter "node.old-*" -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
Get-ChildItem "$HermesHome" -Directory -Filter "node.new-*" -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt (Get-Date).AddMinutes(-10) } |
Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
$stamp = [Guid]::NewGuid().ToString("N")
$staged = "$HermesHome\node.new-$stamp"
$backup = "$HermesHome\node.old-$stamp"
# Stage to a sibling directory so the final swap is a
# same-volume rename (atomic), not a cross-volume Move-Item
# (copy+delete, non-atomic -- a partial copy would leave a
# broken tree). Move from $env:TEMP here, rename below.
try {
Move-Item $extractedDir.FullName $staged -ErrorAction Stop
} catch {
Write-Warn "Failed to stage the new Node.js tree; aborting the Node upgrade."
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
return $false
}
if (Test-Path "$HermesHome\node") {
try {
Rename-Item "$HermesHome\node" $backup -ErrorAction Stop
} catch {
Write-Warn "Hermes-managed Node.js is in use by a running app; deferring its upgrade. Close the app and re-run the update."
Remove-Item -Recurse -Force $staged -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
return $false
}
# A rename preserves LastWriteTime, so a backup renamed
# from a long-lived tree would instantly look older than
# the litter-sweep cutoff to a concurrent heal. Touch it
# (best-effort) so the in-flight backup is never swept.
try {
(Get-Item $backup).LastWriteTime = Get-Date
} catch { }
try {
Rename-Item $staged "$HermesHome\node" -ErrorAction Stop
} catch {
# Restore the live tree before bailing. The swap is a
# same-volume rename, so a failure leaves no partial
# target to clear.
Rename-Item $backup "$HermesHome\node" -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $staged -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
return $false
}
Remove-Item -Recurse -Force $backup -ErrorAction SilentlyContinue
} else {
try {
Rename-Item $staged "$HermesHome\node" -ErrorAction Stop
} catch {
Remove-Item -Recurse -Force $staged -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
return $false
}
}
# Session PATH so the rest of this run sees node/npm.
$env:Path = "$HermesHome\node;$env:Path"
# Persist to User PATH so fresh shells (and future stages
# in cross-process driver mode) see it. Matches the
# pattern Install-Git uses for PortableGit. See
# Set-ManagedNodeFirstOnUserPath for why this is a
# move-to-front and not an add-if-missing.
Set-ManagedNodeFirstOnUserPath "$HermesHome\node"
$version = & "$HermesHome\node\node.exe" --version
Write-Success "Node.js $version installed to $HermesHome\node\ (portable, user-scoped)"
# The zip's bundled npm is below the repo's engines.npm floor.
Update-ManagedNpm "$HermesHome\node" | Out-Null
$script:HasNode = $true
Remove-Item -Force $tmpZip -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $tmpDir -ErrorAction SilentlyContinue
return $true
}
}
} catch {
Write-Warn "Portable Node.js download failed: $_"
}
# Fallback: try winget (used to be primary, demoted because the MSI
# install triggers a UAC prompt that frequently appears minimized in
# the taskbar -- looks like a hang to users on stock Windows).
# Kept for environments where the portable download fails (proxy,
# locked firewall, etc.) but the user is willing to consent to UAC.
if (Get-Command winget -ErrorAction SilentlyContinue) {
Write-Info "Falling back to winget (may prompt UAC -- check your taskbar for a flashing icon)..."
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value (see Install-Uv for the full rationale).
$prevEAP = $ErrorActionPreference
try {
# Relax EAP=Stop so stderr lines from winget don't get wrapped
# as ErrorRecords and short-circuit the 2>&1 pipe before we can
# check the post-condition. See the long comment in Install-Uv
# for the same pattern.
$ErrorActionPreference = "Continue"
# On ARM64, force winget to fetch the ARM64 installer. Without
# the explicit override, winget on WoW64 sometimes still resolves
# to x64 manifests, leaving us with an emulated Node toolchain
# even after a "successful" install. The OpenJS manifest does
# publish an arm64 installer, so this is safe.
$wingetArgs = @(
'install','OpenJS.NodeJS','--silent',
'--accept-package-agreements','--accept-source-agreements'
)
if ((Get-WindowsArch) -eq 'arm64') {
$wingetArgs += @('--architecture','arm64')
}
winget @wingetArgs 2>&1 | Out-Null
$ErrorActionPreference = $prevEAP
# Refresh PATH
$env:Path = [Environment]::GetEnvironmentVariable("Path", "User") + ";" + [Environment]::GetEnvironmentVariable("Path", "Machine")
if (Test-SystemNodeReady) {
$script:HasNode = $true
return $true
}
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
}
}
Write-Info "Install manually: https://nodejs.org/en/download/"
$script:HasNode = $false
return $true
}
function Update-ProcessPathForPackages {
# Make freshly-installed shims (rg.exe, ffmpeg.exe) visible to Get-Command in
# THIS process without spawning a new shell, by folding the persisted
# User+Machine hives plus winget's alias-shim directory into $env:Path.
# Called after every package-manager attempt (winget/choco/scoop): previously
# PATH was only refreshed inside the winget branch, so a successful
# choco/scoop fallback -- or any install on a box without winget -- could be
# misreported as "not installed".
#
# MERGE rather than overwrite: start from the existing process PATH so any
# process-only entries added earlier in this installer run survive, then
# APPEND hive/winget-Links entries not already present (case-insensitive,
# order-preserving dedupe). A wholesale replace would silently drop those
# process-only entries.
$candidates = @()
$candidates += $env:Path
$candidates += [Environment]::GetEnvironmentVariable("Path", "User")
$candidates += [Environment]::GetEnvironmentVariable("Path", "Machine")
$wingetLinks = Join-Path $env:LOCALAPPDATA "Microsoft\WinGet\Links"
if (Test-Path $wingetLinks) {
$candidates += $wingetLinks
}
$seen = New-Object System.Collections.Generic.HashSet[string] ([StringComparer]::OrdinalIgnoreCase)
$ordered = New-Object System.Collections.Generic.List[string]
foreach ($chunk in $candidates) {
if ([string]::IsNullOrEmpty($chunk)) { continue }
foreach ($entry in $chunk.Split(';')) {
$trimmed = $entry.Trim()
if ($trimmed -and $seen.Add($trimmed)) {
$ordered.Add($trimmed)
}
}
}
$env:Path = [string]::Join(';', $ordered)
}
function Install-SystemPackages {
$script:HasRipgrep = $false
$script:HasFfmpeg = $false
$needRipgrep = $false
$needFfmpeg = $false
Write-Info "Checking ripgrep (fast file search)..."
if (Get-Command rg -ErrorAction SilentlyContinue) {
$version = rg --version | Select-Object -First 1
Write-Success "$version found"
$script:HasRipgrep = $true
} else {
$needRipgrep = $true
}
Write-Info "Checking ffmpeg (TTS voice messages)..."
if (Get-Command ffmpeg -ErrorAction SilentlyContinue) {
Write-Success "ffmpeg found"
$script:HasFfmpeg = $true
} else {
$needFfmpeg = $true
}
if (-not $needRipgrep -and -not $needFfmpeg) { return }
# Build description and package lists for each package manager
$descParts = @()
$wingetPkgs = @()
$chocoPkgs = @()
$scoopPkgs = @()
if ($needRipgrep) {
$descParts += "ripgrep for faster file search"
$wingetPkgs += "BurntSushi.ripgrep.MSVC"
$chocoPkgs += "ripgrep"
$scoopPkgs += "ripgrep"
}
if ($needFfmpeg) {
$descParts += "ffmpeg for TTS voice messages"
$wingetPkgs += "Gyan.FFmpeg"
$chocoPkgs += "ffmpeg"
$scoopPkgs += "ffmpeg"
}
$description = $descParts -join " and "
$hasWinget = Get-Command winget -ErrorAction SilentlyContinue
$hasChoco = Get-Command choco -ErrorAction SilentlyContinue
$hasScoop = Get-Command scoop -ErrorAction SilentlyContinue
# Try winget first (most common on modern Windows)
if ($hasWinget) {
Write-Info "Installing $description via winget..."
# Per-package log paths -- key the lookup by package id so we can
# decide AFTER the post-install Get-Command check whether to keep
# the log (still missing -> keep as breadcrumb) or delete it (now
# present -> happy path, no clutter).
$pkgLogs = @{}
foreach ($pkg in $wingetPkgs) {
$log = "$env:TEMP\hermes-winget-$($pkg -replace '[^A-Za-z0-9]','_')-$(Get-Random).log"
$pkgLogs[$pkg] = $log
# --source winget pins us to the github-backed source. Without this,
# a broken msstore source (cert validation failures like 0x8a15005e
# are common on Windows-on-ARM and some corporate networks) makes
# winget bail with "please specify --source" *before* attempting any
# install -- and it exits 0, so the surrounding try/catch never fires.
# We don't ship anything from msstore, so pinning is safe.
try {
$output = winget install --exact --id $pkg --source winget --silent `
--accept-package-agreements --accept-source-agreements 2>&1
$code = $LASTEXITCODE
$output | Out-File -FilePath $log -Encoding utf8
"winget exit: $code" | Out-File -FilePath $log -Encoding utf8 -Append
# 0x8A15002B (-1978335189) = APPINSTALLER_CLI_ERROR_UPDATE_NOT_APPLICABLE.
# winget treats `install` on a package it already has registered as
# an *upgrade*, finds no newer version, and bails with this code --
# even when the binary is gone from disk/PATH (stale registration,
# files removed outside winget, or a missing alias shim). We KNOW the
# command was missing (that's why we're here), so a plain install
# dead-ends forever. Force a reinstall to repair the registration so
# the shim reappears.
if ($code -eq -1978335189) {
"-> already-installed/no-upgrade; retrying with --force" | Out-File -FilePath $log -Encoding utf8 -Append
$output = winget install --exact --id $pkg --source winget --silent --force `
--accept-package-agreements --accept-source-agreements 2>&1
$output | Out-File -FilePath $log -Encoding utf8 -Append
"winget exit (force): $LASTEXITCODE" | Out-File -FilePath $log -Encoding utf8 -Append
}
} catch {
$_ | Out-File -FilePath $log -Encoding utf8 -Append
"winget exit: <exception>" | Out-File -FilePath $log -Encoding utf8 -Append
}
}
# Refresh PATH so packages winget exposed via "command line aliases" in
# %LOCALAPPDATA%\Microsoft\WinGet\Links (added to PATH only in
# newly-spawned shells, not this process) are visible to Get-Command below.
Update-ProcessPathForPackages
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
Write-Success "ripgrep installed"
$script:HasRipgrep = $true
$needRipgrep = $false
Remove-Item -Path $pkgLogs["BurntSushi.ripgrep.MSVC"] -ErrorAction SilentlyContinue
} elseif ($pkgLogs.ContainsKey("BurntSushi.ripgrep.MSVC")) {
Write-Warn "winget could not install ripgrep; details: $($pkgLogs['BurntSushi.ripgrep.MSVC'])"
}
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Success "ffmpeg installed"
$script:HasFfmpeg = $true
$needFfmpeg = $false
Remove-Item -Path $pkgLogs["Gyan.FFmpeg"] -ErrorAction SilentlyContinue
} elseif ($pkgLogs.ContainsKey("Gyan.FFmpeg")) {
Write-Warn "winget could not install ffmpeg; details: $($pkgLogs['Gyan.FFmpeg'])"
}
if (-not $needRipgrep -and -not $needFfmpeg) { return }
}
# Fallback: choco
if ($hasChoco -and ($needRipgrep -or $needFfmpeg)) {
Write-Info "Trying Chocolatey..."
foreach ($pkg in $chocoPkgs) {
try { choco install $pkg -y 2>&1 | Out-Null } catch { }
}
Update-ProcessPathForPackages
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
Write-Success "ripgrep installed via chocolatey"
$script:HasRipgrep = $true
$needRipgrep = $false
}
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Success "ffmpeg installed via chocolatey"
$script:HasFfmpeg = $true
$needFfmpeg = $false
}
}
# Fallback: scoop
if ($hasScoop -and ($needRipgrep -or $needFfmpeg)) {
Write-Info "Trying Scoop..."
foreach ($pkg in $scoopPkgs) {
try { scoop install $pkg 2>&1 | Out-Null } catch { }
}
Update-ProcessPathForPackages
if ($needRipgrep -and (Get-Command rg -ErrorAction SilentlyContinue)) {
Write-Success "ripgrep installed via scoop"
$script:HasRipgrep = $true
$needRipgrep = $false
}
if ($needFfmpeg -and (Get-Command ffmpeg -ErrorAction SilentlyContinue)) {
Write-Success "ffmpeg installed via scoop"
$script:HasFfmpeg = $true
$needFfmpeg = $false
}
}
# Show manual instructions for anything still missing
if ($needRipgrep) {
Write-Warn "ripgrep not installed (file search will use findstr fallback)"
Write-Info " winget install BurntSushi.ripgrep.MSVC"
}
if ($needFfmpeg) {
Write-Warn "ffmpeg not installed (TTS voice messages will be limited)"
Write-Info " winget install Gyan.FFmpeg"
}
}
# ============================================================================
# Installation
# ============================================================================
function Install-Repository {
Write-Info "Installing to $InstallDir..."
$didUpdate = $false
if (Test-Path $InstallDir) {
# Test-Path "$InstallDir\.git" returns True when .git is a file OR a
# directory OR a symlink OR a submodule-style gitfile -- and also when
# it's a broken stub left over from a failed previous install (e.g.
# a partial Remove-Item that couldn't delete a locked index.lock).
# Validate the repo properly by asking git itself. Three checks
# belt-and-braces: rev-parse (work tree), git status, and a resolvable
# HEAD (an initial commit). If any fails the repo is broken and we
# fall through to a fresh clone.
$repoValid = $false
if (Test-Path "$InstallDir\.git") {
Push-Location $InstallDir
try {
# Reset $LASTEXITCODE before the probe so we don't pick up
# a stale 0 from an earlier git call in this session.
$global:LASTEXITCODE = 0
$revParseOut = & git -c windows.appendAtomically=false rev-parse --is-inside-work-tree 2>&1
$revParseOk = ($LASTEXITCODE -eq 0) -and ($revParseOut -match "true")
$global:LASTEXITCODE = 0
$null = & git -c windows.appendAtomically=false status --short 2>&1
$statusOk = ($LASTEXITCODE -eq 0)
# An interrupted previous clone leaves a repo with NO initial
# commit. rev-parse/status still succeed there, but the update
# path's `git stash` (and later `git checkout`) abort with
# "You do not have the initial commit yet" and fail the install
# (#40998). Require a resolvable HEAD so such partial checkouts
# are treated as broken and re-cloned fresh below.
$global:LASTEXITCODE = 0
$null = & git -c windows.appendAtomically=false rev-parse --verify HEAD 2>&1
$hasCommit = ($LASTEXITCODE -eq 0)
if ($revParseOk -and $statusOk -and $hasCommit) {
$repoValid = $true
}
} catch {}
Pop-Location
}
if ($repoValid) {
Write-Info "Existing installation found, updating..."
Push-Location $InstallDir
# Wrap the entire fetch+checkout block in EAP=Continue so git's
# routine stderr output (e.g. 'From <url>' info lines emitted by
# `git fetch`) doesn't terminate the script under the global
# EAP=Stop. We rely on $LASTEXITCODE for actual failures.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
$autostashRef = ""
try {
# This is a MANAGED checkout, not a repo the user edits. Git for
# Windows defaults to core.autocrlf=true, which renormalizes the
# repo's LF-only text files to CRLF in the working tree -- so
# tracked files (.envrc, AGENTS.md, agent/*.py, workflows, ...)
# show as locally modified even though nobody touched them. A
# bare `git checkout` then aborts with "Your local changes would
# be overwritten by checkout", which is exactly the failure GUI
# users hit on update. Pin autocrlf=false so the dirt is never
# created in the first place.
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
Discard-LockfileChurn $InstallDir
# Preserve any real local changes before the checkout instead of
# discarding them with `reset --hard HEAD`. The old hard reset
# silently destroyed agent-edited source on managed clones (the
# #38542 data-loss class). Stash + restore mirrors install.sh:
# nothing is lost, and a failed restore leaves the work in a
# git stash for manual recovery. Untracked files are included so
# agent-created dirs (e.g. tinker-atropos/) survive too.
$statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null
if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) {
# A previously interrupted update can leave the index with
# unmerged entries. In that state `git stash` aborts with
# "could not write index" and the following `git checkout`
# aborts with "you need to resolve your current index first"
# -- the GUI "git checkout main failed (exit 1)" install
# failure. Clear the conflict markers with `git reset` first:
# working-tree changes are kept (and stashed just below); only
# the index conflict state is dropped. Mirrors the `hermes
# update` path (#4735).
$unmergedOut = git -c windows.appendAtomically=false ls-files --unmerged 2>$null
if (-not [string]::IsNullOrWhiteSpace(($unmergedOut -join "`n"))) {
Write-Info "Clearing unmerged index entries from a previous conflict..."
git -c windows.appendAtomically=false reset -q 2>$null
}
$stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss")
Write-Info "Local changes detected, stashing before update..."
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
if ($LASTEXITCODE -eq 0) { $autostashRef = "stash@{0}" }
}
git -c windows.appendAtomically=false fetch origin $Branch
if ($LASTEXITCODE -ne 0) { throw "git fetch failed (exit $LASTEXITCODE)" }
# Precedence: Commit > Tag > Branch. Commit and Tag check
# out as detached HEAD intentionally -- they're meant to be
# reproducible pins, not branches the user pulls into.
if ($Commit) {
# Make sure we have the commit locally (a tag-less commit
# SHA isn't always reachable from any one branch fetch).
git -c windows.appendAtomically=false fetch origin $Commit
# A commit pin must never move an existing install
# BACKWARDS. hermes-setup.exe bakes its build-time commit
# into the binary (BUILD_PIN_COMMIT) and passes it as
# -Commit on every install-mode run -- including the retry
# the desktop's "Update didn't finish" screen kicks off. An
# installer built months ago would otherwise rewind a
# current checkout to its build commit, leaving ancient
# code against a current venv (npm workspaces and Python
# deps that no longer match: the #74xxx report). Skip the
# pin when the target is already an ancestor of HEAD; a
# fresh clone has no such ancestry and pins normally.
$skipRollback = $false
if (-not $ForceCommit) {
git -c windows.appendAtomically=false merge-base --is-ancestor $Commit HEAD 2>$null
$isAncestor = ($LASTEXITCODE -eq 0)
$pinnedSha = (& git -c windows.appendAtomically=false rev-parse "$Commit^{commit}" 2>$null)
$headSha = (& git -c windows.appendAtomically=false rev-parse HEAD 2>$null)
$skipRollback = $isAncestor -and ($pinnedSha -ne $headSha)
}
if ($skipRollback) {
Write-Warn "Ignoring -Commit $Commit`: the checkout is already newer."
Write-Warn "Pinning to it would roll this install back. Pass -ForceCommit to override."
} else {
git -c windows.appendAtomically=false checkout --detach $Commit
if ($LASTEXITCODE -ne 0) { throw "git checkout $Commit failed (exit $LASTEXITCODE)" }
}
} elseif ($Tag) {
git -c windows.appendAtomically=false fetch origin "refs/tags/${Tag}:refs/tags/${Tag}"
git -c windows.appendAtomically=false checkout --detach "refs/tags/$Tag"
if ($LASTEXITCODE -ne 0) { throw "git checkout tag $Tag failed (exit $LASTEXITCODE)" }
} else {
git -c windows.appendAtomically=false checkout $Branch
if ($LASTEXITCODE -ne 0) { throw "git checkout $Branch failed (exit $LASTEXITCODE)" }
# Managed installs should follow origin/$Branch exactly. If
# the checkout has diverged (or has local-only commits),
# ff-only pull cannot succeed -- mirror ``hermes update`` and
# reset to the fetched remote so bootstrap/install can recover.
git -c windows.appendAtomically=false pull --ff-only origin $Branch
if ($LASTEXITCODE -ne 0) {
Write-Warn "Fast-forward not possible; resetting managed install to origin/$Branch..."
git -c windows.appendAtomically=false reset --hard "origin/$Branch"
if ($LASTEXITCODE -ne 0) { throw "git reset --hard origin/$Branch failed (exit $LASTEXITCODE)" }
}
}
if ($autostashRef) {
# Default to restoring so work is never silently dropped.
# Only prompt when we're certain a human can answer: an
# interactive session AND a real, non-redirected console on
# both stdin and stdout. The desktop "Update" button and
# bootstrap run the installer without a usable console -- in
# those cases Read-Host would hang or return empty, so we
# skip the prompt and just restore (the safe default).
$restoreNow = $true
$hasConsole = $false
try {
$hasConsole = (
[Environment]::UserInteractive `
-and (-not [Console]::IsInputRedirected) `
-and (-not [Console]::IsOutputRedirected) `
-and ($Host.Name -eq "ConsoleHost")
)
} catch { $hasConsole = $false }
if ($hasConsole) {
Write-Warn "Local changes were stashed before updating."
Write-Warn "Restoring them may reapply local customizations onto the updated codebase."
$restoreAnswer = Read-Host "Restore local changes now? [Y/n]"
if ($restoreAnswer -match '^(n|no)$') { $restoreNow = $false }
}
if ($restoreNow) {
Write-Info "Restoring local changes..."
$restoreOutput = @(git -c windows.appendAtomically=false stash apply $autostashRef 2>&1)
$restoreExit = $LASTEXITCODE
$conflictedFiles = @(
git -c windows.appendAtomically=false diff --name-only --diff-filter=U 2>$null
) | Where-Object { $_ -and $_.ToString().Trim() }
if (($restoreExit -eq 0) -and ($conflictedFiles.Count -eq 0)) {
git -c windows.appendAtomically=false stash drop $autostashRef 2>$null
Write-Warn "Local changes were restored on top of the updated codebase."
Write-Warn "Review git diff / git status if Hermes behaves unexpectedly."
} else {
Write-Err "Update pulled new code, but restoring local changes hit conflicts."
foreach ($line in $restoreOutput) {
if ($line -and $line.ToString().Trim()) {
Write-Host $line
}
}
if ($conflictedFiles.Count -gt 0) {
Write-Host ""
Write-Host "Conflicted files:"
foreach ($file in $conflictedFiles) {
Write-Host " - $file"
}
}
Write-Host ""
Write-Info "Your stashed changes are preserved -- nothing is lost."
Write-Info " Stash ref: $autostashRef"
git -c windows.appendAtomically=false reset --hard HEAD 2>$null | Out-Null
Write-Info "Working tree reset to clean state."
Write-Info "Restore your changes later with: git stash apply $autostashRef"
}
} else {
Write-Info "Skipped restoring local changes."
Write-Info "Your changes are still preserved in git stash."
Write-Info "Restore manually with: git stash apply $autostashRef"
}
$autostashRef = ""
}
} finally {
if ($autostashRef) {
# We stashed but never reached the restore block (a fetch/
# checkout/pull failure threw). Leave the stash in place and
# tell the user how to recover it -- never silently drop it.
Write-Warn "Update did not complete. Your local changes are preserved in git stash."
Write-Info "Restore manually with: git stash apply $autostashRef"
}
$ErrorActionPreference = $prevEAP
Pop-Location
}
$didUpdate = $true
} else {
# Directory exists but isn't a usable git repo -- e.g. an
# interrupted clone with no initial commit (#40998), or a leftover
# ``.git`` stub from a partial uninstall that used to lock the
# installer into the "update" branch forever. Move it aside rather
# than deleting it -- never destroy a directory the user might still
# want -- and fall through to a fresh clone.
$backupDir = "$InstallDir.broken-" + (Get-Date -Format "yyyyMMdd-HHmmss")
Write-Warn "Existing directory at $InstallDir is not a valid git repo."
Write-Warn "Moving it aside to $backupDir before re-cloning."
try {
Move-Item -LiteralPath $InstallDir -Destination $backupDir -ErrorAction Stop
} catch {
Write-Err "Could not move $InstallDir aside : $_"
Write-Info "Close any programs that might be using files in $InstallDir (editors,"
Write-Info "terminals, running hermes processes) and try again."
throw
}
}
}
if (-not $didUpdate) {
$cloneSuccess = $false
# Fix Windows git "copy-fd: write returned: Invalid argument" error.
# Git for Windows can fail on atomic file operations (hook templates,
# config lock files) due to antivirus, OneDrive, or NTFS filter drivers.
# The -c flag injects config before any file I/O occurs.
Write-Info "Configuring git for Windows compatibility..."
$env:GIT_CONFIG_COUNT = "1"
$env:GIT_CONFIG_KEY_0 = "windows.appendAtomically"
$env:GIT_CONFIG_VALUE_0 = "false"
git config --global windows.appendAtomically false 2>$null
# Try SSH first, then HTTPS, with -c flag for atomic write fix
Write-Info "Trying SSH clone..."
$env:GIT_SSH_COMMAND = "ssh -o BatchMode=yes -o ConnectTimeout=5"
try {
Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlSsh $InstallDir }
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { }
$env:GIT_SSH_COMMAND = $null
if (-not $cloneSuccess) {
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
Write-Info "SSH failed, trying HTTPS..."
try {
Invoke-NativeWithRelaxedErrorAction { git -c windows.appendAtomically=false clone --depth 1 --branch $Branch $RepoUrlHttps $InstallDir }
if ($LASTEXITCODE -eq 0) { $cloneSuccess = $true }
} catch { }
}
# Fallback: download ZIP archive (bypasses git file I/O issues entirely)
if (-not $cloneSuccess) {
if (Test-Path $InstallDir) { Remove-Item -Recurse -Force $InstallDir -ErrorAction SilentlyContinue }
Write-Warn "Git clone failed -- downloading ZIP archive instead..."
try {
# Pick the ZIP URL for the most-specific ref the caller asked
# for. GitHub supports archive URLs for commits, tags, and
# branches; we honour Commit > Tag > Branch.
if ($Commit) {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/$Commit.zip"
$zipLabel = $Commit
} elseif ($Tag) {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/tags/$Tag.zip"
$zipLabel = $Tag
} else {
$zipUrl = "https://github.com/NousResearch/hermes-agent/archive/refs/heads/$Branch.zip"
$zipLabel = $Branch
}
$zipPath = "$env:TEMP\hermes-agent-$zipLabel.zip"
$extractPath = "$env:TEMP\hermes-agent-extract"
Invoke-WebRequest -Uri $zipUrl -OutFile $zipPath -UseBasicParsing
if (Test-Path $extractPath) { Remove-Item -Recurse -Force $extractPath }
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
# GitHub ZIPs extract to repo-branch/ subdirectory
$extractedDir = Get-ChildItem $extractPath -Directory | Select-Object -First 1
if ($extractedDir) {
New-Item -ItemType Directory -Force -Path (Split-Path $InstallDir) -ErrorAction SilentlyContinue | Out-Null
Move-Item $extractedDir.FullName $InstallDir -Force
Write-Success "Downloaded and extracted"
# Initialize git repo so updates work later. A bare
# `git init` leaves NO HEAD -- desktop's write-build-stamp
# then hard-fails with "could not determine git commit"
# (#50823 / #61657). Fetch the requested ref and force-check
# it out (-f) so untracked ZIP files cannot block checkout.
Push-Location $InstallDir
git -c windows.appendAtomically=false init 2>$null
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
# Pin autocrlf=false BEFORE the checkout below. Git for Windows
# defaults to core.autocrlf=true, which would renormalize the
# repo's LF text files to CRLF in the working tree during
# `checkout -f FETCH_HEAD` -- leaving this freshly-created
# managed checkout dirty vs HEAD and aborting the next
# `hermes update` (see the notes at the shared clone-path
# config below and install.ps1:1461-1469). The later pin on
# the shared path is idempotent and still covers git clones.
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
git remote add origin $RepoUrlHttps 2>$null
$fetchRef = if ($Commit) { $Commit } elseif ($Tag) { "refs/tags/$Tag" } else { $Branch }
Write-Info "Fetching $fetchRef so the ZIP checkout has a resolvable HEAD..."
$prevZipEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
git -c windows.appendAtomically=false fetch --depth 1 origin $fetchRef 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
if ($Commit -or $Tag) {
git -c windows.appendAtomically=false checkout -f --detach FETCH_HEAD 2>&1 | Out-Null
} else {
git -c windows.appendAtomically=false checkout -f -B $Branch FETCH_HEAD 2>&1 | Out-Null
}
if ($LASTEXITCODE -eq 0) {
Write-Success "ZIP checkout pinned to $fetchRef"
} else {
# Checkout blocked, but FETCH_HEAD still has a SHA we can stamp with.
$fetchSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null
if ($LASTEXITCODE -eq 0 -and $fetchSha) {
if (-not $env:GITHUB_SHA) { $env:GITHUB_SHA = ("$fetchSha").Trim() }
Write-Warn "ZIP checkout failed; seeded GITHUB_SHA from FETCH_HEAD for desktop stamp"
} else {
Write-Warn "ZIP extract succeeded but git checkout failed -- desktop build may need `$env:GITHUB_SHA"
}
}
} else {
Write-Warn "ZIP extract succeeded but git fetch of $fetchRef failed -- desktop build may need `$env:GITHUB_SHA"
}
} finally {
$ErrorActionPreference = $prevZipEAP
}
Pop-Location
Write-Success "Git repo initialized for future updates"
$cloneSuccess = $true
}
# Cleanup temp files
Remove-Item -Force $zipPath -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force $extractPath -ErrorAction SilentlyContinue
} catch {
Write-Err "ZIP download also failed: $_"
}
}
if (-not $cloneSuccess) {
throw "Failed to download repository (tried git clone SSH, HTTPS, and ZIP)"
}
}
# Set per-repo config (harmless if it fails)
Push-Location $InstallDir
git -c windows.appendAtomically=false config windows.appendAtomically false 2>$null
# Pin autocrlf=false on the managed clone so git never renormalizes the
# repo's LF text files to CRLF in the working tree. Without this, the very
# next `hermes update` checkout aborts on a "dirty" tree the user never
# touched (see the update path above).
git -c windows.appendAtomically=false config core.autocrlf false 2>$null
# Post-clone pin: when a clone (or ZIP-fallback init) just landed us on
# $Branch's tip, honour the higher-precedence $Commit / $Tag by checking
# the exact ref out as a detached HEAD. Skipped for the in-place update
# path (above) since that already routed via the same precedence.
if (-not $didUpdate) {
# Same EAP=Continue wrap as the update path -- git fetch's 'From <url>'
# info line goes to stderr and would terminate the script under the
# global EAP=Stop otherwise. We check $LASTEXITCODE for real errors.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
if ($Commit) {
Write-Info "Pinning to commit $Commit..."
git -c windows.appendAtomically=false fetch origin $Commit
git -c windows.appendAtomically=false checkout --detach $Commit
if ($LASTEXITCODE -ne 0) {
throw "git checkout $Commit failed (exit $LASTEXITCODE)"
}
} elseif ($Tag) {
Write-Info "Pinning to tag $Tag..."
git -c windows.appendAtomically=false fetch origin "refs/tags/${Tag}:refs/tags/${Tag}"
git -c windows.appendAtomically=false checkout --detach "refs/tags/$Tag"
if ($LASTEXITCODE -ne 0) {
throw "git checkout tag $Tag failed (exit $LASTEXITCODE)"
}
}
} finally {
$ErrorActionPreference = $prevEAP
}
}
Write-Success "Repository ready"
}
function Install-Venv {
if ($NoVenv) {
Write-Info "Skipping virtual environment (-NoVenv)"
return
}
# Re-resolve the interpreter before creating the venv. Under Hermes-Setup.exe
# each stage runs in its own powershell.exe, so the fallback the `python`
# stage picked (e.g. 3.12 when 3.11 is absent) did NOT propagate into this
# fresh process -- $PythonVersion is back at its "3.11" default. Trusting it
# here made `uv venv venv --python 3.11` fail with exit 2 on machines without
# 3.11 even though the `python` stage reported success (issue #50769).
$resolvedPython = Resolve-AvailablePythonVersion
if (-not $resolvedPython) {
throw "Hermes-managed Python is unavailable. Run install.ps1 -Stage python first."
}
Write-Info "Creating virtual environment with Python $($resolvedPython.Version)..."
Push-Location $InstallDir
# Tasks we disabled below and must re-enable no matter how this stage
# exits. Populated only with tasks that were ENABLED before we touched
# them, so a task the user deliberately disabled is never re-armed.
$gatewayTasksDisabled = @()
$venvHadExistingVenv = $false
$venvBackupName = $null
$venvParked = $false
try {
if (Test-Path -LiteralPath "venv") {
$venvHadExistingVenv = $true
Write-Info "Virtual environment already exists, recreating..."
# On Windows, native Python extensions (e.g. _bcrypt.pyd, tornado's
# speedups.pyd) are loaded as DLLs by any running hermes process.
# Windows denies deletion of loaded DLLs, so every process running out
# of this venv must be stopped before retiring it. This keeps cleanup
# from accumulating locked stale trees and avoids carrying a live
# gateway into the replacement venv.
if ($env:OS -eq "Windows_NT") {
$myPid = $PID
Write-Info "Stopping any running hermes processes before recreating venv..."
# Disarm the respawner FIRST: the gateway autostart Scheduled Task
# relaunches a killed gateway within seconds, and losing that race
# re-locks the venv's .pyd files between our kill sweep and
# venv parking/cleanup (the July 2026 _brotlicffi.pyd incident). schtasks
# /End stops a running task instance; /Change /DISABLE stops it
# from re-firing mid-install. (The Startup-folder .vbs fallback is
# NOT touched: it only fires at logon, so it cannot respawn a
# gateway mid-install.) Re-enabled in the finally below -- including
# on failure -- but only for tasks that were enabled to begin with.
# Best-effort: a missing task just errors quietly.
try {
schtasks /Query /FO CSV 2>$null | ConvertFrom-Csv | Where-Object { $_.TaskName -like '*Hermes_Gateway*' } | ForEach-Object {
$tn = $_.TaskName
if ($_.Status -eq 'Disabled') {
Write-Info " gateway autostart task $tn is already disabled; leaving it that way"
return
}
schtasks /End /TN $tn 2>$null | Out-Null
schtasks /Change /TN $tn /DISABLE 2>$null | Out-Null
$gatewayTasksDisabled += $tn
Write-Info " disabled gateway autostart task $tn for the duration of the install"
}
} catch {
Write-Warn "Could not enumerate gateway scheduled tasks: $($_.Exception.Message)"
}
# The launcher CLI (hermes.exe) plus its child tree.
& taskkill /F /T /IM hermes.exe /FI "PID ne $myPid" 2>$null | Out-Null
# taskkill /IM hermes.exe is NOT enough: the gateway/agent that a
# scheduled task or watchdog autostarts runs as
# `pythonw.exe -m hermes_cli.main gateway run` straight out of
# venv\Scripts\, so its image name is python/pythonw, not hermes.exe.
# That process holds the venv's .pyd files open and re-triggers the
# access-denied failure. Select only roots whose executable lives
# under this venv, then stop each root's whole process tree. Some
# Hermes children re-exec through .hermes-runtime, so killing only
# the selected venv process can leave its child holding the install
# open. The path-prefix check still keeps unrelated Python processes
# outside this venv untouched.
#
# The gateway autostart task registers with /RL LIMITED as the current
# user (see hermes_cli/gateway_windows.py), so the installer always
# runs at equal-or-higher integrity and can read its executable path.
# Get-CimInstance is used over Get-Process because it returns a null
# ExecutablePath for a process it cannot inspect (a different session)
# instead of throwing, so an unreadable process is skipped rather than
# aborting the whole sweep.
#
# The sweep is a bounded LOOP, not single-shot: supervised processes
# (the Desktop app's backend, a watchdog-managed gateway) respawn in
# the window between one kill pass and venv parking. Each pass re-
# enumerates; three consecutive clean passes (or the attempt cap)
# ends the loop.
$venvPrefix = [System.IO.Path]::GetFullPath((Join-Path $InstallDir "venv")).TrimEnd('\') + '\'
$cleanPasses = 0
for ($sweep = 0; $sweep -lt 10 -and $cleanPasses -lt 3; $sweep++) {
$found = 0
try {
Get-CimInstance Win32_Process -ErrorAction Stop |
Where-Object { $_.ProcessId -ne $myPid -and $_.ExecutablePath -and $_.ExecutablePath.StartsWith($venvPrefix, [System.StringComparison]::OrdinalIgnoreCase) } |
ForEach-Object {
$found++
$treePid = [string]$_.ProcessId
Write-Info " stopping process tree at PID $treePid ($($_.Name)) running from venv"
& taskkill /F /T /PID $treePid 2>$null | Out-Null
}
} catch {
Write-Warn "Could not enumerate venv processes: $($_.Exception.Message)"
break
}
if ($found -eq 0) { $cleanPasses++ } else { $cleanPasses = 0 }
Start-Sleep -Milliseconds 400
}
}
# Move the old venv aside before creating its replacement. A directory
# rename is atomic on the same volume and does not require deleting
# files mapped as DLLs. NEVER fall back to deleting the live venv
# (#83149): Remove-Item -Recurse can delete most of site-packages and
# then fail on one locked .pyd, leaving a gutted venv with no usable
# interpreter and no rollback source. Abort with the previous install
# intact so the user can close holders and retry.
$venvBackupName = "venv.stale.{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), ([Guid]::NewGuid().ToString("N"))
try {
Rename-Item -LiteralPath "venv" -NewName $venvBackupName -ErrorAction Stop
$venvParked = $true
} catch {
$renameErr = $_.Exception.Message
throw (
"Could not move the existing venv aside ($renameErr). " +
"A process still has the install directory open (often a non-Hermes " +
"python.exe that resolved into this venv via PATH). Close those " +
"processes and retry - the previous install was left intact."
)
}
}
# Pass the already-validated private interpreter path and prohibit uv from
# resolving or downloading a different Python during venv creation. Use
# ProcessStartInfo because the desktop bootstrapper redirects this script;
# Windows PowerShell 5.1 can otherwise lose nested native output/exit state.
$venvProcess = New-Object System.Diagnostics.Process
try {
$venvStartInfo = New-Object System.Diagnostics.ProcessStartInfo
$venvStartInfo.FileName = $UvCmd
$venvStartInfo.Arguments = "venv venv --python `"$($resolvedPython.Path)`" --managed-python --no-python-downloads --no-config"
$venvStartInfo.WorkingDirectory = $InstallDir
$venvStartInfo.UseShellExecute = $false
$venvStartInfo.CreateNoWindow = $true
$venvStartInfo.RedirectStandardOutput = $true
$venvStartInfo.RedirectStandardError = $true
$venvProcess.StartInfo = $venvStartInfo
if (-not $venvProcess.Start()) {
throw "Failed to start uv while creating the virtual environment"
}
$venvStdoutTask = $venvProcess.StandardOutput.ReadToEndAsync()
$venvStderrTask = $venvProcess.StandardError.ReadToEndAsync()
$venvProcess.WaitForExit()
$venvStdout = $venvStdoutTask.Result
$venvStderr = $venvStderrTask.Result
$venvExitCode = $venvProcess.ExitCode
if ($venvStdout) { Write-Host $venvStdout.TrimEnd() }
if ($venvStderr) { Write-Host $venvStderr.TrimEnd() }
} finally {
$venvProcess.Dispose()
}
# Fail fast so the stage cannot report ok=true when uv failed.
if ($venvExitCode -ne 0) {
throw "Failed to create virtual environment (uv venv exited with $venvExitCode)"
}
# uv can return success without leaving the interpreter expected by the
# installer (for example after an interrupted filesystem operation). Treat
# that as a failed transaction so the previous venv can be restored.
$venvPythonExe = Join-Path $InstallDir "venv\Scripts\python.exe"
if (-not (Test-Path -LiteralPath $venvPythonExe -PathType Leaf)) {
throw "uv reported success but venv interpreter is missing at $venvPythonExe"
}
# The replacement has a working interpreter, but the transaction is only
# committed after Install-Dependencies' baseline-import gate passes -- the
# bootstrap runs the stages as separate processes, and every dependency
# tier (or the import validation) can still fail after this stage
# succeeds. Record the parked backup so the dependency stage can restore
# it on failure and commit its cleanup only after validation (#83149).
if ($venvParked) {
Set-Content -LiteralPath (Join-Path $InstallDir "venv.pending-backup") -Value $venvBackupName -Encoding ascii
Write-Info "Previous venv parked at $venvBackupName until the dependency install is verified"
}
# Clean up parked venvs from previous installs whose handles have since
# been released. Best-effort -- a still-held tree just stays for next time.
# The backup parked THIS run is excluded: it is the rollback source until
# Install-Dependencies commits the transaction.
Get-ChildItem -Directory -Filter "venv.stale.*" -ErrorAction SilentlyContinue |
Where-Object { $_.Name -ne $venvBackupName } | ForEach-Object {
Remove-Item -Recurse -Force $_.FullName -ErrorAction SilentlyContinue
}
# Neutralize any inherited UV_PYTHON (e.g. $env:UV_PYTHON = "3.14" left in
# the user's shell). uv honours UV_PYTHON over an existing venv for the
# later `uv sync` / `uv pip install` tiers, so without this it would
# silently delete this 3.11 venv and recreate it at the inherited version
# -- building Rust transitives that have no wheel for that version from
# source via maturin, which fails. Pinning UV_PYTHON to the interpreter we
# just created forces every subsequent uv command onto it.
$env:UV_PYTHON = $venvPythonExe
} catch {
$originalError = $_
$rollbackError = $null
if ($venvParked -and $venvBackupName -and (Test-Path -LiteralPath $venvBackupName)) {
try {
if (Test-Path -LiteralPath "venv") {
$failedVenvName = "venv.failed.{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), ([Guid]::NewGuid().ToString("N"))
Rename-Item -LiteralPath "venv" -NewName $failedVenvName -ErrorAction Stop
Write-Warn "Failed replacement parked at $failedVenvName"
}
Rename-Item -LiteralPath $venvBackupName -NewName "venv" -ErrorAction Stop
Write-Warn "Restored previous virtual environment after failed recreate"
} catch {
$rollbackError = $_.Exception.Message
}
if ($rollbackError) {
throw "Virtual environment recreate failed: $($originalError.Exception.Message). Rollback failed: $rollbackError. Previous venv remains at $venvBackupName."
}
} elseif (-not $venvHadExistingVenv -and (Test-Path -LiteralPath "venv")) {
# Preserve a partial first install too. This branch must not touch a
# pre-existing venv whose move-aside failed above.
try {
$failedVenvName = "venv.failed.{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), ([Guid]::NewGuid().ToString("N"))
Rename-Item -LiteralPath "venv" -NewName $failedVenvName -ErrorAction Stop
Write-Warn "Partial virtual environment parked at $failedVenvName"
} catch {
$rollbackError = $_.Exception.Message
}
if ($rollbackError) {
throw "Virtual environment creation failed: $($originalError.Exception.Message). Could not park partial venv: $rollbackError"
}
}
throw $originalError
} finally {
Pop-Location
# Re-arm the gateway autostart tasks disabled during the venv teardown
# -- in a finally so a failed teardown/creation can never strand the
# user's gateway autostart in the disabled state. Same function scope,
# so the list survives even under the stage-per-process bootstrap.
# Deliberately NOT started here -- dependencies aren't installed yet;
# the task fires normally on next logon and `hermes update` / the
# gateway resume path handles the immediate restart.
if ($gatewayTasksDisabled -and $gatewayTasksDisabled.Count -gt 0) {
foreach ($tn in $gatewayTasksDisabled) {
schtasks /Change /TN $tn /ENABLE 2>$null | Out-Null
}
Write-Info "Re-enabled gateway autostart task(s): $($gatewayTasksDisabled -join ', ')"
}
}
Write-Success "Virtual environment ready (Python $($resolvedPython.Version))"
}
function Get-PendingVenvBackup {
# Rollback source recorded by Install-Venv (#83149). Returns the parked
# directory name, or $null when there is nothing to roll back to. A marker
# pointing at a directory that no longer exists is stale -- drop it.
$markerPath = Join-Path $InstallDir "venv.pending-backup"
if (-not (Test-Path -LiteralPath $markerPath -PathType Leaf)) { return $null }
$name = (Get-Content -LiteralPath $markerPath -ErrorAction SilentlyContinue | Select-Object -First 1)
if ($name) { $name = $name.Trim() }
if (-not $name -or -not (Test-Path -LiteralPath (Join-Path $InstallDir $name))) {
Remove-Item -LiteralPath $markerPath -Force -ErrorAction SilentlyContinue
return $null
}
return $name
}
function Complete-VenvTransaction {
# Commit: dependency install + baseline imports passed, so the previous
# venv is no longer needed as a rollback source. Best-effort delete; a
# tree still held open just stays parked for the next install's sweep.
$backupName = Get-PendingVenvBackup
if (-not $backupName) { return }
$backupPath = Join-Path $InstallDir $backupName
Remove-Item -LiteralPath $backupPath -Recurse -Force -ErrorAction SilentlyContinue
if (Test-Path -LiteralPath $backupPath) {
Write-Warn "Old venv parked at $backupName (a process still holds files in it); it will be cleaned up on the next install"
}
Remove-Item -LiteralPath (Join-Path $InstallDir "venv.pending-backup") -Force -ErrorAction SilentlyContinue
}
function Restore-VenvBackup {
# Rollback: the dependency stage failed after Install-Venv replaced the
# venv. Park the unusable replacement and restore the previous working
# venv so Hermes (and the venv-blocker probe) stay usable (#83149).
$backupName = Get-PendingVenvBackup
if (-not $backupName) { return }
try {
if (Test-Path -LiteralPath (Join-Path $InstallDir "venv")) {
$failedVenvName = "venv.failed.{0}-{1}" -f (Get-Date -Format "yyyyMMddHHmmss"), ([Guid]::NewGuid().ToString("N"))
Rename-Item -LiteralPath (Join-Path $InstallDir "venv") -NewName $failedVenvName -ErrorAction Stop
Write-Warn "Failed replacement parked at $failedVenvName"
}
Rename-Item -LiteralPath (Join-Path $InstallDir $backupName) -NewName "venv" -ErrorAction Stop
Remove-Item -LiteralPath (Join-Path $InstallDir "venv.pending-backup") -Force -ErrorAction SilentlyContinue
Write-Warn "Restored previous virtual environment after failed dependency install"
} catch {
Write-Warn "Could not restore previous venv (still parked at $backupName): $($_.Exception.Message)"
}
}
function Install-Dependencies {
Write-Info "Installing dependencies..."
Push-Location $InstallDir
if (-not $NoVenv) {
# Tell uv to install into our venv (no activation needed)
$env:VIRTUAL_ENV = "$InstallDir\venv"
}
# Re-pin UV_PYTHON to the venv interpreter. Install-Venv already does this,
# but the bootstrap runs install stages (venv, python-deps) as separate
# processes, so the env var set in Install-Venv does NOT survive into a
# separate python-deps invocation. Re-deriving it here covers that path.
# Without it, an inherited $env:UV_PYTHON = "3.14" makes the uv sync/pip
# tiers below recreate the venv at 3.14 and fail the maturin source build
# (no cp314 wheels yet).
if (-not $NoVenv) {
$venvPythonExe = Join-Path $InstallDir "venv\Scripts\python.exe"
if (Test-Path $venvPythonExe) {
$env:UV_PYTHON = $venvPythonExe
}
}
# Hash-verified install (Tier 0) -- when uv.lock is present, prefer
# `uv sync --locked`. The lockfile records SHA256 hashes for every
# transitive dependency, so a compromised transitive (different hash
# than what we shipped) is REJECTED by the resolver. This is the
# *only* path that protects against the "direct dep is fine, but the
# dep's dep got worm-poisoned overnight" failure mode. The
# `uv pip install` tiers below re-resolve transitives fresh from PyPI
# without any hash verification -- they exist to keep installs working
# when the lockfile is stale, missing, or out-of-sync with the
# current extras spec, NOT because they're equivalent in posture.
#
# Everything through the baseline-import gate runs inside the venv
# transaction opened by Install-Venv (#83149): on any failure the parked
# previous venv is restored before the error propagates, and the parked
# tree is deleted only after the imports prove the replacement usable.
try {
if (Test-Path "uv.lock") {
Write-Info "Trying tier: hash-verified (uv.lock) ..."
# Critical flag choice: `--extra all`, NOT `--all-extras`.
# --all-extras = every [project.optional-dependencies] key,
# bypassing the curated [all] extra. On Windows
# that means [matrix] -> python-olm (no wheel,
# needs `make` to build from sdist) and the
# install fails.
# --extra all = just the [all] extra's contents (curated).
#
# UV_PROJECT_ENVIRONMENT pins the sync target to our venv\.
# Without it, modern uv (>=0.5) ignores VIRTUAL_ENV for `sync`
# and creates a sibling .venv\ inside the repo -- leaving venv\
# empty and producing the broken state where `hermes.exe` exists
# in the wrong directory and imports fail with ModuleNotFoundError.
# (Mirrors the same flag in scripts/install.sh::install_deps.)
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
Invoke-NativeWithRelaxedErrorAction { & $UvCmd sync --extra all --locked }
if ($LASTEXITCODE -eq 0) {
Write-Success "Main package installed (hash-verified via uv.lock)"
$script:InstalledTier = "hash-verified (uv.lock)"
# Skip the rest of the tiered cascade -- we already have a
# complete, hash-verified install.
$skipPipFallback = $true
} else {
Write-Warn "uv.lock sync failed (lockfile may be stale), falling back to PyPI resolve..."
$skipPipFallback = $false
}
} else {
Write-Info "uv.lock not found -- falling back to PyPI resolve (no hash verification)"
$skipPipFallback = $false
}
# Install main package. Tiered fallback so a single flaky transitive
# doesn't silently drop everything. Each tier's stdout/stderr is
# preserved -- no Out-Null swallowing -- so the user can see what failed.
#
# Tier 1: [all] -- the curated extra in pyproject.toml.
# Tier 2: [all] minus the currently-broken extras list ($brokenExtras).
# Edit $brokenExtras below when something on PyPI breaks; this
# lets users keep the rest of [all] when one transitive is
# unavailable. The list of [all]'s contents is parsed from
# pyproject.toml at runtime -- there is NO hand-mirrored copy
# to drift out of sync.
# Tier 3: bare `.` -- last-resort so at least the core CLI launches.
# Currently-broken extras. Edit this list when an upstream package
# gets quarantined / yanked / breaks resolution. Empty means everything
# in [all] should be installable; populate with the names of extras
# whose deps are temporarily unavailable.
$brokenExtras = @()
# Parse [project.optional-dependencies].all from pyproject.toml.
# tomllib is stdlib on Python 3.11+ which the bootstrap guarantees.
$pythonExeForParse = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
$allExtras = @()
if (Test-Path $pythonExeForParse) {
$parsed = & $pythonExeForParse -c @"
import re, sys, tomllib
try:
with open('pyproject.toml', 'rb') as fh:
data = tomllib.load(fh)
specs = data['project']['optional-dependencies']['all']
out = []
for s in specs:
m = re.search(r'hermes-agent\[([\w-]+)\]', s)
if m: out.append(m.group(1))
print(','.join(out))
except Exception:
sys.exit(1)
"@ 2>$null
if ($LASTEXITCODE -eq 0 -and $parsed) {
$allExtras = $parsed.Trim().Split(',')
}
}
if (-not $allExtras -or $allExtras.Count -eq 0) {
Write-Warn "Could not parse [all] from pyproject.toml; Tier 2 will be a no-op."
$safeAll = "all"
} else {
$safeAll = ($allExtras | Where-Object { $brokenExtras -notcontains $_ }) -join ","
}
$brokenLabel = if ($brokenExtras) { ($brokenExtras -join ", ") } else { "none" }
$installTiers = @(
@{ Name = "all"; Spec = ".[all]" },
@{ Name = "all minus known-broken ($brokenLabel)"; Spec = ".[$safeAll]" },
@{ Name = "core only (no extras)"; Spec = "." }
)
$installed = $skipPipFallback
if (-not $skipPipFallback) {
foreach ($tier in $installTiers) {
Write-Info "Trying tier: $($tier.Name) ..."
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e $tier.Spec }
if ($LASTEXITCODE -eq 0) {
Write-Success "Main package installed ($($tier.Name))"
$script:InstalledTier = $tier.Name
$installed = $true
break
}
Write-Warn "Tier '$($tier.Name)' failed (exit $LASTEXITCODE). Trying next tier..."
}
}
if (-not $installed) {
throw "Failed to install hermes-agent package even with no extras. Inspect the uv pip install output above."
}
# Baseline-import gate. Even if a tier reported success above, the
# actual deps may have landed somewhere other than $InstallDir\venv\
# (e.g. uv 0.5+ syncing into a sibling .venv\ when UV_PROJECT_ENVIRONMENT
# isn't set, leaving venv\ empty and hermes.exe broken with
# `ModuleNotFoundError: No module named 'dotenv'` on first run).
# We probe via the venv's own python so a misdirected sync is caught
# here, not 30 seconds later when the user runs `hermes`.
if (-not $NoVenv) {
$venvPython = "$InstallDir\venv\Scripts\python.exe"
if (-not (Test-Path $venvPython)) {
throw "Install reported success but $venvPython does not exist. The dependency sync likely landed in a sibling .venv\ directory. Re-run the installer; if it persists, close Hermes processes and preserve existing venv directories before retrying. Do not delete venv in place."
}
# Relax EAP=Stop while running the import probe. Python writes
# deprecation warnings and import-system info to stderr; under
# EAP=Stop the 2>&1 merge wraps those as ErrorRecord objects and
# throws even when the imports succeed. $LASTEXITCODE is the
# reliable signal (it's 0 iff the python invocation exited 0,
# regardless of what was written to stderr).
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
& $venvPython -c "import dotenv, openai, rich, prompt_toolkit" 2>&1 | Out-Null
$importExitCode = $LASTEXITCODE
$ErrorActionPreference = $prevEAP
if ($importExitCode -ne 0) {
$sibling = "$InstallDir\.venv"
$hint = if (Test-Path $sibling) {
"Detected sibling .venv\ at $sibling -- uv synced there instead of venv\. Close Hermes processes, preserve the existing venv, and rerun the installer so the transactional recovery path can move directories safely."
} else {
"Recover with: cd '$InstallDir'; `$env:UV_PROJECT_ENVIRONMENT='$InstallDir\venv'; uv sync --extra all --locked"
}
throw "Baseline imports failed in $InstallDir\venv (dotenv/openai/rich/prompt_toolkit). The install completed but dependencies are not in the venv. $hint"
}
Write-Success "Baseline imports verified in venv"
}
# Commit the venv transaction: the dependency install completed and the
# baseline imports passed, so the previous venv is no longer needed as a
# rollback source (#83149).
Complete-VenvTransaction
} catch {
# Dependency install or import validation failed: restore the previous
# working venv (parked by Install-Venv) before surfacing the error, so
# a failed update leaves Hermes and its blocker probe usable.
Restore-VenvBackup
Pop-Location
throw
}
if (-not $NoVenv) {
# uv on Windows can register hermes.exe in dist-info/RECORD but fail to
# materialise the .exe (file lock during self-update, distlib edge case).
# Catch it here so a fresh install/update does not finish with a broken
# `hermes` command while hermes-agent.exe / hermes-acp.exe exist
$scriptsDir = Join-Path $InstallDir "venv\Scripts"
$pythonExe = Join-Path $scriptsDir "python.exe"
if ((Test-Path $scriptsDir) -and (Test-Path $pythonExe)) {
$scriptNames = & $pythonExe -c @"
import tomllib
with open('pyproject.toml', 'rb') as fh:
scripts = tomllib.load(fh).get('project', {}).get('scripts', {}) or {}
print(','.join(scripts))
"@ 2>$null
if ($LASTEXITCODE -eq 0 -and $scriptNames) {
$expected = @($scriptNames.Trim().Split(',') | Where-Object { $_ })
$missing = @()
foreach ($name in $expected) {
$exe = Join-Path $scriptsDir "$name.exe"
if (-not (Test-Path $exe)) { $missing += "$name.exe" }
}
if ($missing.Count -gt 0) {
Write-Warn "Console entry point(s) missing: $($missing -join ', ')"
Write-Info "Reinstalling entry points..."
$env:UV_PROJECT_ENVIRONMENT = "$InstallDir\venv"
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install --reinstall -e . }
$stillMissing = @()
foreach ($name in $expected) {
$exe = Join-Path $scriptsDir "$name.exe"
if (-not (Test-Path $exe)) { $stillMissing += "$name.exe" }
}
if ($stillMissing.Count -gt 0) {
Write-Warn "Entry points still missing after repair: $($stillMissing -join ', ')"
Write-Info "Workaround: `"$pythonExe`" -m hermes_cli.main <command>"
} else {
Write-Success "Console entry points restored"
}
}
}
}
}
# Verify the dashboard deps specifically -- they're the most common thing
# users hit and lazy-import errors from `hermes dashboard` are confusing.
# If tier 1 failed (the common case), [web] was still picked up by tiers
# 2-3; only tier 4 leaves you without it.
$pythonExe = if (-not $NoVenv) { "$InstallDir\venv\Scripts\python.exe" } else { (& $UvCmd python find $PythonVersion) }
if (Test-Path $pythonExe) {
$webOk = $false
$webServerSyntaxOk = $false
# Relax EAP=Stop while running the import probe; see the matching
# comment on the baseline-imports check above. Python writes
# deprecation warnings to stderr and we don't want those wrapped
# as ErrorRecords that silently force the "not importable" path
# even when fastapi/uvicorn are actually installed.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
& $pythonExe -c "import fastapi, uvicorn" 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { $webOk = $true }
} catch { }
try {
& $pythonExe -m py_compile "$InstallDir\hermes_cli\web_server.py" 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) { $webServerSyntaxOk = $true }
} catch { }
$ErrorActionPreference = $prevEAP
if (-not $webOk) {
Write-Warn "fastapi/uvicorn not importable -- `hermes dashboard` will not work."
Write-Info "Attempting targeted install of [web] extra as last resort..."
& $UvCmd pip install -e ".[web]"
if ($LASTEXITCODE -eq 0) {
Write-Success "[web] extra installed; `hermes dashboard` should now work."
} else {
Write-Warn "Could not install [web] extra. Run manually: uv pip install --python `"$pythonExe`" `"fastapi>=0.104,<1`" `"uvicorn[standard]>=0.24,<1`""
}
}
if (-not $webServerSyntaxOk) {
throw "dashboard backend source failed syntax check: hermes_cli/web_server.py"
}
}
Pop-Location
Write-Success "All dependencies installed"
}
function Install-HermesCommandLaunchers {
param(
[Parameter(Mandatory=$true)] [string]$Root,
[Parameter(Mandatory=$true)] [string]$Destination
)
# Expose ONLY the hermes launchers on PATH -- never the whole
# venv\Scripts directory, which contains python.exe / pip.exe and
# silently hijacks the `python` command in every terminal (#83797).
# Requiring hermes.exe before creating the destination keeps the PATH
# stage from reporting success with an unusable command (PR #92092).
$scriptsDir = Join-Path $Root "venv\Scripts"
$requiredSource = Join-Path $scriptsDir "hermes.exe"
if (-not (Test-Path -LiteralPath $requiredSource -PathType Leaf)) {
throw "Cannot set up the hermes command: required launcher not found: $requiredSource"
}
New-Item -ItemType Directory -Force -Path $Destination | Out-Null
# Launcher form depends on the venv (keep in lockstep with
# hermes_cli/_install_repair.py): a normal venv's exe trampoline
# embeds an absolute interpreter path and survives copying; a
# relocatable venv's trampoline (managed_uv rebuilds use
# --relocatable) resolves relative to its own location, and a copy
# dies with 'uv trampoline failed to canonicalize script path' --
# those get a .cmd delegator invoking the in-venv exe instead.
$pyvenvCfg = Join-Path $Root "venv\pyvenv.cfg"
$venvRelocatable = $false
if (Test-Path -LiteralPath $pyvenvCfg) {
$venvRelocatable = [bool](Select-String -Path $pyvenvCfg -Pattern '^\s*relocatable\s*=\s*true\s*$' -Quiet)
}
foreach ($launcher in @("hermes", "hermes-acp")) {
$src = Join-Path $scriptsDir "$launcher.exe"
if (-not (Test-Path -LiteralPath $src -PathType Leaf)) { continue }
if ($venvRelocatable) {
Remove-Item (Join-Path $Destination "$launcher.exe") -Force -ErrorAction SilentlyContinue
Set-Content -Path (Join-Path $Destination "$launcher.cmd") -Value "@echo off`r`n`"$src`" %*" -Encoding Ascii
} else {
Remove-Item (Join-Path $Destination "$launcher.cmd") -Force -ErrorAction SilentlyContinue
Copy-Item -Force -LiteralPath $src -Destination (Join-Path $Destination "$launcher.exe")
}
}
# Verify either staged form before the caller mutates PATH.
$requiredExe = Join-Path $Destination "hermes.exe"
$requiredCmd = Join-Path $Destination "hermes.cmd"
if (-not ((Test-Path -LiteralPath $requiredExe -PathType Leaf) -or
(Test-Path -LiteralPath $requiredCmd -PathType Leaf))) {
throw "Cannot set up the hermes command: launcher was not installed: $requiredExe"
}
return $Destination
}
function Set-PathVariable {
Write-Info "Setting up hermes command..."
if ($NoVenv) {
$hermesBin = "$InstallDir"
} else {
# $HermesHome\bin is the managed binary dir (shared with the managed
# uv), OUTSIDE the git checkout: `hermes update`'s autostash
# (git stash push --include-untracked) deletes untracked files from
# the working tree, which silently removed the launchers an earlier
# installer staged under hermes-agent\bin. No git operation can ever
# touch this dir. Staging and verification live in
# Install-HermesCommandLaunchers, which throws BEFORE any PATH
# mutation when the launchers cannot be staged.
$hermesBin = "$HermesHome\bin"
Install-HermesCommandLaunchers -Root $InstallDir -Destination $hermesBin | Out-Null
}
$currentPath = [Environment]::GetEnvironmentVariable("Path", "User")
# Migrate older layouts off the user PATH:
# venv\Scripts -- shadowed the user's python (#83797)
# hermes-agent\bin -- lived inside the git checkout, where the update
# autostash could sweep the launchers off disk
# The hermes-agent\bin FILES are left in place on purpose: editor/ACP
# configs that captured absolute launcher paths keep working, and the
# dir is git-ignored so it cannot dirty the checkout.
if (-not $NoVenv) {
$legacyEntries = @("$InstallDir\venv\Scripts", "$InstallDir\bin")
$items = @(($currentPath -split ';') | Where-Object { $_ })
$cleaned = @($items | Where-Object { $legacyEntries -notcontains $_ })
if ($cleaned.Count -ne $items.Count) {
$currentPath = $cleaned -join ";"
[Environment]::SetEnvironmentVariable("Path", $currentPath, "User")
Write-Info "Removed legacy launcher entries from user PATH (kept hermes via $hermesBin)"
}
}
if ($currentPath -notlike "*$hermesBin*") {
[Environment]::SetEnvironmentVariable(
"Path",
"$hermesBin;$currentPath",
"User"
)
Write-Success "Added to user PATH: $hermesBin"
} else {
Write-Info "PATH already configured"
}
# Set HERMES_HOME so the Python code finds config/data in the right place.
# Only needed on Windows where we install to %LOCALAPPDATA%\hermes instead
# of the Unix default ~/.hermes
$currentHermesHome = [Environment]::GetEnvironmentVariable("HERMES_HOME", "User")
if (-not $currentHermesHome -or $currentHermesHome -ne $HermesHome) {
[Environment]::SetEnvironmentVariable("HERMES_HOME", $HermesHome, "User")
Write-Success "Set HERMES_HOME=$HermesHome"
}
$env:HERMES_HOME = $HermesHome
# Update current session
$env:Path = "$hermesBin;$env:Path"
Write-Success "hermes command ready"
}
function Write-BootstrapMarker {
# Writes $InstallDir\.hermes-bootstrap-complete which tells the Hermes
# desktop app (apps/desktop/electron/main.ts) "install.ps1 ran
# successfully -- DON'T trigger the legacy first-launch bootstrap
# runner."
#
# Schema mirrors what main.ts's writeBootstrapMarker() / isBootstrap
# Complete() expect. Keep this in lockstep when either side changes:
# apps/desktop/electron/main.ts lines 1199-1222
# BOOTSTRAP_MARKER_SCHEMA_VERSION = 1 (line 187)
#
# Pinned commit/branch come from -Commit + -Branch flags (passed by
# Hermes-Setup.exe) or fall back to whatever git resolves in the
# checkout. The desktop validates schemaVersion + pinnedCommit
# length but doesn't enforce that HEAD matches the pin (users
# update via `hermes update` which moves HEAD legitimately).
if (-not (Test-Path $InstallDir)) {
Write-Warn "Skipping bootstrap marker: $InstallDir doesn't exist"
return
}
# Resolve the pinned commit: explicit -Commit wins, otherwise read
# the checkout's HEAD via git. If git can't run, leave commit empty
# and the marker will fail desktop validation (pinnedCommit.length
# >= 7) -- better to be invalid than wrong.
$pinnedCommit = $Commit
if (-not $pinnedCommit) {
# PS 5.1 doesn't support the ?. null-conditional operator, so
# check Get-Command's result explicitly before reading .Source.
$gitCmd = Get-Command git -ErrorAction SilentlyContinue
$gitExe = if ($gitCmd) { $gitCmd.Source } else { $null }
if ($gitExe) {
Push-Location $InstallDir
try {
$resolved = & $gitExe rev-parse HEAD 2>$null
if ($LASTEXITCODE -eq 0 -and $resolved) {
$pinnedCommit = $resolved.Trim()
}
} catch {
# Ignore -- pinnedCommit stays empty, marker stays invalid,
# desktop falls through to its legacy bootstrap path.
} finally {
Pop-Location
}
}
}
$pinnedBranch = $Branch
if (-not $pinnedBranch) {
$pinnedBranch = "main" # install.ps1's own default for -Branch
}
$markerPath = Join-Path $InstallDir ".hermes-bootstrap-complete"
$marker = [ordered]@{
schemaVersion = 1
pinnedCommit = $pinnedCommit
pinnedBranch = $pinnedBranch
completedAt = (Get-Date).ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffZ")
# desktopVersion field intentionally omitted -- only the desktop
# app knows its own version, and the marker validator doesn't
# require it. The desktop fills it in if/when it writes its
# own marker (e.g. after a future in-app upgrade).
}
$json = $marker | ConvertTo-Json -Compress:$false
# Write WITHOUT a UTF-8 BOM. PowerShell 5.1's `Set-Content -Encoding UTF8`
# always emits a BOM, and Node's plain JSON.parse rejects the BOM as an
# unexpected character -- so a BOM'd marker would silently fail the
# desktop's readJson(), make isBootstrapComplete() return null, and the
# desktop would re-run the legacy bootstrap runner anyway. Defeats the
# whole point. Use the .NET API directly for BOM-less UTF-8.
$utf8NoBom = New-Object System.Text.UTF8Encoding $false
[System.IO.File]::WriteAllText($markerPath, $json, $utf8NoBom)
Write-Success "Bootstrap marker written: $markerPath"
}
function Copy-ConfigTemplates {
Write-Info "Setting up configuration files..."
# Create the HERMES_HOME directory structure ($HermesHome, default %LOCALAPPDATA%\hermes)
New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\pairing" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\hooks" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\image_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\audio_cache" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\memories" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\skills" | Out-Null
# Create .env
$envPath = "$HermesHome\.env"
if (-not (Test-Path $envPath)) {
$examplePath = "$InstallDir\.env.example"
if (Test-Path $examplePath) {
Copy-Item $examplePath $envPath
Write-Success "Created $envPath from template"
} else {
New-Item -ItemType File -Force -Path $envPath | Out-Null
Write-Success "Created $envPath"
}
} else {
Write-Info "$envPath already exists, keeping it"
}
# Create config.yaml
$configPath = "$HermesHome\config.yaml"
if (-not (Test-Path $configPath)) {
$examplePath = "$InstallDir\cli-config.yaml.example"
if (Test-Path $examplePath) {
Copy-Item $examplePath $configPath
Write-Success "Created $configPath from template"
}
} else {
Write-Info "$configPath already exists, keeping it"
}
# Create SOUL.md if it doesn't exist (global persona file).
# IMPORTANT: write without a BOM. Windows PowerShell 5.1's
# ``Set-Content -Encoding UTF8`` writes UTF-8 WITH a byte-order-mark
# (the default PS5 behaviour), and Hermes's prompt-injection scanner
# flags the BOM as an invisible unicode character and refuses to
# load the file. PS7's ``-Encoding utf8NoBOM`` fixes that but we
# don't control which PowerShell version the user has. Go direct
# to .NET with an explicit UTF8Encoding($false) -- BOM-free on every
# PowerShell version.
$soulPath = "$HermesHome\SOUL.md"
if (-not (Test-Path $soulPath)) {
# MUST match DEFAULT_SOUL_MD in hermes_cli/default_soul.py. The runtime
# upgrades the old comment-only scaffold to this text on next run, so
# drift is self-healing, but keep them in sync to avoid first-run churn.
$soulContent = @"
You are Hermes Agent, built by Nous Research. Be direct: match the length of your reply to the weight of the ask -- a one-line question gets a one-line answer, and finished work gets a short report of what changed, what's verified, and what's left, never a replay of the process. No filler ("Great question," "I'd be happy to"), no restating the request back, no re-summarizing what you already said, no narrating tool calls the user can see. Plain claims over adjectives; when unsure, say so plainly. Agree because it's right, not because the user said it. Depth is earned -- give it when the user asks for detail, teaches, or the stakes demand it, not by default.
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
Write-Success "Created $soulPath (edit to customize personality)"
}
Write-Success "Configuration directory ready: $HermesHome"
# Seed bundled skills into $HermesHome\skills (manifest-based, one-time per skill)
Write-Info "Syncing bundled skills to $HermesHome\skills ..."
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
if (Test-Path $pythonExe) {
try {
# Force the child python.exe to emit UTF-8 on its stdout/stderr.
# On non-UTF-8 Windows locales (CP936/GBK zh-CN) Python defaults
# its stream encoding to the active codepage and crashes on glyphs
# like the checkmark (U+2713) that the codepage can't encode; the
# resulting non-UTF-8 bytes break this script's JSON result frame on
# stdout and abort the config-templates stage. Scope to this call
# only. (Comment kept ASCII per this file's PS 5.1 contract above.)
$prevPythonioencoding = $env:PYTHONIOENCODING
$prevPythonutf8 = $env:PYTHONUTF8
$env:PYTHONIOENCODING = "utf-8"
$env:PYTHONUTF8 = "1"
try {
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
} finally {
$env:PYTHONIOENCODING = $prevPythonioencoding
$env:PYTHONUTF8 = $prevPythonutf8
}
Write-Success "Skills synced to $HermesHome\skills"
} catch {
# Fallback: simple directory copy
$bundledSkills = "$InstallDir\skills"
$userSkills = "$HermesHome\skills"
if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) {
Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue
Write-Success "Skills copied to $HermesHome\skills"
}
}
}
}
function Install-NodeDeps {
if (-not $HasNode) {
# Cross-process driver mode (Hermes-Setup.exe runs each -Stage NAME
# in a fresh powershell.exe) means $script:HasNode set by Stage-Node
# in the previous process isn't visible here. Re-probe rather than
# trust the stale global -- Stage-Node already ran successfully or
# the bootstrap would've aborted, so npm is reachable.
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
Write-Info "Skipping Node.js dependencies (Node not installed)"
return
}
}
# npm lifecycle scripts need node.exe on the PATH visible to child
# cmd.exe processes. Stage-Node may have run in a prior process, so
# re-apply here before any npm install (regression #48130).
Ensure-NodeExeOnPath | Out-Null
# Resolve npm explicitly to npm.cmd, NOT npm.ps1. Node.js on Windows
# ships BOTH npm.cmd (a batch shim) and npm.ps1 (a PowerShell shim).
# Get-Command's default ordering picks whichever comes first in PATHEXT,
# and on many systems that's .ps1 -- but .ps1 requires scripts to be
# enabled in PowerShell's execution policy, which most Windows users
# don't have (the Restricted / RemoteSigned default blocks unsigned
# .ps1 files). .cmd has no such restriction and works on every box.
#
# Strategy: look next to the npm shim we found and prefer npm.cmd if
# it exists in the same directory. Fall back to whatever Get-Command
# returned if we can't find a .cmd sibling.
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npmCmd) {
Write-Warn "npm not found on PATH -- skipping Node.js dependencies."
Write-Info "Open a new PowerShell window and re-run 'hermes setup tools' later."
return
}
$npmExe = $npmCmd.Source
if ($npmExe -like "*.ps1") {
$npmCmdSibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
if (Test-Path $npmCmdSibling) {
Write-Info "Using npm.cmd (PowerShell execution policy blocks npm.ps1)"
$npmExe = $npmCmdSibling
} else {
Write-Warn "Only npm.ps1 available -- install may fail if script execution is disabled."
Write-Info " If it fails, either enable PS script execution or install Node via winget."
}
}
# Wall-clock ceiling for each npm / Playwright invocation in this stage.
# scripts/install.sh has time-boxed the same work with
# ``run_with_timeout "$NODE_DEPS_TIMEOUT"`` (600s default) since #39219;
# Windows never got the guard, so a stalled registry fetch or a wedged
# Chromium extraction (#76222, #84614) froze the installer forever -- one
# user left it running 12+ hours overnight. Same env override as bash
# for very slow links.
$nodeDepsTimeoutSec = 600
if ($env:NODE_DEPS_TIMEOUT -match '^\d+$') {
$nodeDepsTimeoutSec = [int]$env:NODE_DEPS_TIMEOUT
}
# Helper: run a native command with a hard wall-clock timeout while
# still streaming its output live. Returns the exit code, or 124 on
# timeout (the same convention as coreutils ``timeout`` and bash's
# run_with_timeout).
#
# Launcher notes: ``Start-Process -FilePath npm.cmd`` fails with
# ``%1 is not a valid Win32 application`` on some PowerShell versions
# because Start-Process bypasses cmd.exe / PATHEXT and expects a real
# PE file -- so route through cmd.exe, which IS a real PE, honours .cmd
# batch shims, and performs the stdout+stderr merge into the log file
# natively. The parent then tails the log into the console each poll
# tick, preserving the live progress that makes a 3-minute download
# distinguishable from a hang (the whole reason _Run-NpmInstall streams
# output in the first place). ``Wait-Job -Timeout`` was rejected: jobs
# swallow live output, and Stop-Job leaves the npm child running.
# taskkill /T kills the real process tree. Works on Windows PowerShell
# 5.1 -- no pwsh-only primitives.
function _Invoke-NativeWithTimeout(
[string]$exePath, [string]$argLine, [string]$workDir,
[string]$logPath, [int]$timeoutSec
) {
$cmdLine = "/d /s /c "" ""$exePath"" $argLine > ""$logPath"" 2>&1 """
$proc = Start-Process -FilePath $env:ComSpec -ArgumentList $cmdLine `
-WorkingDirectory $workDir -NoNewWindow -PassThru
$deadline = [DateTime]::UtcNow.AddSeconds($timeoutSec)
$shown = 0
function _Drain-NewLines([string]$path, [ref]$count) {
$lines = @(Get-Content $path -ErrorAction SilentlyContinue)
if ($lines.Count -gt $count.Value) {
$lines[$count.Value..($lines.Count - 1)] | ForEach-Object {
Write-Host " $_" -ForegroundColor DarkGray
}
$count.Value = $lines.Count
}
}
while (-not $proc.HasExited) {
if ([DateTime]::UtcNow -gt $deadline) {
& taskkill /T /F /PID $proc.Id 2>&1 | Out-Null
return 124
}
Start-Sleep -Milliseconds 750
_Drain-NewLines $logPath ([ref]$shown)
}
_Drain-NewLines $logPath ([ref]$shown)
return $proc.ExitCode
}
# Helper: run "npm install" in a given directory and surface the real
# error when it fails. Returns $true on success.
function _Run-NpmInstall([string]$label, [string]$installDir, [string]$logPath, [string]$npmPath) {
Push-Location $installDir
# Capture EAP outside the try block so the catch's restore call always
# has a meaningful value (see Install-Uv for the full rationale).
$prevEAP = $ErrorActionPreference
try {
# The helper streams npm's output to BOTH the console and the log
# file, so the user watches real progress instead of a frozen
# "Installing..." line (on a fresh VM the install is 1-3 minutes;
# total silence is indistinguishable from a hang) -- and the
# wall-clock ceiling turns a genuinely stalled install (#76222
# class) into a diagnosable failure instead of an overnight freeze.
#
# Relax EAP around the invocation: with EAP=Stop (set at the top
# of this script), PowerShell can wrap stray stderr from the
# launcher plumbing as ErrorRecord objects and throw even though
# npm exited 0. This is the same issue Test-Python and Install-Uv
# work around for uv's stderr-emitting installer. Check success
# via the returned exit code, which is reliable regardless of
# stderr noise.
$ErrorActionPreference = "Continue"
$code = _Invoke-NativeWithTimeout $npmPath "install --silent" `
$installDir $logPath $nodeDepsTimeoutSec
$ErrorActionPreference = $prevEAP
if ($code -eq 0) {
Write-Success "$label dependencies installed"
Remove-Item -Force $logPath -ErrorAction SilentlyContinue
return $true
}
if ($code -eq 124) {
Write-Warn "$label npm install timed out after $([math]::Round($nodeDepsTimeoutSec / 60)) minutes -- a stalled download, wedged extraction, or file lock is the usual cause."
Write-Info " Re-run the installer to retry (completed stages are skipped)."
Write-Info " Slow connection? Raise the ceiling: set NODE_DEPS_TIMEOUT to seconds (default 600)."
} else {
Write-Warn "$label npm install failed -- exit code $code"
}
if (Test-Path $logPath) {
$errText = (Get-Content $logPath -Raw -ErrorAction SilentlyContinue)
if ($errText) {
$snippet = if ($errText.Length -gt 1200) { $errText.Substring(0, 1200) + "..." } else { $errText }
Write-Info " npm output:"
foreach ($line in $snippet -split "`n") {
Write-Host " $line" -ForegroundColor DarkGray
}
Write-Info " Full log: $logPath"
Show-NpmCertHint $errText | Out-Null
Write-NpmDebugLogTail -NpmOutput $errText
}
}
Write-Info "Run manually later: cd `"$installDir`"; npm install"
return $false
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Warn "$label npm install could not be launched: $_"
return $false
} finally {
Pop-Location
}
}
# Browser tools
if (Test-Path "$InstallDir\package.json") {
Write-Info "Installing Node.js dependencies (browser tools)..."
$browserLog = "$env:TEMP\hermes-npm-browser-$(Get-Random).log"
$browserNpmOk = _Run-NpmInstall "Browser tools" $InstallDir $browserLog $npmExe
# Install Playwright Chromium (mirrors scripts/install.sh behaviour for
# Linux). Without this, tools/browser_tool.py::check_browser_requirements
# returns False (no Chromium under %LOCALAPPDATA%\ms-playwright), and the
# browser_* tools are silently filtered out of the agent's tool schema.
# System Chrome at "C:\Program Files\Google\Chrome\..." is NOT used by
# agent-browser -- it expects a Playwright-managed Chromium.
if ($browserNpmOk) {
Write-Info "Installing browser engine (Playwright Chromium)..."
# npx lives next to npm in the same bin dir. Prefer .cmd to dodge
# the same execution-policy gotcha that affects npm.ps1 (see above).
$npmDir = Split-Path $npmExe -Parent
$npxExe = $null
foreach ($cand in @("npx.cmd", "npx.exe", "npx")) {
$try = Join-Path $npmDir $cand
if (Test-Path $try) { $npxExe = $try; break }
}
if (-not $npxExe) {
$npxCmd = Get-Command npx -ErrorAction SilentlyContinue
if ($npxCmd) { $npxExe = $npxCmd.Source }
}
if (-not $npxExe) {
Write-Warn "npx not found -- cannot install Playwright Chromium."
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
} else {
$pwLog = "$env:TEMP\hermes-playwright-install-$(Get-Random).log"
Push-Location $InstallDir
# Capture EAP outside the try block so the catch's restore call
# always has a meaningful value (see Install-Uv for the full
# rationale).
$prevEAP = $ErrorActionPreference
try {
# Playwright Chromium is ~170MB compressed and the
# download regularly takes 3-10 minutes on a fresh
# VM. Tee the output to console + log so the user
# sees download progress in real time instead of
# staring at a silent prompt that looks hung. See
# _Run-NpmInstall above for the same pattern and
# the rationale behind 2>&1 before the pipe.
Write-Info "(this can take several minutes -- streaming progress below)"
# --yes auto-accepts npx's "Need to install playwright@X.Y.Z"
# confirmation prompt. Without it, npx 7+ blocks on stdin
# waiting for a y/N answer that never comes when this is
# invoked through a pipeline (Tee-Object disconnects stdin
# from the user's TTY), and the install hangs indefinitely
# after printing "Need to install the following packages:
# playwright@X.Y.Z".
#
# Relax EAP around the playwright invocation: playwright
# emits a "Chromium downloaded to ..." success banner to
# stderr after a successful install. The launcher merges
# stderr into the log natively, but keep EAP relaxed so
# stray plumbing stderr can't fire the catch block with a
# mangled banner even though the install succeeded. Check
# the returned exit code instead, which is the reliable
# signal.
#
# The wall-clock ceiling is the #76222 / #84614 fix: the
# Chromium download reaches 100% and the extraction wedges
# (or the registry fetch stalls), and without a bound the
# installer sits on this line forever. bash has carried
# the same 600s guard via run_playwright_install since
# #39219.
$ErrorActionPreference = "Continue"
$pwCode = _Invoke-NativeWithTimeout $npxExe "--yes playwright install chromium" `
$InstallDir $pwLog $nodeDepsTimeoutSec
$ErrorActionPreference = $prevEAP
if ($pwCode -eq 0) {
Write-Success "Playwright Chromium installed (browser tools ready)"
Remove-Item -Force $pwLog -ErrorAction SilentlyContinue
} elseif ($pwCode -eq 124) {
Write-Warn "Playwright Chromium install timed out after $([math]::Round($nodeDepsTimeoutSec / 60)) minutes."
Write-Warn "This usually means a stalled download or a wedged archive extraction (a locked previous browser version can also cause it)."
Write-Warn "Browser tools will not work until Chromium is installed."
if (Test-Path $pwLog) { Write-Info " Partial log: $pwLog" }
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
} else {
Write-Warn "Playwright Chromium install failed -- exit code $pwCode"
Write-Warn "Browser tools will not work until Chromium is installed."
if (Test-Path $pwLog) {
$pwErr = Get-Content $pwLog -Raw -ErrorAction SilentlyContinue
if ($pwErr) {
$snippet = if ($pwErr.Length -gt 1200) { $pwErr.Substring(0, 1200) + "..." } else { $pwErr }
Write-Info " playwright output:"
foreach ($line in $snippet -split "`n") {
Write-Host " $line" -ForegroundColor DarkGray
}
Write-Info " Full log: $pwLog"
}
}
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
}
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Write-Warn "Playwright Chromium install could not be launched: $_"
Write-Info "Run manually later: cd `"$InstallDir`"; npx playwright install chromium"
} finally {
Pop-Location
}
}
}
}
# TUI
$tuiDir = "$InstallDir\ui-tui"
if (Test-Path "$tuiDir\package.json") {
Write-Info "Installing TUI dependencies..."
$tuiLog = "$env:TEMP\hermes-npm-tui-$(Get-Random).log"
[void](_Run-NpmInstall "TUI" $tuiDir $tuiLog $npmExe)
}
Install-BrowserUseCli
Install-CuaDriver
}
# The Browser Use CLI is the default browser backend when it is runnable
# (tools/browser_use_cli.py). Provision it at install time so fresh installs
# don't silently fall back to the built-in browser tools. Best-effort: any
# failure is non-fatal (browser_exec can still run via uvx, and `hermes tools`
# can install it later).
function Install-BrowserUseCli {
if (-not $script:UvCmd) { Resolve-UvCmd }
if (-not $script:UvCmd) {
Write-Info "Skipping Browser Use CLI install (uv unavailable)"
return
}
$managedBin = Join-Path $HermesHome "bin"
$managedBu = Join-Path $managedBin "browser-use.exe"
# MANAGED-FIRST: only Hermes' managed copy short-circuits. A browser-use
# on the user's PATH is a side install -- resolution prefers the managed
# copy, so it must be provisioned regardless.
if (Test-Path $managedBu) {
Write-Success "Browser Use CLI already installed"
return
}
Write-Info "Installing Browser Use CLI (default browser backend)..."
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
# UV_TOOL_BIN_DIR keeps the binary inside Hermes' managed bin dir,
# where the browser tool resolves it -- no reliance on the user PATH.
$env:UV_TOOL_BIN_DIR = $managedBin
$env:UV_NO_CONFIG = "1"
& $script:UvCmd tool install browser-use 2>&1 | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Success "Browser Use CLI installed"
} else {
Write-Warn "Browser Use CLI install failed (exit $LASTEXITCODE) -- browser automation falls back to built-in tools."
Write-Info "Install later with: uv tool install browser-use (or via 'hermes tools')"
}
} catch {
Write-Warn "Browser Use CLI install failed: $_"
} finally {
$ErrorActionPreference = $prevEAP
Remove-Item Env:\UV_TOOL_BIN_DIR -ErrorAction SilentlyContinue
Remove-Item Env:\UV_NO_CONFIG -ErrorAction SilentlyContinue
}
}
function Test-CuaDriverRuntimeContract {
param([Parameter(Mandatory = $true)][string]$DriverPath)
try {
$versionOutput = (& $DriverPath --version 2>$null | Out-String).Trim()
if ($LASTEXITCODE -ne 0) {
return $false
}
$versionMatch = [regex]::Match($versionOutput, '(\d+\.\d+\.\d+)')
if (-not $versionMatch.Success) {
return $false
}
if ([version]($versionMatch.Groups[1].Value) -lt [version]'0.20.0') {
return $false
}
$manifestOutput = (& $DriverPath manifest 2>$null | Out-String).Trim()
if ($LASTEXITCODE -ne 0 -or -not $manifestOutput) {
return $false
}
$manifest = $manifestOutput | ConvertFrom-Json
if (-not $manifest.mcp_invocation.args) {
return $false
}
$required = @{
mcp = @('--socket', '--grant')
serve = @(
'--socket', '--permission-mode', '--capability-manifest',
'--approve-capability-manifest', '--embedded'
)
stop = @('--socket')
}
foreach ($commandName in $required.Keys) {
$command = $manifest.subcommands | Where-Object { $_.name -eq $commandName }
if (-not $command) {
return $false
}
$argNames = @($command.args | ForEach-Object { $_.name })
foreach ($requiredArg in $required[$commandName]) {
if ($requiredArg -notin $argNames) {
return $false
}
}
}
return $true
} catch {
return $false
}
}
# cua-driver powers the computer_use toolset (background desktop control).
# Provision it at install time so enabling the tool later -- via `hermes
# tools`, the dashboard, or the desktop app -- is a config flip, not a
# surprise multi-minute binary fetch. Best-effort and non-fatal: the enable
# paths still lazy-install via install_cua_driver() (hermes_cli/tools_config)
# when this step was skipped or failed.
function Install-CuaDriver {
if ($SkipComputerUse) {
Write-Info "Skipping Computer Use (cua-driver) install (-SkipComputerUse)"
return
}
$existingCuaDriver = Get-Command cua-driver -ErrorAction SilentlyContinue
if ($existingCuaDriver) {
if (Test-CuaDriverRuntimeContract -DriverPath $existingCuaDriver.Source) {
Write-Success "Computer Use driver (cua-driver) already installed and compatible"
return
}
Write-Warn "Existing cua-driver is old or incomplete; repairing it"
}
Write-Info "Installing Computer Use driver (cua-driver)..."
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "Continue"
try {
# Same upstream installer `hermes computer-use install` runs. Bounded
# via a background job: the upstream installer serializes with its own
# lock (600s stale window), so the ceiling sits above that -- matching
# Hermes' _CUA_INSTALLER_TIMEOUT (660s).
$job = Start-Job -ScriptBlock {
Invoke-RestMethod -UseBasicParsing "https://raw.githubusercontent.com/trycua/cua/main/libs/cua-driver/scripts/install.ps1" | Invoke-Expression
}
if (Wait-Job $job -Timeout 660) {
Receive-Job $job -ErrorAction SilentlyContinue | Out-Null
Remove-Job $job -Force -ErrorAction SilentlyContinue
$installedCuaDriver = Get-Command cua-driver -ErrorAction SilentlyContinue
if ($installedCuaDriver -and (Test-CuaDriverRuntimeContract -DriverPath $installedCuaDriver.Source)) {
Write-Success "Computer Use driver installed (enable via 'hermes tools' -> Computer Use)"
} else {
Write-Warn "Computer Use driver install did not produce a compatible runtime -- repair it before enabling the tool."
Write-Info "Install later with: hermes computer-use install"
}
} else {
Stop-Job $job -ErrorAction SilentlyContinue
Remove-Job $job -Force -ErrorAction SilentlyContinue
Write-Warn "Computer Use driver install timed out -- it will install on demand when you enable the tool."
Write-Info "Install later with: hermes computer-use install"
}
} catch {
Write-Warn "Computer Use driver install failed: $_"
Write-Info "Install later with: hermes computer-use install"
} finally {
$ErrorActionPreference = $prevEAP
}
}
# Clear the cached Electron download + any half-written unpacked output so the
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
# the per-user Electron download cache - most often a partial download resumed
# into the same file, leaving concatenated junk - makes electron-builder's
# `app-builder unpack-electron` extract a tree MISSING the electron binary, so
# the final `electron` -> `Hermes` rename dies with ENOENT and every re-run
# repeats the broken extraction forever.
#
# We deliberately do not validate the zip ourselves: the common
# prepended/concatenated-junk corruption slips past naive checks, so a
# self-rolled gate would skip the real-world case. We unconditionally drop the
# cached electron-*.zip (loose copy and any @electron/get hash-subdir copy) plus
# the stale unpacked dir, then let the caller retry once - @electron/get
# re-downloads with its own SHASUM verification, the real source of truth.
#
# Returns the removed paths. Best-effort: never throws.
function Clear-ElectronBuildCache {
param([string]$DesktopDir)
$removed = @()
# Per-user Electron download cache dirs, honoring the overrides @electron/get
# respects, then the Windows default (%LOCALAPPDATA%\electron\Cache).
$cacheDirs = @()
if ($env:electron_config_cache) { $cacheDirs += $env:electron_config_cache }
if ($env:ELECTRON_CACHE) { $cacheDirs += $env:ELECTRON_CACHE }
if ($env:LOCALAPPDATA) { $cacheDirs += (Join-Path $env:LOCALAPPDATA 'electron\Cache') }
$cacheDirs += (Join-Path $HOME 'AppData\Local\electron\Cache')
foreach ($dir in $cacheDirs) {
if (-not (Test-Path -LiteralPath $dir)) { continue }
# Recurse: the bad copy may be the top-level zip OR a copy inside an
# @electron/get hash subdir.
$removed += @(Get-ChildItem -LiteralPath $dir -Recurse -Filter 'electron-*.zip' -File -ErrorAction SilentlyContinue | ForEach-Object {
try { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop; $_.FullName } catch { }
})
}
# A half-written unpacked dir from an interrupted prior pack poisons the
# rename even after the zip is fixed (win-unpacked / win-arm64-unpacked).
$releaseDir = Join-Path $DesktopDir 'release'
if (Test-Path -LiteralPath $releaseDir) {
$removed += @(Get-ChildItem -LiteralPath $releaseDir -Directory -Filter '*-unpacked' -ErrorAction SilentlyContinue | ForEach-Object {
try { Remove-Item -LiteralPath $_.FullName -Recurse -Force -ErrorAction Stop; $_.FullName } catch { }
})
}
return $removed
}
# Last-resort Electron mirror after GitHub download fails (#47266).
$script:DesktopElectronFallbackMirror = "https://npmmirror.com/mirrors/electron/"
# Electron package dir -- workspace-local nest first, then root hoist.
function Get-ElectronDir {
param([string]$InstallDir)
$desktopLocal = Join-Path $InstallDir 'apps\desktop\node_modules\electron'
if (Test-Path -LiteralPath $desktopLocal) { return $desktopLocal }
return (Join-Path $InstallDir 'node_modules\electron')
}
# True when dist/ holds a usable Electron binary (#38673 / run-electron-builder.mjs).
function Test-ElectronDist {
param([string]$InstallDir)
$electronDir = Get-ElectronDir -InstallDir $InstallDir
$distExe = Join-Path $electronDir 'dist\electron.exe'
return (Test-Path -LiteralPath $distExe)
}
# Best-effort: run electron/install.js to populate dist/ (optional mirror).
function Restore-ElectronDist {
param([string]$InstallDir, [string]$Mirror)
if (Test-ElectronDist -InstallDir $InstallDir) { return $true }
$electronDir = Get-ElectronDir -InstallDir $InstallDir
$distExe = Join-Path $electronDir 'dist\electron.exe'
$installer = Join-Path $electronDir 'install.js'
if (-not (Test-Path -LiteralPath $installer)) { return $false }
$node = Get-Command node -ErrorAction SilentlyContinue
if (-not $node) { return $false }
$distDir = Join-Path $electronDir 'dist'
if (Test-Path -LiteralPath $distDir) {
Remove-Item -LiteralPath $distDir -Recurse -Force -ErrorAction SilentlyContinue
}
Remove-Item -LiteralPath (Join-Path $electronDir 'path.txt') -Force -ErrorAction SilentlyContinue
$prevMirror = $env:ELECTRON_MIRROR
if ($Mirror) { $env:ELECTRON_MIRROR = $Mirror }
try {
# Out-Host so the downloader's progress shows on the console WITHOUT
# leaking into this function's return value (PowerShell returns every
# object left on the output stream, so a bare pipe here would make the
# boolean below ambiguous).
& $node.Source $installer 2>&1 | ForEach-Object { "$_" } | Out-Host
} catch {
} finally {
$env:ELECTRON_MIRROR = $prevMirror
}
return (Test-Path -LiteralPath $distExe)
}
function Test-ElectronPkgStagedMissingDist {
param([string]$InstallDir)
$electronDir = Get-ElectronDir -InstallDir $InstallDir
return (
(Test-Path -LiteralPath (Join-Path $electronDir 'package.json')) -and
(Test-Path -LiteralPath (Join-Path $electronDir 'install.js')) -and
(-not (Test-ElectronDist -InstallDir $InstallDir))
)
}
function Try-RestoreElectronDist {
param([string]$InstallDir)
if (Restore-ElectronDist -InstallDir $InstallDir) { return $true }
if ($env:ELECTRON_MIRROR) { return $false }
return Restore-ElectronDist -InstallDir $InstallDir -Mirror $script:DesktopElectronFallbackMirror
}
function Install-DesktopVoiceDeps {
# Desktop ships with working voice out of the box: eagerly install the
# wake-word + local-STT stacks ([wake] + [voice] extras) instead of
# leaving them to lazy first-use install. Policy change (Teknium, July
# 2026, #70509 testing): the first ear-click used to trigger a
# multi-minute onnxruntime pip install that froze the UI and blew RPC
# timeouts. Best-effort -- lazy install remains the fallback for anything
# this step fails to fetch.
if (-not $script:UvCmd) { Resolve-UvCmd }
if (-not $script:UvCmd) {
Write-Warn "uv unavailable -- voice/wake deps will lazy-install at first use instead"
return
}
$env:VIRTUAL_ENV = "$InstallDir\venv"
Write-Info "Installing voice + wake-word dependencies (onnxruntime, faster-whisper -- 1-3min)..."
Push-Location $InstallDir
try {
Invoke-NativeWithRelaxedErrorAction { & $UvCmd pip install -e ".[wake,voice]" }
if ($LASTEXITCODE -eq 0) {
Write-Success "Voice + wake-word dependencies installed"
} else {
Write-Warn "Voice/wake dependency install failed (exit $LASTEXITCODE) -- they will lazy-install at first use"
}
} finally {
Pop-Location
}
}
function Install-Desktop {
# Build apps/desktop into a launchable Hermes.exe. Only called from
# Stage-Desktop, which is itself only included in the manifest when
# -IncludeDesktop was passed to install.ps1.
#
# The workspace npm install at repo root (done by Install-NodeDeps for
# browser tools) does NOT pull apps/desktop's dependencies, because the
# browser-tools workspace at $InstallDir\package.json is a separate
# workspace from apps/*. We do a full root-level `npm install` here
# so the workspace resolves apps/desktop's deps (including Electron
# itself, ~150MB), then run `npm run pack` in apps/desktop which
# produces the unpacked binary at apps/desktop/release/<os>-unpacked/.
#
# The Tauri bootstrap installer's launch_hermes_desktop command
# resolves apps/desktop/release/win-unpacked/Hermes.exe directly,
# so an "unpacked" build (electron-builder --dir) is enough -- we
# don't need to produce an NSIS/MSI artifact here.
# Always re-resolve Node here. Stages run in separate PowerShell processes,
# so $script:HasNode from Stage-Node isn't visible; more importantly Test-Node
# enforces the supported Node lines and prepends the Hermes-managed Node to
# PATH, so the build never runs on an unsupported system Node -- the cause
# of the opaque "Build desktop app ... exit code 1" failure (Vite crashes on
# old Node).
Test-Node | Out-Null
if (-not (Get-Command npm -ErrorAction SilentlyContinue)) {
Write-Warn "Skipping desktop build (Node.js / npm not on PATH)"
$script:_StageSkippedReason = "Node.js not available"
return
}
$desktopDir = "$InstallDir\apps\desktop"
if (-not (Test-Path "$desktopDir\package.json")) {
Write-Warn "Skipping desktop build (apps/desktop not present in checkout)"
$script:_StageSkippedReason = "apps/desktop not present"
return
}
$npmCmd = Get-Command npm -ErrorAction SilentlyContinue
if (-not $npmCmd) {
Write-Warn "Skipping desktop build (npm not on PATH)"
$script:_StageSkippedReason = "npm not found"
return
}
$npmExe = $npmCmd.Source
if ($npmExe -like "*.ps1") {
$sibling = Join-Path (Split-Path $npmExe -Parent) "npm.cmd"
if (Test-Path $sibling) { $npmExe = $sibling }
}
# 1. Workspace-level install so apps/desktop's deps (Electron, Vite,
# node-pty prebuilds, etc.) actually land in node_modules. This is
# the SAME `npm install` Install-NodeDeps does for browser tools,
# but at the root rather than the browser-tools workspace, so all
# apps/* workspaces resolve.
Write-Info "Installing desktop workspace dependencies (this includes Electron ~150MB, takes 1-3min)..."
Push-Location $InstallDir
$prevEAP = $ErrorActionPreference
try {
$ErrorActionPreference = "Continue"
# Drop --silent so npm emits its full progress + error trail.
# When this fails on a non-dev box (e.g. native-module build
# without VS Build Tools, ETARGET on a transitive, etc.), the
# actual reason needs to reach the Tauri installer's log; with
# --silent it was completely suppressed and the user just saw
# "exit 1" with no actionable detail.
#
# The streaming sink in bootstrap.rs's run_install_script
# captures every stdout/stderr line as it's emitted, so we don't
# need a side TEMP log file -- the installer's bootstrap log
# IS the artifact a support engineer reads.
#
# Prefer `npm ci`: it wipes node_modules and reinstalls from the
# lockfile, always producing a complete tree. Bare `npm install`
# can report "up to date" against a stale
# node_modules\.package-lock.json marker while node_modules is
# actually empty (Windows workspace-hoisting flake), leaving
# tsc/typescript unresolved so `npm run pack`'s `tsc -b` dies with
# no obvious cause. Fall back to `npm install` only if `npm ci`
# fails (lockfile out of sync / very old npm without ci).
#
# Tee the merged output into $npmOut while still emitting every line
# live. We don't need a side log file (the bootstrap streaming sink
# is the artifact), but on failure we scan $npmOut for the TLS-trust
# signature so corporate-proxy users get the NODE_EXTRA_CA_CERTS hint
# instead of an opaque "exit 1" (issue #38016).
& $npmExe ci 2>&1 | ForEach-Object { "$_" } | Tee-Object -Variable npmOut
$code = $LASTEXITCODE
if ($code -ne 0) {
Write-Info " npm ci failed (exit $code) -- retrying with npm install..."
& $npmExe install 2>&1 | ForEach-Object { "$_" } | Tee-Object -Variable npmOut
$code = $LASTEXITCODE
}
$ErrorActionPreference = $prevEAP
if ($code -ne 0) {
if (Test-ElectronPkgStagedMissingDist -InstallDir $InstallDir) {
Write-Warn "Desktop dependency install failed with a missing Electron dist; attempting self-heal..."
Try-RestoreElectronDist -InstallDir $InstallDir | Out-Null
} else {
Show-NpmCertHint ($npmOut -join "`n") | Out-Null
# Replay npm's own debug log into our stream: the terse
# summary above rarely contains the postinstall stderr
# (e.g. Electron's install.js) that explains the failure.
Write-NpmDebugLogTail -NpmOutput ($npmOut -join "`n")
throw "desktop workspace npm install failed (exit $code) -- see lines above for cause"
}
} else {
Write-Success "Desktop workspace dependencies installed"
}
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Pop-Location
throw
}
Pop-Location
# 2. Build apps/desktop. `npm run pack` runs:
# assert-root-install + write-build-stamp + stage-native-deps +
# tsc -b + vite build + electron-builder --dir
# The --dir mode produces an unpacked Hermes.exe in
# apps/desktop/release/win-unpacked/ without bundling NSIS/MSI;
# we don't need a distributable installer artifact, just a
# launchable binary the Tauri installer can spawn.
#
# CSC_IDENTITY_AUTO_DISCOVERY=false tells electron-builder we are
# NOT signing the output. Combined with signAndEditExecutable=false in
# apps/desktop/package.json's build.win block, electron-builder never
# invokes signtool and therefore never fetches/extracts winCodeSign
# (whose macOS symlinks crash 7-Zip on non-admin Windows -- a dead end we
# are NOT trying to work around). The Hermes icon + product name are
# stamped onto Hermes.exe by our own rcedit step (Set-DesktopExeIdentity)
# AFTER this build, completely decoupled from electron-builder signing.
#
# WIN_CSC_LINK and WIN_CSC_KEY_PASSWORD explicitly cleared as
# belt-and-suspenders: if the user's environment has them set
# for some other tool, electron-builder would still try to sign.
Write-Info "Building desktop app (this takes 1-3 minutes)..."
$buildLog = "$env:TEMP\hermes-desktop-build-$(Get-Random).log"
# Seed GITHUB_SHA for write-build-stamp.mjs. The stamp prefers CI env vars
# over `git rev-parse`, so this covers: (1) node can't find git.exe on PATH
# even though this PowerShell session can, (2) ZIP/init trees that still
# lack a HEAD after a failed post-extract fetch. Without it the desktop
# pack dies with "could not determine git commit" (#50823).
if (-not $env:GITHUB_SHA) {
if ($Commit) {
$env:GITHUB_SHA = $Commit
} else {
Push-Location $InstallDir
try {
$global:LASTEXITCODE = 0
$resolvedSha = & git -c windows.appendAtomically=false rev-parse HEAD 2>$null
if ($LASTEXITCODE -ne 0 -or -not $resolvedSha) {
# ZIP path may have FETCH_HEAD after a fetch even when HEAD is unset.
$global:LASTEXITCODE = 0
$resolvedSha = & git -c windows.appendAtomically=false rev-parse FETCH_HEAD 2>$null
}
if ($LASTEXITCODE -eq 0 -and $resolvedSha) {
$env:GITHUB_SHA = ("$resolvedSha").Trim()
}
} catch { } finally {
Pop-Location
}
}
}
if (-not $env:GITHUB_REF_NAME) {
$env:GITHUB_REF_NAME = if ($Branch) { $Branch } else { "main" }
}
if ($env:GITHUB_SHA) {
$shaPreview = if ($env:GITHUB_SHA.Length -ge 12) { $env:GITHUB_SHA.Substring(0, 12) } else { $env:GITHUB_SHA }
Write-Info "Desktop build stamp: $shaPreview ($($env:GITHUB_REF_NAME))"
} else {
Write-Warn "Could not resolve a git commit for the desktop stamp -- write-build-stamp will use its non-git fallback"
}
Push-Location $desktopDir
$prevEAP = $ErrorActionPreference
$prevCSCAuto = $env:CSC_IDENTITY_AUTO_DISCOVERY
$prevWinCscLink = $env:WIN_CSC_LINK
$prevWinCscKeyPassword = $env:WIN_CSC_KEY_PASSWORD
try {
$ErrorActionPreference = "Continue"
$env:CSC_IDENTITY_AUTO_DISCOVERY = "false"
$env:WIN_CSC_LINK = ""
$env:WIN_CSC_KEY_PASSWORD = ""
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
if ($code -ne 0) {
$purged = @()
$restored = $false
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
$purged = @(Clear-ElectronBuildCache -DesktopDir $desktopDir)
$restored = Restore-ElectronDist -InstallDir $InstallDir
}
if ($restored) {
Write-Warn "Desktop build failed - refreshed the Electron download, retrying once:"
foreach ($p in $purged) { Write-Info " - $p" }
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
}
}
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
$mirror = $script:DesktopElectronFallbackMirror
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
Write-Warn "Re-downloading Electron via a public mirror ($mirror), then rebuilding:"
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
if (-not (Test-ElectronDist -InstallDir $InstallDir)) {
Restore-ElectronDist -InstallDir $InstallDir -Mirror $mirror | Out-Null
}
$prevMirror = $env:ELECTRON_MIRROR
$env:ELECTRON_MIRROR = $mirror
try {
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
} finally {
$env:ELECTRON_MIRROR = $prevMirror
}
}
$ErrorActionPreference = $prevEAP
if ($code -ne 0) {
$errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue
if ($errText) {
$snippet = if ($errText.Length -gt 1800) { $errText.Substring(0, 1800) + "..." } else { $errText }
Write-Info " desktop build output:"
foreach ($line in $snippet -split "`n") { Write-Host " $line" -ForegroundColor DarkGray }
Write-Info " Full log: $buildLog"
}
# `npm run pack` failures (lifecycle script exits) also land in
# npm's debug log; replay it so the bootstrap log carries the
# full evidence even when $buildLog's tail cuts off the cause.
Write-NpmDebugLogTail -NpmOutput $errText
throw "apps/desktop build failed (exit $code)"
}
Write-Success "Desktop app built"
Remove-Item -LiteralPath $buildLog -Force -ErrorAction SilentlyContinue
} catch {
if ($prevEAP) { $ErrorActionPreference = $prevEAP }
Pop-Location
throw
} finally {
# Restore env to whatever the caller had -- don't leak our
# signing-off override into anything install.ps1 invokes later
# (Stage-PlatformSdks, etc.).
$env:CSC_IDENTITY_AUTO_DISCOVERY = $prevCSCAuto
$env:WIN_CSC_LINK = $prevWinCscLink
$env:WIN_CSC_KEY_PASSWORD = $prevWinCscKeyPassword
}
Pop-Location
# 3. Sanity-check the produced binary. Probe both arches so this works
# on x64 and arm64 build machines.
$exeCandidates = @(
"$desktopDir\release\win-unpacked\Hermes.exe",
"$desktopDir\release\win-arm64-unpacked\Hermes.exe"
)
$found = $false
$desktopExe = $null
foreach ($cand in $exeCandidates) {
if (Test-Path $cand) {
Write-Success "Desktop ready: $cand"
$desktopExe = $cand
$found = $true
break
}
}
if (-not $found) {
throw "Desktop build completed but no Hermes.exe was found under $desktopDir\release\*-unpacked\"
}
# 3b. The Hermes icon + identity are stamped onto Hermes.exe by the
# electron-builder `afterPack` hook (apps/desktop/scripts/after-pack.mjs)
# during `npm run pack` above -- for every build, so the installer's
# --update rebuild stays branded too. No separate stamp step needed here.
# electron-builder's own rcedit step stays disabled (signAndEditExecutable
# =false) because enabling it drags in signtool -> winCodeSign -> the
# unfixable symlink crash; the afterPack hook runs rcedit directly.
# 3c. Grant ALL APPLICATION PACKAGES (S-1-15-2-2) RX on the unpacked app
# directory. Chromium's GPU/renderer sandboxes CHECK-fail with
# 0x80000003 when this ACE is missing alongside orphan AppContainer
# SIDs under %LOCALAPPDATA% (electron/electron#51761, hermes-agent#38216).
# Best-effort -- never fail an otherwise-good install over ACL repair.
try {
$appDir = Split-Path -Parent $desktopExe
& icacls $appDir /grant "*S-1-15-2-2:(OI)(CI)(RX)" /T /C /Q | Out-Null
if ($LASTEXITCODE -eq 0) {
Write-Success "Granted AppContainer read access on $appDir"
} else {
Write-Warn "icacls AppContainer grant returned exit $LASTEXITCODE for $appDir"
}
} catch {
Write-Warn "Could not grant AppContainer ACL: $($_.Exception.Message)"
}
# 4. Create Start Menu + Desktop shortcuts pointing DIRECTLY at the packed
# Hermes.exe. We deliberately do NOT point them at `hermes desktop`: that
# command rebuilds (npm install + electron-builder) on every launch,
# which would cost minutes each time. The packed exe is the consumer --
# launching it directly is instant, and updates flow through the
# installer's --update path (which rebuilds once, then relaunches).
New-DesktopShortcuts -TargetExe $desktopExe
}
function New-DesktopShortcuts {
param([Parameter(Mandatory = $true)][string]$TargetExe)
# Best-effort: a shortcut failure must never fail an otherwise-good install.
try {
$shell = New-Object -ComObject WScript.Shell
$workDir = Split-Path -Parent $TargetExe
# Prefer the standalone icon.ico (shipped beside the exe via
# electron-builder extraResources -> resources/icon.ico) over the exe's
# embedded resource. An explicit .ico path is more stable across update
# cycles: pointing at "$TargetExe,0" makes Windows cache the icon it
# extracted from the exe at shortcut-creation time, and that cached
# bitmap can persist (showing the OLD/Electron icon) even after the exe
# is re-stamped on update. A dedicated .ico sidesteps that extraction.
$iconIco = Join-Path $workDir 'resources\icon.ico'
if (Test-Path $iconIco) {
$iconLocation = "$iconIco,0"
} else {
$iconLocation = "$TargetExe,0"
}
$targets = @(
(Join-Path ([Environment]::GetFolderPath('Programs')) 'Hermes.lnk'),
(Join-Path ([Environment]::GetFolderPath('Desktop')) 'Hermes.lnk')
)
foreach ($lnkPath in $targets) {
try {
$parent = Split-Path -Parent $lnkPath
if (-not (Test-Path $parent)) {
New-Item -ItemType Directory -Force -Path $parent | Out-Null
}
$sc = $shell.CreateShortcut($lnkPath)
$sc.TargetPath = $TargetExe
$sc.WorkingDirectory = $workDir
$sc.IconLocation = $iconLocation
$sc.Description = 'Hermes Agent'
$sc.Save()
Write-Success "Shortcut created: $lnkPath"
} catch {
Write-Warn "Could not create shortcut $lnkPath : $($_.Exception.Message)"
}
}
# Bust the Windows shell icon cache so the desktop/Start-Menu shortcut
# repaints with the (possibly newly-stamped) icon instead of a stale
# cached bitmap. Critical on the --update path: the exe was re-stamped
# with the Hermes icon, but without this the shortcut can keep drawing
# the old Electron icon until the user manually refreshes / reboots.
# Best-effort and silent -- never fail the install over a cosmetic cache.
try {
& ie4uinit.exe -show 2>$null
} catch {
# ie4uinit may be absent/renamed on some SKUs -- ignore.
}
} catch {
Write-Warn "Skipping shortcut creation: $($_.Exception.Message)"
}
}
function Install-PlatformSdks {
# Ensure messaging-platform SDKs matching tokens the user added to
# ~/.hermes/.env are importable. Two problems this solves:
#
# 1. The tiered `uv pip install` cascade above can fall through to a
# lower tier when the first fails (common when RL git deps choke),
# which silently skips some messaging SDKs from [messaging].
# 2. `uv` creates the venv without pip. If a messaging SDK ends up
# missing, the user can't `pip install python-telegram-bot` to
# recover -- pip simply isn't in their venv.
#
# Strategy: bootstrap pip via `python -m ensurepip` (idempotent), then
# for each token set in .env, verify the matching SDK imports. If not,
# run one targeted `pip install` as last-chance recovery. Keeps fresh
# Windows installs from hitting silent "python-telegram-bot not installed"
# at runtime.
if ($NoVenv) {
Write-Info "Skipping platform-SDK verification (-NoVenv: no venv to bootstrap)"
return
}
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
if (-not (Test-Path $pythonExe)) {
Write-Warn "Skipping platform-SDK verification: $pythonExe not found"
return
}
$envPath = "$HermesHome\.env"
if (-not (Test-Path $envPath)) { return }
$envLines = Get-Content $envPath -ErrorAction SilentlyContinue
# Map: env var set in .env -> (import name, pip spec matching [messaging] extra).
# Specs mirror pyproject.toml to avoid version drift.
$sdkMap = @(
@{ Var = "TELEGRAM_BOT_TOKEN"; Import = "telegram"; Spec = "python-telegram-bot[webhooks]>=22.6,<23" },
@{ Var = "DISCORD_BOT_TOKEN"; Import = "discord"; Spec = "discord.py[voice]>=2.7.1,<3" },
@{ Var = "SLACK_BOT_TOKEN"; Import = "slack_sdk"; Spec = "slack-sdk>=3.27.0,<4" },
@{ Var = "SLACK_APP_TOKEN"; Import = "slack_bolt";Spec = "slack-bolt>=1.18.0,<2" },
@{ Var = "WHATSAPP_ENABLED"; Import = "qrcode"; Spec = "qrcode>=7.0,<8" }
)
# Which tokens are actually set (not placeholder)?
$needed = @()
foreach ($sdk in $sdkMap) {
$match = $envLines | Where-Object {
$_ -match ("^" + [regex]::Escape($sdk.Var) + "=.+") `
-and $_ -notmatch "your-token-here" `
-and $_ -notmatch "^\s*#"
}
if ($match) { $needed += $sdk }
}
if ($needed.Count -eq 0) { return }
Write-Host ""
Write-Info "Verifying platform SDKs for tokens found in $envPath ..."
# Verify each SDK's import without triggering side-effect imports.
# Quirk: PowerShell wraps non-zero-exit native stderr as a
# NativeCommandError that prints even with `2>$null` / `*> $null`
# unless we set $ErrorActionPreference to SilentlyContinue for the
# span. Save + restore rather than nuking globally.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
try {
$missing = @()
foreach ($sdk in $needed) {
& $pythonExe -c "import $($sdk.Import)" 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
$missing += $sdk
Write-Warn " $($sdk.Import) NOT importable (needed for $($sdk.Var))"
} else {
Write-Success " $($sdk.Import) OK"
}
}
} finally {
$ErrorActionPreference = $prevEAP
}
if ($missing.Count -eq 0) { return }
# Bootstrap pip into the venv if it isn't there. `uv` creates venvs
# without pip; ensurepip is the stdlib-blessed way to add it.
$prevEAP = $ErrorActionPreference
$ErrorActionPreference = "SilentlyContinue"
try {
& $pythonExe -m pip --version 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Info "Bootstrapping pip into venv (uv doesn't ship pip)..."
& $pythonExe -m ensurepip --upgrade 2>&1 | Out-Null
if ($LASTEXITCODE -ne 0) {
Write-Warn "ensurepip failed -- can't auto-install missing SDKs."
Write-Info "Manual recovery: $UvCmd pip install `"$($missing[0].Spec)`""
return
}
}
foreach ($sdk in $missing) {
Write-Info " Installing $($sdk.Spec) ..."
& $pythonExe -m pip install $sdk.Spec 2>&1 | ForEach-Object { Write-Host " $_" }
if ($LASTEXITCODE -eq 0) {
Write-Success " Installed $($sdk.Import)"
} else {
Write-Warn " Failed to install $($sdk.Spec). Recover manually: $pythonExe -m pip install `"$($sdk.Spec)`""
}
}
} finally {
$ErrorActionPreference = $prevEAP
}
}
function Invoke-SetupWizard {
if ($SkipSetup) {
Write-Info "Skipping setup wizard (-SkipSetup)"
return
}
if ($NonInteractive) {
# The setup wizard prompts for API keys, model choice, persona, etc.
# Non-interactive callers (GUI installer) own that UX themselves; let
# them drive it after install.ps1 returns.
Write-Info "Skipping setup wizard (non-interactive). Configure via the GUI or 'hermes setup'."
return
}
Write-Host ""
Write-Info "Starting setup wizard..."
Write-Host ""
Push-Location $InstallDir
# Run hermes setup using the venv Python directly (no activation needed)
if (-not $NoVenv) {
& ".\venv\Scripts\python.exe" -m hermes_cli.main setup
} else {
python -m hermes_cli.main setup
}
Pop-Location
}
function Start-GatewayIfConfigured {
$envPath = "$HermesHome\.env"
if (-not (Test-Path $envPath)) { return }
$hasMessaging = $false
$content = Get-Content $envPath -ErrorAction SilentlyContinue
foreach ($var in @("TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "WHATSAPP_ENABLED")) {
$match = $content | Where-Object { $_ -match "^${var}=.+" -and $_ -notmatch "your-token-here" }
if ($match) { $hasMessaging = $true; break }
}
if (-not $hasMessaging) { return }
$hermesCmd = "$InstallDir\venv\Scripts\hermes.exe"
if (-not (Test-Path $hermesCmd)) {
$hermesCmd = "hermes"
}
# If WhatsApp is enabled but not yet paired, run foreground for QR scan
$whatsappEnabled = $content | Where-Object { $_ -match "^WHATSAPP_ENABLED=true" }
$whatsappSession = "$HermesHome\whatsapp\session\creds.json"
if ($whatsappEnabled -and -not (Test-Path $whatsappSession)) {
Write-Host ""
Write-Info "WhatsApp is enabled but not yet paired."
Write-Info "Running 'hermes whatsapp' to pair via QR code..."
Write-Host ""
# Non-interactive callers (GUI installer, CI) skip the QR-pair prompt;
# WhatsApp pairing requires a human looking at a phone camera, so the
# downstream UI is responsible for surfacing this when it makes sense.
if (-not $NonInteractive) {
$response = Read-Host "Pair WhatsApp now? [Y/n]"
if ($response -eq "" -or $response -match "^[Yy]") {
try {
& $hermesCmd whatsapp
} catch {
# Expected after pairing completes
}
}
} else {
Write-Info "Skipping WhatsApp pairing prompt (non-interactive)."
}
}
Write-Host ""
Write-Info "Messaging platform token detected!"
Write-Info "The gateway handles messaging platforms and cron job execution."
Write-Host ""
# In non-interactive mode the gateway lifecycle is the caller's problem
# (the GUI manages its own gateway process, CI doesn't want background
# services on the build agent, etc.). Treat it like the user declined.
if ($NonInteractive) {
Write-Info "Skipping gateway autostart prompt (non-interactive)."
Write-Info "Start the gateway later with: hermes gateway"
return
}
$response = Read-Host "Would you like to start the gateway now? [Y/n]"
if ($response -eq "" -or $response -match "^[Yy]") {
Write-Info "Starting gateway in background..."
try {
$logFile = "$HermesHome\logs\gateway.log"
Start-Process -FilePath $hermesCmd -ArgumentList "gateway" `
-RedirectStandardOutput $logFile `
-RedirectStandardError "$HermesHome\logs\gateway-error.log" `
-WindowStyle Hidden
Write-Success "Gateway started! Your bot is now online."
Write-Info "Logs: $logFile"
Write-Info "To stop: close the gateway process from Task Manager"
} catch {
Write-Warn "Failed to start gateway. Run manually: hermes gateway"
}
} else {
Write-Info "Skipped. Start the gateway later with: hermes gateway"
}
}
function Write-Completion {
Write-Host ""
Write-Host "+---------------------------------------------------------+" -ForegroundColor Green
Write-Host "| [OK] Installation Complete! |" -ForegroundColor Green
Write-Host "+---------------------------------------------------------+" -ForegroundColor Green
Write-Host ""
# Show file locations
Write-Host "* Your files:" -ForegroundColor Cyan
Write-Host ""
Write-Host " Config: " -NoNewline -ForegroundColor Yellow
Write-Host "$HermesHome\config.yaml"
Write-Host " API Keys: " -NoNewline -ForegroundColor Yellow
Write-Host "$HermesHome\.env"
Write-Host " Data: " -NoNewline -ForegroundColor Yellow
Write-Host "$HermesHome\cron\, sessions\, logs\"
Write-Host " Code: " -NoNewline -ForegroundColor Yellow
Write-Host "$HermesHome\hermes-agent\"
Write-Host ""
Write-Host "---------------------------------------------------------" -ForegroundColor Cyan
Write-Host ""
Write-Host "* Commands:" -ForegroundColor Cyan
Write-Host ""
Write-Host " hermes " -NoNewline -ForegroundColor Green
Write-Host "Start chatting"
Write-Host " hermes setup " -NoNewline -ForegroundColor Green
Write-Host "Configure API keys & settings"
Write-Host " hermes config " -NoNewline -ForegroundColor Green
Write-Host "View/edit configuration"
Write-Host " hermes config edit " -NoNewline -ForegroundColor Green
Write-Host "Open config in editor"
Write-Host " hermes gateway " -NoNewline -ForegroundColor Green
Write-Host "Start messaging gateway (Telegram, Discord, etc.)"
Write-Host " hermes update " -NoNewline -ForegroundColor Green
Write-Host "Update to latest version"
Write-Host ""
Write-Host "---------------------------------------------------------" -ForegroundColor Cyan
Write-Host ""
Write-Host "[*] Restart your terminal for PATH changes to take effect" -ForegroundColor Yellow
Write-Host ""
if (-not $HasNode) {
Write-Host "Note: Node.js could not be installed automatically." -ForegroundColor Yellow
Write-Host "Browser tools need Node.js. Install manually:" -ForegroundColor Yellow
Write-Host " https://nodejs.org/en/download/" -ForegroundColor Yellow
Write-Host ""
}
if (-not $HasRipgrep) {
Write-Host "Note: ripgrep (rg) was not installed. For faster file search:" -ForegroundColor Yellow
Write-Host " winget install BurntSushi.ripgrep.MSVC" -ForegroundColor Yellow
Write-Host ""
}
}
# ============================================================================
# Stage protocol
# ============================================================================
#
# install.ps1 supports a small, stable "stage protocol" that lets programmatic
# callers (the desktop GUI's onboarding wizard, CI, future install.sh, etc.)
# drive the install one step at a time and surface progress/errors with their
# own UI. CLI users running the canonical `irm | iex` one-liner never
# encounter this -- default invocation behaves exactly as before.
#
# Entry points:
#
# install.ps1 Interactive install (today's behavior).
# install.ps1 -ProtocolVersion Emit the protocol version integer.
# install.ps1 -Manifest Emit the stage manifest as JSON.
# install.ps1 -Stage <name> Run one stage and emit its result.
# install.ps1 -NonInteractive Disable all Read-Host prompts (also
# skips the setup wizard and the gateway
# autostart prompt). Can be combined
# with default invocation to do a full
# non-interactive install.
# install.ps1 -Json Emit machine-readable JSON instead of
# the human-readable success banner at
# the end of a full install.
#
# Manifest schema (the JSON returned by -Manifest):
#
# {
# "protocol_version": 1,
# "stages": [
# {
# "name": "uv",
# "title": "Installing uv package manager",
# "category": "prereqs",
# "needs_user_input": false
# },
# ...
# ]
# }
#
# Stage result (the JSON written by -Stage <name>):
#
# {
# "stage": "uv",
# "ok": true,
# "skipped": false,
# "reason": null,
# "duration_ms": 1234
# }
#
# Exit codes:
#
# 0 -- success (stage ran, or stage was deliberately skipped).
# 1 -- generic failure; the stage threw.
# 2 -- unknown stage name passed to -Stage.
#
# Adding a stage:
#
# 1. Append an entry to $InstallStages below.
# 2. Make sure the worker function it points at is idempotent and respects
# $NonInteractive when it has prompts. Add it before "configure"
# (the wizard) or "gateway" (autostart) if it should run unconditionally;
# after those if it's optional post-install glue.
# 3. Do NOT bump $InstallStageProtocolVersion -- adding stages is additive.
# Drivers iterate the manifest dynamically.
#
# ============================================================================
# Stage definitions -- the single source of truth. Each entry maps a stable
# stage name (the API contract drivers depend on) to the worker function that
# implements it. ``Title`` is what UIs show; ``Category`` lets UIs group
# stages; ``NeedsUserInput`` tells UIs "this stage prompts -- either skip it
# or arrange to provide answers another way."
$InstallStages = @(
@{ Name = "uv"; Title = "Installing uv package manager"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Uv" }
@{ Name = "git"; Title = "Installing Git"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Git" }
@{ Name = "node"; Title = "Detecting Node.js"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Node" }
@{ Name = "system-packages"; Title = "Installing ripgrep and ffmpeg"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-SystemPackages" }
@{ Name = "repository"; Title = "Cloning Hermes repository"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Repository" }
# Managed Python lives under $InstallDir\.hermes-runtime, so the checkout
# must exist before this stage creates that directory. Otherwise the later
# repository stage treats the runtime-only directory as a broken checkout,
# parks it, and leaves Stage-Venv with no managed interpreter.
@{ Name = "python"; Title = "Verifying Python $PythonVersion"; Category = "prereqs"; NeedsUserInput = $false; Worker = "Stage-Python" }
@{ Name = "venv"; Title = "Creating Python virtual environment"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Venv" }
@{ Name = "dependencies"; Title = "Installing Python dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Dependencies" }
@{ Name = "node-deps"; Title = "Installing Node.js dependencies"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-NodeDeps" }
)
if ($IncludeDesktop) {
# Insert AFTER node-deps so workspace npm is already installed when
# the desktop build runs. Inserted only when explicitly requested
# (Hermes-Setup.exe), never via the irm|iex CLI one-liner.
$InstallStages += @{ Name = "desktop"; Title = "Building desktop app"; Category = "install"; NeedsUserInput = $false; Worker = "Stage-Desktop" }
}
$InstallStages += @(
@{ Name = "path"; Title = "Adding Hermes to PATH"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-Path" }
@{ Name = "config-templates"; Title = "Writing configuration templates"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-ConfigTemplates" }
@{ Name = "platform-sdks"; Title = "Installing messaging platform SDKs"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-PlatformSdks" }
@{ Name = "bootstrap-marker"; Title = "Marking install complete"; Category = "finalize"; NeedsUserInput = $false; Worker = "Stage-BootstrapMarker" }
# Interactive stages. In non-interactive mode these become no-ops; the
# caller (GUI / CI) handles the equivalent UX themselves.
@{ Name = "configure"; Title = "Configuring API keys and models"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Configure" }
@{ Name = "gateway"; Title = "Starting messaging gateway"; Category = "post-install"; NeedsUserInput = $true; Worker = "Stage-Gateway" }
)
# Stage workers -- thin wrappers that delegate to the existing Install-* /
# Test-* / Invoke-* functions while preserving their error semantics. Kept
# as a separate layer so the existing functions remain callable directly
# (helpful for one-off recovery: ``. install.ps1; Install-Venv``).
#
# Stages that depend on uv (anything after Stage-Uv) call Resolve-UvCmd
# first so they work in cross-process driver mode where $script:UvCmd
# set by Stage-Uv in a sibling powershell process is not visible here.
# Resolve-UvCmd is a fast no-op when $script:UvCmd is already populated
# (the default-invocation case where Main runs everything in one
# process), and throws cleanly if uv truly isn't installed yet.
function Stage-Uv { if (-not (Install-Uv)) { throw "uv installation failed" } }
function Stage-Python { Resolve-UvCmd; if (-not (Test-Python)) { throw "Python $PythonVersion not available" } }
function Stage-Git {
if (-not (Install-Git)) {
if ($script:GitInstallFailureReason) { throw $script:GitInstallFailureReason }
throw "Git not available and auto-install failed -- install from https://git-scm.com/download/win then re-run"
}
}
# Node is optional (browser tools degrade gracefully without it). Surface
# failure to the JSON contract as skipped=true / reason rather than ok=true,
# so a GUI driver consuming the manifest can distinguish "node ready" from
# "node missing". Install flow continues either way -- matches the
# existing Write-Completion behavior that prints a "Note: Node.js could
# not be installed" hint instead of aborting.
function Stage-Node {
if (-not (Test-Node)) {
$script:_StageSkippedReason = "Node.js not available; browser tools will be unavailable until node is installed manually from https://nodejs.org/en/download/"
}
}
function Stage-SystemPackages { Install-SystemPackages }
function Stage-Repository { Install-Repository }
function Stage-Venv { Resolve-UvCmd; Install-Venv }
function Stage-Dependencies { Resolve-UvCmd; Install-Dependencies }
function Stage-NodeDeps { Install-NodeDeps }
function Stage-Desktop { Install-DesktopVoiceDeps; Install-Desktop }
function Stage-Path { Set-PathVariable }
function Stage-ConfigTemplates { Copy-ConfigTemplates }
function Stage-PlatformSdks { Resolve-UvCmd; Install-PlatformSdks }
function Stage-BootstrapMarker { Write-BootstrapMarker }
function Stage-Configure { Invoke-SetupWizard }
function Stage-Gateway { Start-GatewayIfConfigured }
function Get-InstallStage {
param([string]$Name)
foreach ($s in $InstallStages) {
if ($s.Name -eq $Name) { return $s }
}
return $null
}
function Step-OutOfInstallDir {
# Windows refuses to delete a directory any shell is currently cd'd
# inside -- and silently leaves orphan files behind, which then wedge
# "is this a valid git repo" probes on re-install. Harmless when the
# caller ran the installer from somewhere else.
try {
$currentResolved = (Get-Location).ProviderPath
$installResolved = $null
if (Test-Path $InstallDir) {
$installResolved = (Resolve-Path $InstallDir -ErrorAction SilentlyContinue).ProviderPath
}
if ($installResolved -and $currentResolved.ToLower().StartsWith($installResolved.ToLower())) {
Write-Info "Stepping out of $InstallDir so Windows can replace files there if needed..."
Set-Location $env:USERPROFILE
}
} catch {}
}
function Invoke-Stage {
param(
[Parameter(Mandatory=$true)] [hashtable]$StageDef
)
# Refresh PATH from registry so this stage sees binaries installed by
# prior stages, even when each stage runs in its own powershell process.
# No-op in cost-relevant cases (default invocation path syncs once per
# foreach pass; cross-process drivers get the necessary freshening).
Sync-EnvPath
# Per-stage soft-skip channel. A worker can populate
# $script:_StageSkippedReason to surface "ran, but the thing it was
# supposed to set up is not available" as skipped=true in the JSON
# frame, without throwing. Used by Stage-Node so the install flow
# doesn't abort when an optional capability is missing while still
# being honest in the protocol contract. Reset before each stage so
# a prior stage's reason can never leak into a later stage's frame.
$script:_StageSkippedReason = $null
$start = [DateTime]::UtcNow
$result = @{
stage = $StageDef.Name
ok = $false
skipped = $false
reason = $null
duration_ms = 0
}
try {
& $StageDef.Worker
$result.ok = $true
if ($script:_StageSkippedReason) {
$result.skipped = $true
$result.reason = $script:_StageSkippedReason
}
} catch {
$result.ok = $false
$result.reason = "$_"
throw
} finally {
$result.duration_ms = [int]([DateTime]::UtcNow - $start).TotalMilliseconds
if ($Json -or $Stage) {
# In stage-driver mode every stage emits a JSON line so the
# caller can stream progress. In default interactive mode we
# stay silent here (the worker already wrote human output).
$result | ConvertTo-Json -Compress | Write-Output
# Tell the entry-point catch that we've already emitted a
# frame for this failure (when $result.ok = $false), so it
# doesn't double-emit a second JSON object and break the
# one-line-per-stage contract the driver protocol promises.
if (-not $result.ok) {
$script:_StageEmittedErrorFrame = $true
}
}
}
}
# ============================================================================
# Main
# ============================================================================
function Invoke-AllStages {
Step-OutOfInstallDir
foreach ($s in $InstallStages) {
Invoke-Stage -StageDef $s
}
}
function Invoke-EnsureMode {
param([string]$Deps)
$depList = $Deps -split ","
foreach ($dep in $depList) {
$dep = $dep.Trim()
switch ($dep) {
"node" {
[void](Test-Node)
if (-not $script:HasNode) {
Write-Err "Node.js could not be installed"
exit 1
}
}
"browser" {
[void](Test-Node)
if ($script:HasNode) {
Install-AgentBrowser
} else {
Write-Err "Node.js is required for browser tools but could not be installed"
exit 1
}
}
"ripgrep" {
Write-Info "ripgrep: install manually on Windows (scoop install ripgrep)"
}
"ffmpeg" {
Write-Info "ffmpeg: install manually on Windows (scoop install ffmpeg)"
}
default {
Write-Err "Unknown dependency: $dep"
exit 1
}
}
}
}
function Invoke-PostInstallMode {
Write-Info "Running post-install setup..."
Invoke-EnsureMode -Deps "node,browser"
Write-Info "Post-install complete"
}
function Main {
Write-Banner
Invoke-AllStages
if (-not $Json) {
Write-Completion
} else {
@{ ok = $true; protocol_version = $InstallStageProtocolVersion } | ConvertTo-Json -Compress | Write-Output
}
}
# ----------------------------------------------------------------------------
# Entry-point dispatch
# ----------------------------------------------------------------------------
#
# All branches funnel through one try/catch so errors don't kill an `irm |
# iex` PowerShell session, and so failures in stage-driver mode produce a
# structured JSON error frame instead of a bare exception.
# Dot-sourcing loads the installer's real functions for isolated behavioral
# tests without running an install. Normal script and `irm | iex` entry points
# are unchanged.
if ($MyInvocation.InvocationName -eq ".") {
return
}
try {
if ($Ensure -ne "") {
if ($PSBoundParameters.ContainsKey("Stage")) {
Write-Err "Cannot use -Ensure and -Stage simultaneously"
exit 1
}
Invoke-EnsureMode -Deps $Ensure
exit 0
}
if ($PostInstall) {
Invoke-PostInstallMode
exit 0
}
if ($ProtocolVersion) {
Write-Output $InstallStageProtocolVersion
exit 0
}
if ($ShowResolvedPaths) {
$script:ResolvedPathReport | ConvertTo-Json -Depth 5 -Compress | Write-Output
exit 0
}
if ($Manifest) {
$payload = @{
protocol_version = $InstallStageProtocolVersion
stages = @($InstallStages | ForEach-Object {
@{
name = $_.Name
title = $_.Title
category = $_.Category
needs_user_input = $_.NeedsUserInput
}
})
}
$payload | ConvertTo-Json -Depth 5 -Compress | Write-Output
exit 0
}
# Use PSBoundParameters rather than $Stage truthiness so that an
# explicit `-Stage ""` from a misbehaving driver doesn't fall through
# to the full-install Main path and silently kick off a destructive
# operation. Empty string is a contract violation; surface it as
# unknown-stage exit 2 with a structured JSON frame.
if ($PSBoundParameters.ContainsKey("Stage")) {
$def = Get-InstallStage -Name $Stage
if (-not $def) {
$err = @{
ok = $false
stage = $Stage
reason = "unknown stage: $Stage. Run install.ps1 -Manifest to list valid stages."
}
$err | ConvertTo-Json -Compress | Write-Output
exit 2
}
Step-OutOfInstallDir
Invoke-Stage -StageDef $def
exit 0
}
# Default: full install (today's behavior, plus optional -NonInteractive
# and -Json layered on by the params above).
Main
} catch {
if ($Json -or $Stage) {
# Stage-driver mode: caller wants JSON they can parse. Emit a
# structured error frame and exit non-zero -- BUT only if
# Invoke-Stage didn't already emit one for this same failure.
# The inner finally emits the authoritative per-stage frame
# (with duration_ms + skipped fields); a second emit here
# would produce two concatenated JSON objects on stdout and
# break drivers that parse one-line-per-invocation.
if (-not $script:_StageEmittedErrorFrame) {
$err = @{
ok = $false
stage = if ($Stage) { $Stage } else { $null }
reason = "$_"
}
$err | ConvertTo-Json -Compress | Write-Output
}
exit 1
}
# Interactive mode: keep today's friendly recovery hint.
Write-Host ""
Write-Err "Installation failed: $_"
Write-Host ""
Write-Info "If the error is unclear, try downloading and running the script directly:"
Write-Host " Invoke-WebRequest -Uri 'https://hermes-agent.nousresearch.com/install.ps1' -OutFile install.ps1" -ForegroundColor Yellow
Write-Host " .\install.ps1" -ForegroundColor Yellow
Write-Host ""
}
|