diff --git a/CHANGELOG.md b/CHANGELOG.md index 43c7491..3ed8884 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,67 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.39] - 2026-08-03 + +### Changed + +- **Windows no longer ships an application.** 1Helm on Windows now runs the + ordinary Linux build inside a WSL 2 distribution and serves its interface to + the browser at `http://localhost:8123`. Install it with one command in an + ordinary PowerShell window: + + ```powershell + irm https://1helm.com/install.ps1 | iex + ``` + + There is no Electron host, no Squirrel installer, no `.exe`, and therefore + nothing to code-sign — so SmartScreen never appears. Windows publishes no + release artifacts; the desktop matrix is now three files (macOS DMG, macOS + updater ZIP, Linux archive), and the Linux archive serves both Linux and + Windows. +- Windows setup asks for administrator approval once, to enable the WSL 2 + optional features and install Microsoft's digest- and signature-verified WSL + package. Everything else — importing the distribution, installing 1Helm, + registering the keepalive — runs as the signed-in user, because WSL state is + per-user. +- Windows requires one restart partway through setup, which Windows itself + demands before WSL 2 becomes usable. Setup reports that as a restart with + numbered steps rather than a failure, and re-running the same command + continues from where it stopped. +- `#main`'s terminal on Windows is now bash inside the distribution rather than + `cmd.exe`, matching Linux. +- Removing 1Helm from Windows is `irm https://1helm.com/uninstall.ps1 | iex`. + +### Fixed + +- Windows file operations are roughly four times faster. Every channel storage + operation previously crossed the Windows-to-WSL boundary through + `wsl.exe`, costing a flat ~208 ms per call — measured at 281 ms versus 73 ms + for the same work without the crossing. Those crossings no longer exist, + because the server now runs inside the distribution. +- The Windows interface can no longer freeze. Those boundary crossings were + synchronous calls on the Electron main thread, which is the thread Windows + requires for its message pump, so a file listing could stall the window past + the five seconds after which Windows reports "not responding". There is no + longer a window to freeze: the browser waits on an HTTP request instead. +- Linux and Windows installs no longer build 1Helm on the target machine. The + release archive now ships production dependencies and prebuilt assets, with + native addons compiled against an older glibc and verified on arrival by + loading each one and checking its Node ABI. A cold install went from 8m49s to + 3m40s, and no C/C++ toolchain is installed on the host any more. +- The Linux installer no longer reports success when another process holds port + 8123. Its readiness check required only that something answered, which a + foreign listener satisfies; it now also requires the unit to be active, and + refuses to start when the port is already taken. +- A version mismatch between installer and archive failed silently after + several minutes of work. It now names both versions and states that nothing + was installed. +- The website no longer requires a Windows Setup executable, `.nupkg` and + `RELEASES` to exist before it will serve release metadata. That requirement + backed the endpoint the Linux installer resolves, so the first release + without those files would have broken the public Linux and Windows + installers simultaneously. + ## [0.0.38] - 2026-08-02 ### Fixed @@ -1038,6 +1099,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 Application Support, and isolated Apple container machines. [Unreleased]: https://github.com/gitcommit90/1Helm/compare/v0.0.36...HEAD +[0.0.39]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...v0.0.39 [0.0.38]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...v0.0.38 [0.0.37]: https://github.com/gitcommit90/1Helm/compare/v0.0.30...v0.0.37 [0.0.36]: https://github.com/gitcommit90/1Helm/compare/v0.0.35...v0.0.36 diff --git a/README.md b/README.md index b91ceb6..1b1cd7e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@

Download for Mac  ·  - for Windows + for Windows  ·  for Linux  ·  @@ -130,30 +130,74 @@ device displaying the web UI—to download and verify the signed update. ### Windows 11 x64 -1. [Download the current Setup executable](https://1helm.com/download/windows). -2. Windows Setup is **not yet Authenticode signed**, so SmartScreen shows - "Windows protected your PC". Choose **More info** → **Run anyway**. -3. Open 1Helm and complete Captain → Providers → Workspace. -4. At the Workspace step, 1Helm builds its WSL 2 runtime. **You do not need WSL - installed beforehand.** A stock Windows 11 ships with WSL and - VirtualMachinePlatform turned off, and 1Helm enables both itself: - - Approve the one administrator (UAC) prompt. - - A PowerShell window opens and reports progress. **Leave it open** until it - finishes. - - Because those Windows features were just turned on, **Windows has to - restart once.** 1Helm says so in plain language. This is the normal path - on a new PC — it is not an error, and nothing is lost. - - Restart, sign back in as the **same** Windows user, then open 1Helm and - continue setup. It picks up where it left off; already-completed steps are - skipped. - -Setup downloads Microsoft's pinned WSL 2 package and a pinned Ubuntu 24.04 root -filesystem, both SHA-256 verified, and the WSL package is additionally checked -for a valid Microsoft Authenticode signature. The result is one -installation-scoped WSL 2 runtime hosting one durable OCI container per ordinary -channel, with Windows-drive mounts and process interop disabled. App state lives -in `%APPDATA%\1Helm-OCI-v1`; the shared runtime disk lives in -`%LOCALAPPDATA%\1Helm-Runtime`. +There is nothing to download and no Windows application to install. 1Helm runs +its ordinary **Linux** build inside a WSL 2 distribution named `1helm`, and your +**browser is the interface**, at `http://localhost:8123`. Because no `.exe` +ships, nothing needs code signing and **SmartScreen never appears**. + +You need: Windows 11 **x64** (arm64 is not supported), virtualization enabled in +firmware, internet access, and roughly 10 GB of free disk. **You do not need to +install WSL first** — this does it for you. + +1. Open **PowerShell**. The normal one — do **not** use "Run as + Administrator". + +2. Run exactly this: + + ```powershell + irm https://1helm.com/install.ps1 | iex + ``` + +3. A Windows permission pop-up appears. Click **Yes**. That one approval covers + only turning on Windows' WSL features and installing Microsoft's own WSL + package; everything after it deliberately runs as you, because WSL state + belongs to the signed-in user. + +4. Wait about a minute. The window finishes by printing **"Restart required"** + and a short numbered list. **This is normal — it is not an error and nothing + is lost.** + +5. Restart the PC. + +6. Sign back in as the **same** Windows user and open PowerShell again. + +7. Run the **identical command** a second time: + + ```powershell + irm https://1helm.com/install.ps1 | iex + ``` + +8. Wait about six and a half minutes. Pages of `apt` output scroll past — that + is normal progress. When it is done it prints the address and opens your + default browser on the onboarding page. + +9. Complete Captain → Providers → Workspace in the browser. + +The whole thing takes about **9 minutes**, restart included. + +Two things you may see along the way: + +- Microsoft's own **"Welcome to WSL"** window may open during step 8. It belongs + to Microsoft, it is harmless, and you can close it. +- After 1Helm reports that it is running, the channel-computer runtime needs + roughly another **40 seconds** to finish preparing before your first channel + computer can be created. It has not hung. + +If you *download* `install.ps1` instead of piping it, Windows blocks running +`.ps1` files and you must run it as +`powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1`. The +`irm | iex` form above is unaffected, because it pipes a string rather than +executing a file. + +One behaviour difference from earlier versions: **`#main`'s Terminal is now bash +inside the WSL distribution, not `cmd.exe`.** Windows commands do not work +there. That is deliberate — the host is Linux now. + +To remove it later, from the same ordinary PowerShell window: + +```powershell +irm https://1helm.com/uninstall.ps1 | iex +``` ### Linux (Ubuntu/Debian, systemd) @@ -178,20 +222,26 @@ machines for safe deletion. Export irreplaceable channel files before you start. - **macOS** — drag 1Helm to the Trash. `~/Library/Application Support/1Helm-OCI-v1` is preserved unless you delete it yourself. -- **Windows** — uninstall from Settings → Apps → Installed apps. The uninstaller - removes 1Helm's own containers, unregisters its WSL runtime, and deletes - `%LOCALAPPDATA%\1Helm-Runtime`. +- **Windows** — from an ordinary PowerShell window, run + `irm https://1helm.com/uninstall.ps1 | iex`. It stops the keepalive, runs + 1Helm's own Linux uninstaller inside the distribution, then unregisters the + `1helm` distribution and deletes `C:\1helm` and the Start Menu shortcut. It + asks you to type `remove` first, because unregistering the distribution + **destroys every channel's files and the database** — there is no undo and no + copy is kept. Other WSL distributions on the PC are never touched. - **Linux** — `sudo /opt/1helm/uninstall-host.sh` removes the services, helper, and 1Helm-owned containers while preserving `/var/lib/1helm-oci-v1` for recovery. ### Release discipline -Mac, Linux, and Windows use one synchronized desktop release version. A release -is held in full until the signed/notarized Mac DMG and updater ZIP, verified -Linux host archive, and Windows Setup/Squirrel feed have all passed native -install and update acceptance from the same source commit. Windows -Authenticode status is disclosed in every release; v0.0.38 is `NotSigned`. +Mac and Linux use one synchronized desktop release version, and a release is +held in full until all three artifacts — the signed/notarized Mac DMG, the +notarized Mac updater ZIP, and the verified Linux host archive — have passed +native install and update acceptance from the same source commit. Windows ships +no release artifacts at all: it installs the Linux build through +`install.ps1`, served from the site, so there is nothing to sign and no +Authenticode status to disclose. ### Connect from a phone or tablet @@ -324,9 +374,9 @@ and an audit trail. A prompt saying “use this service” is not a connector. SHA-256 chain for new operational events. - Local-first collaboration through an optional workspace domain routed to the Captain's helm host; workspace state and provider credentials remain there. -- Host-owned updates: a signed native Mac updater, a Windows Squirrel feed with - disclosed Authenticode status, and an atomic digest-verified Linux system - service with health-check rollback. +- Host-owned updates: a signed native Mac updater, and an atomic digest-verified + Linux system service with health-check rollback — which is also how a Windows + host updates, because Windows runs that same Linux service inside WSL. - Signed, Apple-notarized, stapled Apple Silicon DMG releases. - Browser access from phones and tablets to an already configured HTTPS 1Helm host; native mobile gateway source is present but has no v0.0.38 public build. @@ -337,7 +387,7 @@ and an audit trail. A prompt saying “use this service” is not a connector. |---|---| | **Apple Silicon macOS 26** | Native desktop product and real isolated Linux computer per resident (Apple `container machine`, `home-mount=none`). | | **Linux / CI** | Supported headless systemd host with one durable Podman OCI container per resident, runtime-owned storage, and exact ownership checks; CI may select an explicit test backend. | -| **Windows 11 x64** | Native desktop product with one installation-scoped WSL 2 OCI runtime and one durable container per resident; Windows-drive mounts and interop are disabled. | +| **Windows 11 x64** | The Linux host, installed by `install.ps1` into a WSL 2 distribution named `1helm`, with the browser as the interface at `http://localhost:8123`. One durable Podman OCI container per resident, exactly as on Linux. No Windows application ships. | | **iPhone, iPad, and Android** | Use the current HTTPS browser interface. Native gateway source exists, but v0.0.38 has no public mobile artifact and the iOS app is not publicly listed. | Not yet shipped: current public mobile builds, a native Linux desktop shell, a @@ -365,7 +415,7 @@ A fresh data directory opens first-run setup. The source runtime defaults to | `PORT` | `8123` | HTTP/WebSocket control-plane port. | | `CTRL_DATA_DIR` | `./data` | Databases, routing state, uploads, and non-OCI development/Apple workspace mirrors. | | `HELM_CHANNEL_COMPUTER_BACKEND` | `apple` on macOS, `oci` on Linux and Windows | Host isolation backend; `native` and `mock` are explicit development/test overrides. | -| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.38` | Versioned channel-machine image contract. | +| `HELM_CHANNEL_MACHINE_IMAGE` | `local/1helm-channel-machine:0.0.39` | Versioned channel-machine image contract. | ### Agent-first JSON CLI @@ -383,9 +433,10 @@ npm run helm -- audit-verify ## Architecture -1Helm is a compact Node/TypeScript control plane hosted by Electron on macOS -and in the accepted Windows implementation, or by systemd on Linux. It does not -need an external database or a server transpilation step. +1Helm is a compact Node/TypeScript control plane hosted by Electron on macOS, or +by systemd on Linux — including the Linux inside a Windows host's WSL 2 +distribution. It does not need an external database or a server transpilation +step. | Layer | Implementation | |---|---| @@ -393,7 +444,7 @@ need an external database or a server transpilation step. | Control plane | `node:http`, WebSocket, additive SQLite migrations. | | Client | Vanilla TypeScript bundled with esbuild and Tailwind CSS. | | Model routing | Embedded ReRouted headless engine, private internal gateway, account pools, retries, routes, quotas, and logs. | -| Computers | Defensive argv-only Apple `container machine`; native Podman OCI on Linux; one shared managed WSL 2/Podman runtime on Windows; explicit `native`/`mock` test seams. | +| Computers | Defensive argv-only Apple `container machine`; native Podman OCI on Linux, including inside a Windows host's WSL 2 distribution; explicit `native`/`mock` test seams. | | Terminal | `node-pty`; ordinary terminals enter their channel VM while Skipper remains native. | | Memory | Curated records with provenance plus an isolated Mnemosyne SQLite store per identity. | | Scheduling | Durable obligations, wake reconciliation, lifecycle safety, repair, update, and pressure-aware sizing. | @@ -430,9 +481,9 @@ the complete `npm test` contract. ## Security boundary -- Residents use separate Linux worlds: Apple machines with no Mac home mount, - or durable OCI containers on Linux and inside Windows' one shared managed - WSL runtime. Windows-drive mounts and interop are disabled. +- Residents use separate Linux worlds: Apple machines with no Mac home mount, or + durable OCI containers on Linux — including the Linux inside a Windows host's + WSL 2 distribution. - Skipper's host tools require Captain-authorized provenance. - OCI workspace storage is runtime-owned and authoritative; Files and Cowork access it directly through a channel-scoped boundary. Apple mirrors remain diff --git a/desktop/main.cjs b/desktop/main.cjs index 62a5618..567ca7c 100644 --- a/desktop/main.cjs +++ b/desktop/main.cjs @@ -41,34 +41,14 @@ function rememberDesktopMode(mode) { fs.writeFileSync(desktopModePath(), `${mode}\n`, { mode: 0o600 }); } -// Windows packages ship application code inside app.asar; assets consumed by -// external processes (Python, PowerShell, WSL, plain-Node sidecars) are -// unpacked beside the archive. Translate paths for those consumers. Loose -// packages (macOS, Linux, development) pass through unchanged. +// An asar-packaged build keeps application code inside app.asar while assets +// consumed by external processes (Python, plain-Node sidecars) are unpacked +// beside the archive. Translate paths for those consumers. Loose packages +// (macOS, Linux, development) pass through unchanged. function unpackedPath(target) { return String(target).replace(/app\.asar(?=[\\/]|$)/, "app.asar.unpacked"); } -function handleSquirrelEvent() { - if (process.platform !== "win32") return false; - const event = process.argv[1]; - if (!["--squirrel-install", "--squirrel-updated", "--squirrel-uninstall", "--squirrel-obsolete"].includes(event)) return false; - const appFolder = path.resolve(process.execPath, ".."); - const updateExe = path.resolve(appFolder, "..", "Update.exe"); - const exe = path.basename(process.execPath); - if (event === "--squirrel-install" || event === "--squirrel-updated") { - spawnSync(updateExe, ["--createShortcut", exe], { stdio: "ignore", windowsHide: true }); - } else if (event === "--squirrel-uninstall") { - const dataRoot = app.getPath("userData"); - const wslRoot = path.join(String(process.env.LOCALAPPDATA || ""), "1Helm-Runtime"); - const cleanup = unpackedPath(path.resolve(__dirname, "..", "scripts", "windows-removal.cjs")); - spawnSync(process.execPath, [cleanup, dataRoot, wslRoot], { env: { ...process.env, ELECTRON_RUN_AS_NODE: "1" }, stdio: "ignore", windowsHide: true, timeout: 10 * 60_000 }); - spawnSync(updateExe, ["--removeShortcut", exe], { stdio: "ignore", windowsHide: true }); - } - setTimeout(() => app.quit(), 1000); - return true; -} - function preferredWorkspaceOrigin() { if (desktopMode() !== "client") return localOrigin; try { @@ -310,10 +290,7 @@ function createWindow(showWhenReady = true) { mainWindow = window; } -if (handleSquirrelEvent()) { - // Squirrel install/update/uninstall work must exit before the application - // acquires its normal single-instance lock or starts the local server. -} else if (!app.requestSingleInstanceLock()) { +if (!app.requestSingleInstanceLock()) { app.quit(); } else { app.on("second-instance", (_event, argv) => { @@ -325,7 +302,6 @@ if (handleSquirrelEvent()) { }); app.whenReady().then(async () => { - if (process.platform === "win32") app.setAppUserModelId("com.squirrel.1Helm.1Helm"); session.defaultSession.setPermissionCheckHandler((webContents, permission, _origin, details) => microphonePermissionAllowed(webContents, permission, details)); session.defaultSession.setPermissionRequestHandler(async (webContents, permission, callback, details) => { if (!microphonePermissionAllowed(webContents, permission, details)) { callback(false); return; } diff --git a/desktop/updater.cjs b/desktop/updater.cjs index 9e2ba29..d61aa52 100644 --- a/desktop/updater.cjs +++ b/desktop/updater.cjs @@ -21,23 +21,25 @@ function createNativeUpdateService({ app, autoUpdater, platform = process.platfo let busy = false; let initialTimer = null; let intervalTimer = null; - const nativeMode = platform === "win32" ? "native-windows" : "native-macos"; + // Only macOS has a native 1Helm host. A Windows host is the Linux host inside + // WSL 2, so it updates through the in-distribution Linux systemd updater and + // never through this Electron feed. let state = { - mode: nativeMode, + mode: "native-macos", status: "idle", current_version: app.getVersion(), version: null, checked_at: null, error: null, - message: `Check for a signed 1Helm update on this ${platform === "win32" ? "Windows PC" : "Mac"}.`, + message: "Check for a signed 1Helm update on this Mac.", }; let inApplications = true; if (platform === "darwin" && typeof app.isInApplicationsFolder === "function") { try { inApplications = app.isInApplicationsFolder(); } catch { inApplications = false; } } - const feedPlatform = platform === "win32" && arch === "x64" ? "win32-x64" : "darwin-arm64"; - const supported = app.isPackaged === true && ((platform === "darwin" && arch === "arm64" && inApplications) || (platform === "win32" && arch === "x64")); + const feedPlatform = "darwin-arm64"; + const supported = app.isPackaged === true && platform === "darwin" && arch === "arm64" && inApplications; const feedUrl = `https://update.electronjs.org/gitcommit90/1Helm/${feedPlatform}/${encodeURIComponent(app.getVersion())}`; const snapshot = () => ({ ...state }); @@ -53,7 +55,7 @@ function createNativeUpdateService({ app, autoUpdater, platform = process.platfo ? "Move 1Helm to Applications to enable host updates." : null, message: app.isPackaged - ? "Signed automatic updates are available for supported macOS and Windows hosts." + ? "Signed automatic updates are available for supported macOS hosts." : "Development builds are updated from their source checkout.", }); return false; @@ -122,7 +124,7 @@ function createNativeUpdateService({ app, autoUpdater, platform = process.platfo if (state.status !== "ready") { return { ...snapshot(), error: "No downloaded host update is ready." }; } - setState({ status: "installing", error: null, message: `1Helm is restarting this ${platform === "win32" ? "Windows" : "Mac"} host to install the verified update…` }); + setState({ status: "installing", error: null, message: "1Helm is restarting this Mac host to install the verified update…" }); process.env.HELM_UPDATE_INSTALLING = "1"; return snapshot(); } diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 35cd5eb..80972b2 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -57,23 +57,31 @@ contract as the slice hardens. 8. A multi-item user request retains a numbered acceptance ledger in the pull request and GitHub Release. Do not collapse completed items into a generic summary or rely on generated commit notes as the user-facing release record. -9. Each supported desktop platform owns its native artifact and installed-app - verification lane. The retained Apple Silicon host owns macOS signing and - notarization; Linux owns the systemd/OCI artifact and updater acceptance; - Windows 11 x64 owns Squirrel and shared-WSL OCI acceptance. Authenticode is optional - until 1Helm adopts a trusted Windows signing identity; unsigned artifacts - must be identified honestly, but their signature status is not a release - blocker. +9. Each supported desktop platform owns an installed-app verification lane. The + retained Apple Silicon host owns macOS signing, notarization, and its two + artifacts; Linux owns the systemd/OCI artifact and updater acceptance. + **Windows owns no artifact**: it runs the Linux host inside a per-user WSL 2 + distribution installed from `https://1helm.com/install.ps1`, so Windows 11 + x64 owns a behavioural acceptance lane instead — non-elevated one-liner + install with a single UAC prompt, restart and resume, keepalive across a + reboot, `http://localhost:8123` onboarding, prior-version update with the + data root retained, and removal via `uninstall.ps1`. Because no Windows + executable code ships, there is no signing identity or signature status to + record or disclose for it. ## Versioning - Semantic versioning on `package.json`. - **Do not** reuse a published version tag for different bits. -- A desktop release requires one unique version and exact commit, changelog, - complete Mac + Linux + Windows artifact matrix, and clean-install plus - prior-to-new update evidence on every platform. Partial platform releases - under the shared product version are prohibited. If one platform is blocked, - the entire tag/publication waits. +- A desktop release requires one unique version and exact commit, changelog, the + complete three-artifact matrix (`1Helm--arm64.dmg`, + `1Helm--mac-arm64.zip`, `1Helm--linux-node.tgz`), and + clean-install plus prior-to-new update evidence on macOS, Linux, and Windows. + Windows publishes no artifact; its installer is served by the site, not + attached to the release. Partial platform releases under the shared product + version are prohibited. If one platform is blocked, the entire + tag/publication waits. Because a Windows host installs the Linux archive, a + blocked Linux artifact blocks Windows too. - GitHub Release notes are a first-class product artifact. They must enumerate every user-visible fix and feature accepted for that release, using the same numbered ledger as the originating request when one exists. A short summary @@ -84,14 +92,20 @@ contract as the slice hardens. - Linux verification must use the digest-qualified release archive and prove a real systemd update, health-failure rollback, and retained `/var/lib/1helm-oci-v1`. -- Windows verification must prove the Setup/Squirrel signature status, clean - install, old-to-new update, loopback health, WSL lifecycle, and retained - application data on Windows 11 x64. Clean install must be exercised on a host - where WSL and VirtualMachinePlatform start disabled, covering the elevation - prompt, the required Windows restart, and resumed setup after that restart. - Do not substitute a self-signed - certificate or block an otherwise accepted release solely because the - artifacts are honestly disclosed as unsigned. +- Windows verification is behavioural and must be performed on real Windows 11 + x64 hardware. It must prove: a clean install driven by + `irm https://1helm.com/install.ps1 | iex` from an ordinary, non-elevated + PowerShell window with exactly one UAC prompt, exercised on a host where + `Microsoft-Windows-Subsystem-Linux` and `VirtualMachinePlatform` start + disabled; the mid-install restart reported without a false failure and the + identical command resuming to completion as the same signed-in user; the + keepalive registered as that user's scheduled task and surviving a reboot with + `1helm.service` active; a browser on that PC reaching `http://localhost:8123` + and completing onboarding; a prior-version update through the in-distribution + Linux updater with the data root under `/var/lib/1helm-oci-v1` retained; and + removal via the site-served `uninstall.ps1`, which must never call + `wsl --shutdown` and must never unregister a distribution whose name is not an + exact match for the target. No Windows signature status exists to record. Never hand-edit only a deployment target to fix the product. Fix in git, review, merge, and redeploy the exact source commit. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index f5cd638..b39a685 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -165,9 +165,15 @@ longer exists, 1Helm opens a fresh one. Ordinary residents cannot select or enter the Captain's native host. On supported Apple Silicon Macs, each resident runs inside its own Apple `container machine` with `home-mount=none`; Linux systemd hosts use one durable -Podman OCI container per resident; Windows uses one installation-scoped WSL 2 -runtime hosting one container per resident, with Windows-drive mounts and -interop disabled. `native` and `mock` remain explicit source/CI test seams. +Podman OCI container per resident. A Windows host is that same Linux host, +running inside a WSL 2 distribution named `1helm`, so its residents get the +identical Podman OCI containers. `native` and `mock` remain explicit source/CI +test seams. + +On Windows, `#main`'s Terminal is therefore **bash inside that WSL +distribution, not `cmd.exe`** — Windows commands do not work there. This is a +deliberate change from earlier versions, where the Windows host was a native +application and Skipper's terminal was the Windows shell. ![The full-height channel Terminal in its persistent workspace](assets/guide/terminal.png) @@ -385,21 +391,36 @@ Every host update preserves: ```text macOS: ~/Library/Application Support/1Helm-OCI-v1 Linux: /var/lib/1helm-oci-v1 -Windows: %APPDATA%\1Helm-OCI-v1 (plus %LOCALAPPDATA%\1Helm-Runtime) +Windows: /var/lib/1helm-oci-v1, inside the "1helm" WSL 2 distribution ``` On macOS that directory contains databases, credentials, workspaces, resident -state, and Apple mirrors. Linux and Windows preserve the equivalent -OCI-generation state; on Windows the shared WSL runtime disk lives separately -under `%LOCALAPPDATA%\1Helm-Runtime`. The retired data roots remain untouched -and are not imported by this generation. Do not delete a current data root -during replacement. +state, and Apple mirrors. Linux preserves the equivalent OCI-generation state. +A Windows host has **no data under `%APPDATA%`**: it runs the Linux host inside +WSL, so the same `/var/lib/1helm-oci-v1` is the data root and it lives on that +distribution's virtual disk under `C:\1helm\distro`. Reach it from PowerShell +with `wsl -d 1helm -u root --exec ls /var/lib/1helm-oci-v1`; it is root-owned +inside the distribution, exactly as on any other Linux host. The +retired data roots remain untouched and are not imported by this generation. Do +not delete a current data root during replacement, and do not unregister the +`1helm` distribution unless you intend to destroy everything on it. Before removing 1Helm, use its removal preparation flow. It is Captain-only, requires typed confirmation, reports backend-owned resident machines, and prepares those machines for safe deletion. Export or back up irreplaceable channel files first. +Then, per host: on macOS drag the app to the Trash; on Linux run +`sudo /opt/1helm/uninstall-host.sh`; on Windows run +`irm https://1helm.com/uninstall.ps1 | iex` from an ordinary PowerShell window, +signed in as the user who installed it. macOS and Linux deliberately leave the +data root in place for recovery. **Windows does not**, and cannot: it unregisters +the `1helm` distribution, which deletes the virtual disk the entire data root +lives on. It therefore asks you to type `remove` before it starts, states exactly +what will be lost, and takes `-Force` only for scripted removal. Other WSL +distributions on the PC are never touched, and Windows' own WSL feature is left +installed. + Recovery principles: - restore an archived channel rather than recreating it; @@ -414,29 +435,40 @@ Recovery principles: ### First run and installation -**Windows asks me to restart in the middle of setup.** That is the expected -path on a normal Windows 11 PC. WSL and VirtualMachinePlatform ship disabled; -1Helm turns them on for you, and Windows cannot use them until it restarts. -Restart, sign back in as the same Windows user, open 1Helm, and continue — -completed steps are skipped. Nothing is lost and nothing else is required. - -**Windows says "Windows protected your PC" when I run Setup.** Windows Setup is -not yet Authenticode signed, so SmartScreen warns about it. Choose **More info** -→ **Run anyway**. Every release discloses its Authenticode status; v0.0.38 is -`NotSigned`. Only run an installer you downloaded from -[1helm.com](https://1helm.com/download/windows) or the project's GitHub -releases. - -**A PowerShell window opened during Windows setup.** That window is the setup -itself and shows live progress. Leave it open until it finishes; closing it -early aborts the step. 1Helm mirrors the same progress in the app and writes a -log to `%APPDATA%\1Helm-OCI-v1\windows-wsl-setup.log`. - -**Windows setup failed or stalled.** Check the PowerShell window for the error, -then use **Retry shared runtime setup** in onboarding or Settings → Channel -computers. Setup is idempotent — retrying resumes rather than starting over. -The runtime requires Windows 11 **x64**; arm64 Windows is not supported by this -build. +**Windows printed "Restart required" and stopped.** That is the expected halfway +point of a Windows install, not a failure. WSL and VirtualMachinePlatform ship +disabled; the installer turns them on for you, and Windows cannot use them until +it restarts. Restart, sign back in as the same Windows user, open PowerShell, and +run the **same command again** — it continues from where it stopped and skips +what is already done. Nothing is lost. The whole install, restart included, took +about 8 minutes 49 seconds on a real Windows 11 machine. + +**Windows refuses to run install.ps1.** You downloaded the script instead of +piping it, and Windows blocks running `.ps1` files. Use +`powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1`. The +`irm https://1helm.com/install.ps1 | iex` one-liner is unaffected, because it +pipes a string into PowerShell rather than executing a file. + +**A "Welcome to WSL" window opened during the Windows install.** That window is +Microsoft's, not 1Helm's. It is harmless; close it and let the install continue. + +**Windows says nothing is listening on `http://localhost:8123`.** After the +installer reports 1Helm is running, the channel-computer runtime needs roughly +another 40 seconds to finish preparing before the first channel computer can be +created — it has not hung. If the address itself stays unavailable, ask the +service inside the distribution: +`wsl -d 1helm -u root --exec systemctl status 1helm`. + +**Windows says port 8123 is already in use.** Windows and every WSL distribution +share one network namespace, so another distribution or an ordinary Windows +process listening on 8123 stops 1Helm binding it. The installer refuses to +continue rather than half-install, and names the port. Stop whatever owns it and +run the command again. + +**The Windows install stopped for another reason.** Every step is idempotent: +read the reason it printed, fix it, and run the same command again — it resumes +rather than starting over. It requires Windows 11 **x64**; arm64 Windows is not +supported by this build. **macOS blocks the app or reports it is damaged.** The Mac release is Developer ID signed, notarized, and stapled, so Gatekeeper should accept it after you @@ -484,8 +516,8 @@ fallback. ## Security summary - Per-resident Apple Linux VMs have no Mac home mount. OCI residents use exact - container labels and runtime-owned storage; the shared Windows runtime - disables Windows-drive mounts and interop. + container labels and runtime-owned storage, including inside a Windows host's + WSL 2 distribution. - Credentials and connectors remain host-owned and minimally brokered. - Membership scopes data and live events; private coworker channels are not Captain-readable without invitation. @@ -494,8 +526,10 @@ fallback. - External skills are revision-pinned, bounded, scanned, hashed, and wrapped. - Operational history is locally tamper-evident. - Mac artifacts are Developer ID signed, notarized, stapled, and Gatekeeper - verified. Linux assets are digest-verified, and Windows Authenticode status - is disclosed for every release (`NotSigned` for v0.0.38). + verified, and Linux assets are digest-verified. Windows ships no artifact of + its own; its installer verifies Microsoft's WSL package against a pinned + SHA-256 and a valid Microsoft Authenticode signature, and the pinned Ubuntu + root filesystem against its SHA-256. For the detailed boundary, see [SECURITY.md](../SECURITY.md). For product intent, see [VISION.md](VISION.md). diff --git a/docs/release-checklist.md b/docs/release-checklist.md index f31a2d4..a3bcc57 100644 --- a/docs/release-checklist.md +++ b/docs/release-checklist.md @@ -12,6 +12,18 @@ Do not create the tag or GitHub Release, publish any platform, mark anything latest, or say “done” until all three lanes pass. If one lane is blocked, pause the whole release and report it. +**Three release artifacts, not six.** A complete release attaches exactly +`1Helm--arm64.dmg`, `1Helm--mac-arm64.zip` and +`1Helm--linux-node.tgz`. **Windows publishes nothing.** There is no +Windows executable, no Windows installer package, no Windows update manifest, no +Electron host on Windows and nothing to code-sign, so no signing status exists to +record or disclose. A Windows host is the Linux host running inside a per-user WSL 2 +distribution named `1helm`, installed from `https://1helm.com/install.ps1`, +which the site serves rather than a GitHub Release. Windows is therefore +accepted by behaviour (Section 7) rather than by artifact, and because a Windows +host installs the Linux archive, a Linux artifact that has not passed acceptance +blocks Windows too. + ## 1. Prepare ```bash @@ -73,18 +85,16 @@ VERSION="$(node -p "require('./package.json').version")" HEADLESS="dist/1Helm-${VERSION}-linux-node.tgz" DMG="dist/1Helm-${VERSION}-arm64.dmg" UPDATE_ZIP="dist/1Helm-${VERSION}-mac-arm64.zip" -WINDOWS_SETUP="dist/1Helm-${VERSION}-windows-x64-setup.exe" -WINDOWS_NUPKG="dist/1Helm-${VERSION}-full.nupkg" -WINDOWS_RELEASES="dist/RELEASES" ANDROID_APK="dist/1Helm-${VERSION}-universal.apk" RELEASE_NOTES="dist/1Helm-${VERSION}-release-notes.md" # Build from clean snapshots of the same MERGED_COMMIT on the platform owners: # macOS arm64: npm ci && npm run typecheck && npm run build && npm test && npm run package:dmg:release # Linux: npm ci && npm run typecheck && npm run build && npm test && npm run package:linux -# Windows x64: npm ci && npm run typecheck && npm run build && npm test && npm run package:windows +# Windows: no build. A Windows host installs "$HEADLESS" through the +# site-served install.ps1; there is no Windows artifact to produce. -for artifact in "$DMG" "$UPDATE_ZIP" "$HEADLESS" "$WINDOWS_SETUP" "$WINDOWS_NUPKG" "$WINDOWS_RELEASES"; do +for artifact in "$DMG" "$UPDATE_ZIP" "$HEADLESS"; do test -s "$artifact" done # Author RELEASE_NOTES from docs/release-notes-template.md. It must contain the @@ -94,12 +104,13 @@ test -s "$RELEASE_NOTES" rg -q '^1\. ' "$RELEASE_NOTES" # multi-item ships must retain a numbered ledger ``` -The Windows `.nupkg` basename must exactly match the entry inside `RELEASES`. -Record Authenticode status for Windows executable code and Setup. Trusted -signing is optional until 1Helm adopts a Windows signing identity: an honestly -disclosed `NotSigned` result is accepted and must not block the release. Never -substitute a self-signed identity. When a trusted identity is configured, use -the fail-closed `package:windows:release` command. +Those three files are the whole desktop matrix. Do not invent a fourth desktop +asset, and do not attach `install.ps1`, `uninstall.ps1` or the keepalive payload +to the release: they are served from the site, so a release commit that changes +them is not shipped until the site is deployed. Because Windows ships no +executable code of its own, there is no Windows signing identity and no Windows +signature status to record or disclose. Never sign anything with a self-signed +identity. Only after Sections 6–8 pass for all three desktop platforms: @@ -108,7 +119,6 @@ git tag -a "v${VERSION}" "$MERGED_COMMIT" -m "1Helm ${VERSION}" git push origin "refs/tags/v${VERSION}" gh release create "v${VERSION}" \ "$DMG" "$UPDATE_ZIP" "$HEADLESS" \ - "$WINDOWS_SETUP" "$WINDOWS_NUPKG" "$WINDOWS_RELEASES" \ --title "1Helm ${VERSION}" --notes-file "$RELEASE_NOTES" --draft # Upload mobile artifacts through their applicable distribution lane; their # timing never permits a partial desktop release. @@ -166,16 +176,39 @@ Expect first-run / needs_setup on empty data dir. host running the prior release, invoke the Captain host-update action, observe checking/downloading/installing/restarting, verify the new version and `/var/lib/1helm-oci-v1` identity, and exercise health-failure rollback. -- **Windows:** on Windows 11 x64, record Authenticode status for Setup, the - packaged app, and its executable code; confirm `.nupkg` and `RELEASES` - consistency; clean install Setup; exercise the real shared-WSL OCI channel lifecycle; - then expose staged - Squirrel metadata to the prior public version and prove download, - verification, restart installation, new version, loopback health, shared - runtime state, and current-generation app-data preservation. +- **Windows:** accepted by behaviour on real Windows 11 x64 hardware, not by + artifact. There is nothing to sign and no update feed to stage. Prove every + one of these: + 1. **Clean install from the one-liner.** In an ordinary, **non-elevated** + PowerShell window, run `irm https://1helm.com/install.ps1 | iex`. Exactly + **one** UAC prompt appears, and only the Windows optional features + (`Microsoft-Windows-Subsystem-Linux`, `VirtualMachinePlatform`) and + Microsoft's own WSL package run elevated. Everything else — importing the + distribution, installing 1Helm inside it, registering the keepalive — runs + as the signed-in user, because WSL state is per-user. + 2. **Restart and resume.** The first run reports the required restart and + exits without a false failure. After the restart, re-running the identical + command as the **same** signed-in Windows user resumes and completes. + 3. **Keepalive survives a reboot.** The keepalive is registered as that user's + scheduled task, starts again at sign-in after a reboot, and holds the + distribution up with `1helm.service` active. + 4. **Browser reaches the host.** A browser on that PC reaches + `http://localhost:8123` and completes onboarding. + 5. **Prior-version update preserves the data root.** Update from the previous + release through the in-distribution Linux updater and confirm the new + version, loopback health, and a retained data root under + `/var/lib/1helm-oci-v1`. + 6. **Removal.** `irm https://1helm.com/uninstall.ps1 | iex` removes the + keepalive, the `1helm` distribution and `C:\1helm`. It must never call + `wsl --shutdown` and must never unregister a distribution whose name is not + an exact match for the target; other distributions on the PC are untouched. - Before publication, compare each uploaded GitHub asset digest with the local - verified digest and assert the release contains the complete six-file - desktop matrix. A missing asset is a release blocker, not “not applicable.” + verified digest and assert the release contains the complete **three-file** + desktop matrix: `1Helm--arm64.dmg`, + `1Helm--mac-arm64.zip`, `1Helm--linux-node.tgz`. A missing + asset is a release blocker, not “not applicable.” Windows contributes no + asset, so an absent Windows file is correct — an absent Windows **behavioural + record** is a blocker. ## 8. Clean deployment verify (when shipping install path) @@ -196,8 +229,11 @@ Local setup: needs_setup verified on clean CTRL_DATA_DIR Clean deploy: Mac host update: Linux update: -Windows update: -Desktop matrix: +Windows: +Desktop matrix: Android: iOS: CI: Actions green on main diff --git a/docs/release-lifecycle.md b/docs/release-lifecycle.md index 3240499..1aa9ec7 100644 --- a/docs/release-lifecycle.md +++ b/docs/release-lifecycle.md @@ -5,20 +5,34 @@ Process contract from intent to verified deploy. Commands: [release-checklist.md ## Immutable desktop release rule 1Helm has one synchronized desktop-host release train. A named desktop release -is one version, one exact source commit, and one GitHub Release containing all -of the following: - -- Apple Silicon macOS DMG and native updater ZIP; -- Linux host archive; -- Windows x64 Setup executable, full Squirrel package, and `RELEASES` manifest. - -All three platform lanes are mandatory even when a change appears -platform-specific, because the application source and updater version advance -together. Do not tag, create or publish a GitHub Release, mark it latest, or -update public download/feed metadata until every lane is built, signed where -required, digest-verified, installed, and update-tested from the previous -release. If any lane is unavailable or fails, pause the whole release. Never -publish a Mac-only or otherwise partial set under the product version. +is one version, one exact source commit, and one GitHub Release containing +exactly these **three** artifacts: + +- `1Helm--arm64.dmg` — Developer ID signed, Apple-notarized and stapled + Apple Silicon macOS DMG; +- `1Helm--mac-arm64.zip` — the notarized/stapled native updater ZIP; +- `1Helm--linux-node.tgz` — the digest-qualified Linux host archive. + +**Windows publishes nothing.** There is no Windows executable, no Windows +installer package, no Windows update manifest, no Electron host on Windows and +nothing to code-sign, so no signing status exists to record or disclose. A +Windows host is the Linux host +running inside a per-user WSL 2 distribution named `1helm`, installed from +`https://1helm.com/install.ps1`, with the browser as its interface at +`http://localhost:8123`. `install.ps1`, `uninstall.ps1` and the keepalive payload +are served from the site rather than attached to a release, so a release commit +that changes them is not shipped until the site is deployed. + +All three platform lanes — macOS, Linux, and Windows — are mandatory even when a +change appears platform-specific, because the application source and updater +version advance together. Windows is accepted by **behaviour** rather than by +artifact, and since a Windows host installs the Linux archive, a Linux artifact +that has not passed acceptance blocks Windows too. Do not tag, create or publish +a GitHub Release, mark it latest, or update public download/feed metadata until +every lane is built where it has an artifact, digest-verified, installed, and +update-tested from the previous release. If any lane is unavailable or fails, +pause the whole release. Never publish a Mac-only or otherwise partial set under +the product version. Mobile distribution may have additional store/signing timing, but it never weakens the Mac + Linux + Windows desktop invariant. @@ -45,10 +59,11 @@ weakens the Mac + Linux + Windows desktop invariant. exact-commit candidates on every desktop lane │ v - Mac DMG + ZIP · Linux host archive · Windows Setup + nupkg + RELEASES + Mac DMG + ZIP · Linux host archive (Windows publishes no artifact) │ v clean install + prior→new updater acceptance on Mac, Linux, and Windows + (Windows via the site-served install.ps1 into WSL 2) │ v full numbered notes · tag · one complete GitHub Release @@ -116,11 +131,13 @@ Draft PRs are allowed for long slices; mark ready only when the quality bar is m user-visible item must appear once, with the same numbering as the request when available. Include additional fixes, artifacts/digests, and verification evidence in their own sections. -6. Before creating the tag or GitHub Release, finish the complete desktop - matrix from the exact merged commit: verified macOS DMG + updater ZIP, - Linux host archive, and Windows Setup + full `.nupkg` + literal `RELEASES` - manifest. Record whether Windows artifacts are trusted-signed or unsigned; - unsigned is accepted until 1Helm adopts a trusted Windows signing identity. +6. Before creating the tag or GitHub Release, finish the complete three-artifact + desktop matrix from the exact merged commit: verified macOS DMG + (`1Helm--arm64.dmg`), macOS updater ZIP + (`1Helm--mac-arm64.zip`), and Linux host archive + (`1Helm--linux-node.tgz`). Windows produces no artifact and has no + signing status to record; complete its behavioural acceptance instead + (`docs/release-checklist.md` Section 7). 7. Publish those desktop artifacts and complete release notes together through one GitHub Release. Never publish a subset or attach a platform later to a version already described as complete. Include a directly distributed @@ -152,10 +169,10 @@ workspace state. | Code landed | On `origin/main`, CI green | | Behavior fixed | Tests + manual/API check | | Install path still works | Clean `CTRL_DATA_DIR` boot through the wizard plus platform acceptance | -| Named desktop release | One version/commit, changelog, full numbered notes, exact tag, complete Mac + Linux + Windows asset matrix, and clean installation evidence for all three | +| Named desktop release | One version/commit, changelog, full numbered notes, exact tag, the complete three-artifact matrix (`1Helm--arm64.dmg`, `1Helm--mac-arm64.zip`, `1Helm--linux-node.tgz`), and clean installation evidence on macOS, Linux, and Windows | | Mac host update | Published notarized/stapled updater ZIP feed, installed-old-to-new acceptance, and preserved Application Support | | Linux host update | Digest-qualified artifact, real systemd install/update, health check/rollback, and preserved `/var/lib/1helm-oci-v1` | -| Windows host update | Setup + `.nupkg` + `RELEASES` with disclosed signature status, Squirrel install/update, shared-WSL OCI lifecycle smoke, and preserved current-generation app data | +| Windows host | No artifact and no signing status. Install from `https://1helm.com/install.ps1` in a non-elevated PowerShell window with a single UAC prompt, the mid-install restart and resume, a keepalive surviving a reboot, `http://localhost:8123` reached from a browser, a prior-version update through the in-distribution Linux updater with `/var/lib/1helm-oci-v1` retained, and removal via `uninstall.ps1` | If any platform artifact or acceptance run is skipped, the release is paused, not partially shipped. Say exactly what is missing and do not call it “done.” diff --git a/docs/release-notes-template.md b/docs/release-notes-template.md index ebf1348..ad1b52b 100644 --- a/docs/release-notes-template.md +++ b/docs/release-notes-template.md @@ -24,13 +24,24 @@ so plainly instead of silently omitting it. | `1Helm-x.y.z-arm64.dmg` | `` | | `1Helm-x.y.z-mac-arm64.zip` | `` | | `1Helm-x.y.z-linux-node.tgz` | `` | -| `1Helm-x.y.z-windows-x64-setup.exe` | `` | -| `1Helm-x.y.z-full.nupkg` | `` | -| `RELEASES` | `` | -Every desktop row is mandatory and must resolve to the same version and source -commit. “Not applicable” is forbidden for macOS, Linux, or Windows. If any row -is unavailable, this release must remain unpublished. +These three rows are the whole desktop matrix. Every one is mandatory and must +resolve to the same version and source commit. “Not applicable” is forbidden for +macOS or Linux. If any row is unavailable, this release must remain unpublished. +A release is complete only once macOS, Linux, and Windows have each been accepted. + +**Windows publishes no artifact.** A Windows host is the Linux host running +inside a per-user WSL 2 distribution named `1helm`, installed with one command +in an ordinary PowerShell window: + +```powershell +irm https://1helm.com/install.ps1 | iex +``` + +That script, `uninstall.ps1` and the keepalive payload are served from +`https://1helm.com`, not attached here. No Windows executable ships, so there is +no code signing and no signature status to disclose. Do not add a Windows row to +the table above; record Windows under Verification instead. Source commit: `` @@ -44,9 +55,14 @@ Source commit: `` - For Linux, state archive/source/digest verification and the real prior-version systemd update, health-check rollback, and `/var/lib/1helm-oci-v1` preservation. -- For Windows, state Authenticode status, Setup clean install, Squirrel - prior-version update, shared-WSL OCI lifecycle smoke, loopback health, and app-data - preservation. +- For Windows, state the behavioural acceptance on real Windows 11 x64 hardware: + a clean install via `irm https://1helm.com/install.ps1 | iex` from a + non-elevated PowerShell window with exactly one UAC prompt, the mid-install + restart and the resumed second run as the same signed-in user, the keepalive + surviving a reboot with `1helm.service` active, a browser reaching + `http://localhost:8123` and completing onboarding, the prior-version update + through the in-distribution Linux updater with `/var/lib/1helm-oci-v1` + retained, and removal via the site-served `uninstall.ps1`. - Name anything skipped or incomplete; do not call an incomplete release fully verified. diff --git a/package-lock.json b/package-lock.json index a8d9dd8..d9afe7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "1helm", - "version": "0.0.38", + "version": "0.0.39", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "1helm", - "version": "0.0.38", + "version": "0.0.39", "hasInstallScript": true, "license": "AGPL-3.0-only", "dependencies": { @@ -56,9 +56,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "electron": "43.1.1", - "electron-winstaller": "^5.4.4", "esbuild": "^0.28.1", - "png-to-ico": "^3.0.1", "puppeteer": "^25.3.0", "sharp": "^0.35.3", "typescript": "^7.0.2", @@ -5801,13 +5799,6 @@ "node": "^12.20.0 || >=14" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/cookie": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", @@ -5852,14 +5843,6 @@ "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", "license": "MIT" }, - "node_modules/cross-dirname": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/cross-dirname/-/cross-dirname-0.1.0.tgz", - "integrity": "sha512-+R08/oI0nl3vfPcqftZRpytksBXDzOUveBq/NBVx0sUp1axwzPQrKinNx5yd5sxPu8j1wIy8AfnVQ+5eFdha6Q==", - "dev": true, - "license": "MIT", - "optional": true - }, "node_modules/cross-env": { "version": "7.0.3", "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", @@ -6690,171 +6673,6 @@ "node": ">= 22.12.0" } }, - "node_modules/electron-winstaller": { - "version": "5.4.4", - "resolved": "https://registry.npmjs.org/electron-winstaller/-/electron-winstaller-5.4.4.tgz", - "integrity": "sha512-j9ETcBGJaXxAY/b6UBpR7LZfjdU4BAO+yvr4ifqHEdyuc3UNCy91PDGkWKY5UQ4coHNYfnwFggrqD6QPeFGAlg==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "@electron/asar": "^3.2.1", - "debug": "^4.1.1", - "fs-extra": "^7.0.1", - "lodash": "^4.17.21", - "semver": "^7.6.3", - "temp": "^0.9.0" - }, - "engines": { - "node": ">=8.0.0" - }, - "optionalDependencies": { - "@electron/windows-sign": "^1.1.2" - } - }, - "node_modules/electron-winstaller/node_modules/@electron/asar": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/@electron/asar/-/asar-3.4.1.tgz", - "integrity": "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA==", - "dev": true, - "license": "MIT", - "dependencies": { - "commander": "^5.0.0", - "glob": "^7.1.6", - "minimatch": "^3.0.4" - }, - "bin": { - "asar": "bin/asar.js" - }, - "engines": { - "node": ">=10.12.0" - } - }, - "node_modules/electron-winstaller/node_modules/@electron/windows-sign": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/@electron/windows-sign/-/windows-sign-1.2.2.tgz", - "integrity": "sha512-dfZeox66AvdPtb2lD8OsIIQh12Tp0GNCRUDfBHIKGpbmopZto2/A8nSpYYLoedPIHpqkeblZ/k8OV0Gy7PYuyQ==", - "dev": true, - "license": "BSD-2-Clause", - "optional": true, - "dependencies": { - "cross-dirname": "^0.1.0", - "debug": "^4.3.4", - "fs-extra": "^11.1.1", - "minimist": "^1.2.8", - "postject": "^1.0.0-alpha.6" - }, - "bin": { - "electron-windows-sign": "bin/electron-windows-sign.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/electron-winstaller/node_modules/@electron/windows-sign/node_modules/fs-extra": { - "version": "11.4.0", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.4.0.tgz", - "integrity": "sha512-EQsFzMUJkCKGr1ePqlYADkIUmHW1s3ZXr5Yqy6wbGrfUCphpl2maM/kyOIRA2HpP3AaFQTZXD4ldjek+nccddA==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "graceful-fs": "^4.2.0", - "jsonfile": "^6.0.1", - "universalify": "^2.0.0" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/electron-winstaller/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/electron-winstaller/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/electron-winstaller/node_modules/commander": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", - "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, - "node_modules/electron-winstaller/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/electron-winstaller/node_modules/jsonfile": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", - "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "universalify": "^2.0.0" - }, - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, - "node_modules/electron-winstaller/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/electron-winstaller/node_modules/universalify": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", - "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", - "dev": true, - "license": "MIT", - "optional": true, - "engines": { - "node": ">= 10.0.0" - } - }, "node_modules/electron/node_modules/@types/node": { "version": "24.13.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", @@ -7187,28 +7005,6 @@ "license": "MIT", "optional": true }, - "node_modules/fs-extra": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-7.0.1.tgz", - "integrity": "sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, - "node_modules/fs.realpath": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", - "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, - "license": "ISC" - }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -7442,18 +7238,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/inflight": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", - "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", - "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, - "license": "ISC", - "dependencies": { - "once": "^1.3.0", - "wrappy": "1" - } - }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -7638,16 +7422,6 @@ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", "license": "MIT" }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -8241,13 +8015,6 @@ "url": "https://github.com/sponsors/antonk52" } }, - "node_modules/lodash": { - "version": "4.18.1", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", - "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, - "license": "MIT" - }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", @@ -8507,8 +8274,8 @@ "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "devOptional": true, "license": "MIT", + "optional": true, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8543,19 +8310,6 @@ "dev": true, "license": "MIT" }, - "node_modules/mkdirp": { - "version": "0.5.6", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", - "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", - "dev": true, - "license": "MIT", - "dependencies": { - "minimist": "^1.2.6" - }, - "bin": { - "mkdirp": "bin/cmd.js" - } - }, "node_modules/mkdirp-classic": { "version": "0.5.3", "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", @@ -8763,8 +8517,8 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "wrappy": "1" } @@ -8882,16 +8636,6 @@ "integrity": "sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w==", "license": "MIT" }, - "node_modules/path-is-absolute": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", - "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -9056,51 +8800,6 @@ "crc-32": "^0.3.0" } }, - "node_modules/png-to-ico": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/png-to-ico/-/png-to-ico-3.0.1.tgz", - "integrity": "sha512-S8BOAoaGd9gT5uaemQ62arIY3Jzco7Uc7LwUTqRyqJDTsKqOAiyfyN4dSdT0D+Zf8XvgztgpRbM5wnQd7EgYwg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/node": "^22.10.3", - "minimist": "^1.2.8", - "pngjs": "^7.0.0" - }, - "bin": { - "png-to-ico": "bin/cli.js" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/png-to-ico/node_modules/@types/node": { - "version": "22.20.1", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", - "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/png-to-ico/node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/pngjs": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-7.0.0.tgz", - "integrity": "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14.19.0" - } - }, "node_modules/points-on-curve": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/points-on-curve/-/points-on-curve-1.0.1.tgz", @@ -9492,73 +9191,6 @@ "node": ">= 4" } }, - "node_modules/rimraf": { - "version": "2.6.3", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.3.tgz", - "integrity": "sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==", - "deprecated": "Rimraf versions prior to v4 are no longer supported", - "dev": true, - "license": "ISC", - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - } - }, - "node_modules/rimraf/node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, - "node_modules/rimraf/node_modules/brace-expansion": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz", - "integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/rimraf/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", - "dev": true, - "license": "ISC", - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/rimraf/node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/robust-predicates": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.3.tgz", @@ -10201,20 +9833,6 @@ "node": ">=18" } }, - "node_modules/temp": { - "version": "0.9.4", - "resolved": "https://registry.npmjs.org/temp/-/temp-0.9.4.tgz", - "integrity": "sha512-yYrrsWnrXMcdsnu/7YMYAofM1ktpL5By7vZhf15CrXijWWrEYZks5AXBudalfSWJLlnen/QUJUB5aoB0kqZUGA==", - "dev": true, - "license": "MIT", - "dependencies": { - "mkdirp": "^0.5.1", - "rimraf": "~2.6.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/through2": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz", @@ -10366,16 +9984,6 @@ "pathe": "^2.0.3" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/untildify": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", @@ -10663,8 +10271,8 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "devOptional": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/ws": { "version": "8.21.1", diff --git a/package.json b/package.json index 317c8ed..127e0bb 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "1helm", "productName": "1Helm", - "version": "0.0.38", + "version": "0.0.39", "private": true, "type": "module", "license": "AGPL-3.0-only", @@ -50,8 +50,6 @@ "package:dmg:release": "HELM_REQUIRE_NOTARIZATION=1 node scripts/package-mac-dmg.cjs", "package:channel-image": "bash scripts/build-oci-channel-image.sh", "package:linux": "node scripts/package-linux-host.mjs", - "package:windows": "node scripts/package-windows.cjs", - "package:windows:release": "set HELM_REQUIRE_WINDOWS_SIGNATURE=1&& node scripts/package-windows.cjs", "package:android:release": "node scripts/package-android-apk.mjs", "package:ios:release": "node scripts/package-ios-ipa.mjs", "test:desktop": "node --test test/desktop.mjs", @@ -104,9 +102,7 @@ "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", "electron": "43.1.1", - "electron-winstaller": "^5.4.4", "esbuild": "^0.28.1", - "png-to-ico": "^3.0.1", "puppeteer": "^25.3.0", "sharp": "^0.35.3", "typescript": "^7.0.2", diff --git a/public/index.html b/public/index.html index 36115f8..7cceecb 100644 --- a/public/index.html +++ b/public/index.html @@ -30,12 +30,12 @@ document.querySelectorAll('meta[name="theme-color"]').forEach(function (m) { m.setAttribute("content", color); }); })(); - +

- + diff --git a/scripts/deploy-site.sh b/scripts/deploy-site.sh new file mode 100755 index 0000000..bb51082 --- /dev/null +++ b/scripts/deploy-site.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Deploy 1helm.com from an exact Git commit. +# +# The site is a release-directory deployment on this host: each release is an +# immutable `git archive` of one commit under /opt/1helm-site/releases/, +# `current` points at the live one, and 1helm-site.service runs +# `node site/server.mjs` from there behind 1helm-site-cloudflared.service. +# +# This exists because the site gates a release. install.ps1 and install.sh are +# served from here, and install.sh resolves /api/releases/linux/latest to find +# the archive - so a release published without deploying the site leaves Windows +# with no installer and Linux unable to resolve a version. +# +# scripts/deploy-site.sh +# +# Rolls back to the previous release automatically if the new one fails to +# answer, so a bad deploy cannot leave 1helm.com down. +set -euo pipefail + +SITE_ROOT=/opt/1helm-site +RELEASES="$SITE_ROOT/releases" +CURRENT="$SITE_ROOT/current" +UNIT=1helm-site.service +PORT=8130 + +[[ "${EUID}" -eq 0 ]] || { echo "Run with sudo." >&2; exit 1; } +COMMITISH="${1:-}" +[[ -n "$COMMITISH" ]] || { echo "usage: $0 " >&2; exit 1; } + +REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO" + +SHA="$(git rev-parse --verify "$COMMITISH^{commit}")" +# A deployed site must be reproducible from a pushed commit, otherwise the live +# surface cannot be traced back to reviewed source. +git merge-base --is-ancestor "$SHA" origin/main 2>/dev/null \ + || { echo "Refusing to deploy $SHA: it is not an ancestor of origin/main." >&2; exit 1; } + +TARGET="$RELEASES/$SHA" +PREVIOUS="$(readlink -f "$CURRENT" 2>/dev/null || true)" + +echo "repo : $REPO" +echo "commit : $SHA" +echo "previous : ${PREVIOUS:-}" + +if [[ -d "$TARGET" ]]; then + echo "release directory already exists; reusing it" +else + install -d -m 0755 "$TARGET" + git archive --format=tar "$SHA" | tar -x -C "$TARGET" + # The site serves the product's own public assets from ../public, so a bare + # archive of site/ alone would 404 icons and schemas. + [[ -d "$TARGET/site" && -f "$TARGET/site/server.mjs" ]] \ + || { echo "archive is missing site/server.mjs" >&2; rm -rf -- "$TARGET"; exit 1; } + for required in site/public/install.sh site/public/install.ps1 site/public/keepalive/keepalive-install.ps1; do + [[ -e "$TARGET/$required" ]] || { echo "archive is missing $required" >&2; rm -rf -- "$TARGET"; exit 1; } + done +fi + +ln -sfn "$TARGET" "$SITE_ROOT/.current-next" +mv -Tf "$SITE_ROOT/.current-next" "$CURRENT" +systemctl restart "$UNIT" + +healthy=0 +for _ in $(seq 1 60); do + if curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1; then healthy=1; break; fi + sleep 0.5 +done + +if [[ "$healthy" -ne 1 ]]; then + echo "New release did not answer on :$PORT - rolling back." >&2 + if [[ -n "$PREVIOUS" && -d "$PREVIOUS" ]]; then + ln -sfn "$PREVIOUS" "$SITE_ROOT/.current-next" + mv -Tf "$SITE_ROOT/.current-next" "$CURRENT" + systemctl restart "$UNIT" + echo "Rolled back to $PREVIOUS." >&2 + fi + systemctl status "$UNIT" --no-pager -l >&2 || true + exit 1 +fi + +echo "deployed : $SHA" +for path in /health /install.sh /install.ps1 /keepalive/keepalive-install.ps1 /api/releases/linux/latest; do + printf ' %-38s %s\n' "$path" "$(curl -s -o /dev/null -w '%{http_code}' "http://127.0.0.1:$PORT$path")" +done diff --git a/scripts/install-wsl-runtime.ps1 b/scripts/install-wsl-runtime.ps1 deleted file mode 100644 index 8688f4c..0000000 --- a/scripts/install-wsl-runtime.ps1 +++ /dev/null @@ -1,412 +0,0 @@ -param( - [Parameter(Mandatory = $true)][string]$RuntimeName, - [Parameter(Mandatory = $true)][string]$AppRoot, - [switch]$HostSetup, - # Shared status file path so elevated HostSetup can report real errors to the parent/UI. - [string]$StatusPath = "" -) - -$ErrorActionPreference = "Stop" -$wsl = "$env:SystemRoot\System32\wsl.exe" -$wslVersion = "2.7.10.0" -$wslInstallerUrl = "https://github.com/microsoft/WSL/releases/download/2.7.10/wsl.2.7.10.0.x64.msi" -$wslInstallerSha256 = "1a62f90a43c03cc5bda47dfd0b6faf496ac70fd4389190518120a4f84fc895cf" -$rootfsUrl = "https://cloud-images.ubuntu.com/wsl/releases/24.04/20240423/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz" -$rootfsSha256 = "8251e27ffff381a4af5f41dcb94d867de3e0d9774a9241908ab34555d99315ea" - -if ($RuntimeName -notmatch '^1helm-[a-f0-9]{16}-runtime$') { throw "The shared runtime name is invalid." } -if ([System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture -ne [System.Runtime.InteropServices.Architecture]::X64) { - throw "This 1Helm build requires Microsoft's x64 WSL runtime." -} - -if ($StatusPath) { $env:HELM_WSL_SETUP_STATUS = $StatusPath } - -# Script-scoped: both elevated host setup and the signed-in owner path download files. -function Fetch-File { - param([string]$Url, [string]$Destination) - $curl = "curl.exe" - if (Get-Command $curl -ErrorAction SilentlyContinue) { - & $curl -fsSLo $Destination $Url - if ($LASTEXITCODE -ne 0) { throw "curl failed to download $Url" } - } else { - Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Destination - } -} - -# wsl.exe emits UTF-16LE. Captured as a .NET string that still contains NULs, so -# version/list matches fail unless the NULs are stripped first. -function Get-WslText { - param([Parameter(Mandatory = $true)][string[]]$ArgumentList) - # Windows PowerShell promotes native stderr to ErrorRecord objects. With the - # script-wide Stop policy, an expected nonzero probe (such as --version on a - # genuinely fresh host) otherwise aborts before we can inspect LASTEXITCODE - # and install WSL. Keep the probe non-terminating, then restore fail-closed - # behavior for the surrounding setup transaction. - $previousErrorAction = $ErrorActionPreference - try { - $ErrorActionPreference = "Continue" - $raw = & $wsl @ArgumentList 2>&1 | Out-String - $code = $LASTEXITCODE - } finally { - $ErrorActionPreference = $previousErrorAction - } - $text = ($raw -replace [char]0, "").Trim() - return [pscustomobject]@{ ExitCode = $code; Text = $text } -} - -function Test-PinnedWslRuntime { - $result = Get-WslText -ArgumentList @("--version") - return $result.ExitCode -eq 0 -and $result.Text -match [regex]::Escape($wslVersion) -} - -function Test-RestartRequired { - param($Feature) - if ($null -eq $Feature) { return $false } - $value = [string]$Feature.RestartRequired - return $value -eq "Required" -or $value -eq "1" -or $value -eq "True" -} - -function Test-WslRestartFailure { - param([string]$Text) - return $Text -match 'HCS_E_SERVICE_NOT_AVAILABLE|required feature is not installed' -} - -function Require-WindowsRestart { - $message = "Restart this PC to finish enabling WSL 2, then open 1Helm again. Setup continues automatically." - Write-SetupStatus -Status "restart_required" -Step $message -Progress 20 -ErrorMessage "Windows must restart to finish enabling WSL 2. No other action is needed." - Write-Host $message - exit 10 -} - -# Windows cannot activate the WSL 2 features until it reboots: the features sit -# in EnablePending, DISM reports RestartRequired as the ambiguous "Possible", -# and the vmcompute service does not exist yet. Any of those is a reboot, not a -# failure, and must never be reported to the Captain as a broken installation. -function Test-PendingWslRestart { - if ($null -eq (Get-Service -Name vmcompute -ErrorAction SilentlyContinue)) { return $true } - foreach ($name in @("Microsoft-Windows-Subsystem-Linux", "VirtualMachinePlatform")) { - $feature = Get-WindowsOptionalFeature -Online -FeatureName $name -ErrorAction SilentlyContinue - if ($null -eq $feature) { continue } - if ([string]$feature.State -ne "Enabled") { return $true } - if (Test-RestartRequired $feature) { return $true } - } - return (Test-Path -LiteralPath 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') -} - -function Get-WslDistributionNames { - $result = Get-WslText -ArgumentList @("--list", "--quiet") - if ($result.ExitCode -ne 0) { return @() } - return @( - $result.Text -split "(\r?\n)+" | - ForEach-Object { $_.Trim() } | - Where-Object { $_ } - ) -} - -function Write-SetupStatus { - param( - [string]$Status, - [string]$Step, - [int]$Progress, - [string]$ErrorMessage = "" - ) - Write-Host $Step - $path = $env:HELM_WSL_SETUP_STATUS - if (-not $path) { return } - try { - $dir = Split-Path -Parent $path - if ($dir -and -not (Test-Path -LiteralPath $dir)) { - New-Item -ItemType Directory -Path $dir -Force | Out-Null - } - $payload = @{ - status = $Status - step = $Step - progress = $Progress - error = $ErrorMessage - updated = (Get-Date).ToUniversalTime().ToString("o") - } | ConvertTo-Json -Compress - # Windows PowerShell's "utf8" encoding writes a BOM that turns status text into - # mojibake (e.g. "base...") in the Electron UI. Write plain UTF-8 instead. - [System.IO.File]::WriteAllText($path, $payload, [System.Text.UTF8Encoding]::new($false)) - } catch { - # Status reporting must never abort setup. - } -} - -function Fail-Setup { - param([string]$Message, [int]$Code = 1) - Write-SetupStatus -Status "failed" -Step $Message -Progress 0 -ErrorMessage $Message - Write-Host "ERROR: $Message" -ForegroundColor Red - exit $Code -} - -function Read-ReportedSetupStatus { - $path = $env:HELM_WSL_SETUP_STATUS - if (-not $path -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { return $null } - try { - return Get-Content -LiteralPath $path -Raw | ConvertFrom-Json - } catch { - return $null - } -} - -# The elevated process exit code is not authoritative across every Windows -# PowerShell/UAC host. The shared status file is the transaction record: in -# particular, never continue into the signed-in-user WSL probe after the -# elevated pass has declared that a reboot is required. -function Get-HostSetupOutcome { - param([Nullable[int]]$ExitCode) - $reported = Read-ReportedSetupStatus - if ($null -ne $reported -and $reported.status -eq "restart_required") { - $step = if ($reported.step) { [string]$reported.step } else { "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." } - $errorMessage = if ($reported.error) { [string]$reported.error } else { "Windows restart required to finish enabling WSL 2." } - return [pscustomobject]@{ Status = "restart_required"; Step = $step; Detail = $errorMessage } - } - if ($null -ne $reported -and $reported.status -eq "failed") { - $detail = if ($reported.error) { [string]$reported.error } elseif ($reported.step) { [string]$reported.step } else { "The administrator-approved WSL host setup failed." } - return [pscustomobject]@{ Status = "failed"; Step = $detail; Detail = $detail } - } - if ($null -eq $ExitCode) { - $detail = "Administrator approval was cancelled or Windows did not start the elevated WSL host setup." - return [pscustomobject]@{ Status = "failed"; Step = $detail; Detail = $detail } - } - if ($ExitCode -eq 10) { - return [pscustomobject]@{ - Status = "restart_required" - Step = "WSL 2 features are enabled. Restart Windows once, then retry 1Helm computer setup." - Detail = "Windows restart required to finish enabling WSL 2." - } - } - if ($ExitCode -ne 0) { - $detail = "The administrator-approved WSL host setup failed with exit code $ExitCode." - return [pscustomobject]@{ Status = "failed"; Step = $detail; Detail = $detail } - } - return [pscustomobject]@{ Status = "continue"; Step = ""; Detail = "" } -} - -if ($HostSetup) { - try { - $identity = [Security.Principal.WindowsIdentity]::GetCurrent() - $principal = [Security.Principal.WindowsPrincipal]::new($identity) - if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { - Fail-Setup "The WSL host setup phase requires administrator approval." - } - Write-SetupStatus -Status "running" -Step "Enabling Windows WSL features..." -Progress 8 - $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux - $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform - $enabledWslFeatureNow = $wslFeature.State -ne "Enabled" - $enabledVmFeatureNow = $vmFeature.State -ne "Enabled" - if ($enabledWslFeatureNow) { Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux -All -NoRestart | Out-Null } - if ($enabledVmFeatureNow) { Enable-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform -All -NoRestart | Out-Null } - $wslFeature = Get-WindowsOptionalFeature -Online -FeatureName Microsoft-Windows-Subsystem-Linux - $vmFeature = Get-WindowsOptionalFeature -Online -FeatureName VirtualMachinePlatform - # DISM's RestartRequired enum can stringify as "Possible" even though its - # numeric value is 1. Enabling either feature in this invocation is itself - # authoritative evidence that Windows must reboot before a WSL 2 VM import. - $restartRequired = $enabledWslFeatureNow -or $enabledVmFeatureNow -or (Test-RestartRequired $wslFeature) -or (Test-RestartRequired $vmFeature) - $hostTemporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-wsl-host-" + [Guid]::NewGuid().ToString("N")) - New-Item -ItemType Directory -Path $hostTemporary | Out-Null - try { - if (-not (Test-PinnedWslRuntime)) { - $msi = Join-Path $hostTemporary "wsl.candidate.msi" - Write-SetupStatus -Status "running" -Step "Downloading Microsoft WSL installer..." -Progress 12 - Fetch-File -Url $wslInstallerUrl -Destination $msi - if ((Get-FileHash -LiteralPath $msi -Algorithm SHA256).Hash.ToLowerInvariant() -ne $wslInstallerSha256) { - Fail-Setup "Microsoft WSL installer did not match 1Helm's pinned SHA-256." - } - $signature = Get-AuthenticodeSignature -LiteralPath $msi - if ($signature.Status -ne [System.Management.Automation.SignatureStatus]::Valid -or $null -eq $signature.SignerCertificate -or - $signature.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft Corporation(,|$)') { - Fail-Setup "Microsoft WSL installer did not have a valid Microsoft Corporation signature." - } - Write-SetupStatus -Status "running" -Step "Installing Microsoft WSL $wslVersion..." -Progress 18 - $installer = Start-Process -FilePath "$env:SystemRoot\System32\msiexec.exe" -ArgumentList @("/i", $msi, "/qn", "/norestart") -Wait -PassThru - # 0 success; 1641/3010 success+reboot; 1638 already installed (same/newer). - # 1603 is a hard fail from msiexec, but on machines that already have the - # pinned WSL build (or UTF-16 made detection fail earlier) the package may - # still leave a working runtime - re-verify before failing closed. - if ($installer.ExitCode -in @(1641, 3010)) { $restartRequired = $true } - elseif ($installer.ExitCode -notin @(0, 1638)) { - if (Test-PinnedWslRuntime) { - Write-Host "Microsoft WSL installer returned $($installer.ExitCode), but pinned WSL $wslVersion is already present; continuing." - } else { - Fail-Setup "Microsoft WSL installer failed with exit code $($installer.ExitCode)." - } - } - } else { - Write-SetupStatus -Status "running" -Step "Microsoft WSL $wslVersion is already installed." -Progress 18 - } - # A feature may already report Enabled before the reboot has registered - # WSL's VM compute service. This is the concrete pre-reboot state that - # otherwise lets setup continue into HCS_E_SERVICE_NOT_AVAILABLE. - if ($null -eq (Get-Service -Name vmcompute -ErrorAction SilentlyContinue)) { $restartRequired = $true } - if ($restartRequired) { - Require-WindowsRestart - } - if (-not (Test-PinnedWslRuntime)) { Fail-Setup "Microsoft WSL $wslVersion was installed but could not be verified." } - Write-SetupStatus -Status "running" -Step "Setting WSL 2 as the default..." -Progress 22 - $defaultVersion = Get-WslText -ArgumentList @("--set-default-version", "2") - if ($defaultVersion.ExitCode -ne 0) { - if (Test-WslRestartFailure $defaultVersion.Text) { Require-WindowsRestart } - Fail-Setup "WSL could not set version 2 as the default. $($defaultVersion.Text)" - } - } finally { - if (Test-Path -LiteralPath $hostTemporary) { Remove-Item -LiteralPath $hostTemporary -Recurse -Force } - } - exit 0 - } catch { - $message = $_.Exception.Message - if (-not $message) { $message = "$_" } - Fail-Setup $message - } -} - -# Keep the distribution owned by the signed-in Windows account. Only optional -# features and Microsoft's signed WSL package cross the UAC boundary; importing -# the distribution in that child would attach it to a different administrator -# when over-the-shoulder credentials are used. -try { - Write-SetupStatus -Status "running" -Step "Requesting administrator approval for WSL host setup..." -Progress 5 - $statusArg = if ($env:HELM_WSL_SETUP_STATUS) { $env:HELM_WSL_SETUP_STATUS } else { "" } - $hostArguments = @( - "-NoProfile", "-ExecutionPolicy", "Bypass", "-File", ('"{0}"' -f $PSCommandPath), - "-RuntimeName", $RuntimeName, "-AppRoot", ('"{0}"' -f $AppRoot), "-HostSetup" - ) - if ($statusArg) { - $hostArguments += @("-StatusPath", ('"{0}"' -f $statusArg)) - } - $hostProcess = Start-Process -FilePath "powershell.exe" -ArgumentList ($hostArguments -join " ") -Verb RunAs -Wait -PassThru - # -Wait is not dependable for an elevated ShellExecute launch: it can return - # while the child is still enabling Windows features. Continuing here probes - # for a WSL runtime the child has not finished installing and reports a false - # failure, so block on the real process handle before reading any outcome. - if ($null -ne $hostProcess) { - try { $hostProcess.WaitForExit() } catch { } - } - $hostExitCode = if ($null -eq $hostProcess) { $null } else { $hostProcess.ExitCode } - # The status file is written by the child immediately before it exits. Give a - # bounded grace period for a terminal record rather than racing its last write. - $settleDeadline = (Get-Date).AddSeconds(30) - while ((Get-Date) -lt $settleDeadline) { - $pending = Read-ReportedSetupStatus - if ($null -ne $pending -and @("restart_required", "failed", "complete") -contains [string]$pending.status) { break } - Start-Sleep -Milliseconds 500 - } - $hostOutcome = Get-HostSetupOutcome -ExitCode $hostExitCode - if ($hostOutcome.Status -eq "restart_required") { - Write-SetupStatus -Status "restart_required" -Step $hostOutcome.Step -Progress 20 -ErrorMessage $hostOutcome.Detail - Write-Host $hostOutcome.Step - exit 10 - } - if ($hostOutcome.Status -eq "failed") { - # A reboot that Windows has not taken yet is the single most common reason - # the elevated pass cannot finish. Tell the Captain to restart instead of - # presenting a failed installation they cannot act on. - if (Test-PendingWslRestart) { Require-WindowsRestart } - Fail-Setup $hostOutcome.Detail - } - if (-not (Test-PinnedWslRuntime)) { - if (Test-PendingWslRestart) { Require-WindowsRestart } - Fail-Setup "Microsoft WSL $wslVersion is not ready in the signed-in user's session." - } - - $AppRoot = [System.IO.Path]::GetFullPath($AppRoot) - $required = @( - (Join-Path $AppRoot "scripts\1helm-oci-runtime"), - (Join-Path $AppRoot "deploy\1helm-oci-runtime-v1.conf"), - (Join-Path $AppRoot "container\Containerfile.oci"), - (Join-Path $AppRoot "container\channel-machine.oci.tar"), - (Join-Path $AppRoot "container\channel-machine.oci.sha256") - ) - foreach ($source in $required) { - if (-not (Test-Path -LiteralPath $source -PathType Leaf)) { - Fail-Setup "The packaged OCI runtime contract is incomplete: missing $(Split-Path -Leaf $source)." - } - } - $expectedImageSha = (Get-Content -LiteralPath $required[4] -Raw).Trim().ToLowerInvariant() - if ($expectedImageSha -notmatch '^[a-f0-9]{64}$') { Fail-Setup "The sealed channel image digest file is invalid." } - $actualImageSha = (Get-FileHash -LiteralPath $required[3] -Algorithm SHA256).Hash.ToLowerInvariant() - if ($actualImageSha -ne $expectedImageSha) { Fail-Setup "The sealed channel image digest does not match." } - - $temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-oci-runtime-" + [Guid]::NewGuid().ToString("N")) - New-Item -ItemType Directory -Path $temporary | Out-Null - try { - Write-SetupStatus -Status "running" -Step "Checking for the shared 1Helm WSL runtime..." -Progress 28 - $names = @(Get-WslDistributionNames) - $runtimeRoot = Join-Path $env:LOCALAPPDATA "1Helm-Runtime" - $installDirectory = Join-Path $runtimeRoot $RuntimeName - $partialMarker = "$installDirectory.1helm-partial-import" - if ($names -notcontains $RuntimeName) { - if (Test-Path -LiteralPath $installDirectory) { - $entries = @(Get-ChildItem -LiteralPath $installDirectory -Force -ErrorAction SilentlyContinue) - $ownedPartial = (Test-Path -LiteralPath $partialMarker -PathType Leaf) -and ((Get-Content -LiteralPath $partialMarker -Raw).Trim() -eq $RuntimeName) - # v0.0.33 could leave an empty app-owned directory when Windows rejected - # the import before creating its VM. New attempts carry an ownership - # marker so an interrupted partial VHD can also be retried safely. - if ($entries.Count -eq 0 -or $ownedPartial) { - Remove-Item -LiteralPath $installDirectory -Recurse -Force - } else { - Fail-Setup "The shared runtime disk directory already exists without a registered runtime. Remove `"$installDirectory`" or unregister the partial distro, then retry." - } - } - New-Item -ItemType Directory -Path $installDirectory -Force | Out-Null - [System.IO.File]::WriteAllText($partialMarker, $RuntimeName, [System.Text.UTF8Encoding]::new($false)) - $rootfs = Join-Path $temporary "ubuntu-noble-wsl.rootfs.tar.gz" - Write-SetupStatus -Status "running" -Step "Downloading shared Linux runtime base..." -Progress 35 - Fetch-File -Url $rootfsUrl -Destination $rootfs - if ((Get-FileHash -LiteralPath $rootfs -Algorithm SHA256).Hash.ToLowerInvariant() -ne $rootfsSha256) { - Fail-Setup "Ubuntu's pinned WSL rootfs failed SHA-256 verification." - } - Write-SetupStatus -Status "running" -Step "Importing shared Linux runtime..." -Progress 48 - $imported = Get-WslText -ArgumentList @("--import", $RuntimeName, $installDirectory, $rootfs, "--version", "2") - if ($imported.ExitCode -ne 0) { - if (Test-WslRestartFailure $imported.Text) { Require-WindowsRestart } - Fail-Setup "The shared 1Helm WSL runtime could not be imported. $($imported.Text)" - } - Remove-Item -LiteralPath $partialMarker -Force - } - - Write-SetupStatus -Status "running" -Step "Installing shared runtime packages (podman, crun, ...)..." -Progress 58 - $bootstrap = @' -set -euo pipefail -export DEBIAN_FRONTEND=noninteractive -apt-get update -apt-get install -y --no-install-recommends acl ca-certificates crun fuse-overlayfs iptables podman python3 sudo uidmap util-linux -apt-get clean -rm -rf /var/lib/apt/lists/* -id 1helm >/dev/null 2>&1 || useradd --system --home-dir /var/lib/1helm-oci-v1 --no-create-home --shell /usr/sbin/nologin 1helm -install -d -m 0755 /etc/1helm /usr/libexec /usr/lib/1helm-oci -printf '[automount]\nenabled=false\nmountFsTab=false\n\n[interop]\nenabled=false\nappendWindowsPath=false\n\n[user]\ndefault=root\n\n[boot]\nsystemd=true\n' >/etc/wsl.conf -'@ - $bootstrapped = Get-WslText -ArgumentList @("--distribution", $RuntimeName, "--user", "root", "--exec", "/bin/bash", "-lc", $bootstrap) - if ($bootstrapped.ExitCode -ne 0) { Fail-Setup "The shared 1Helm runtime prerequisites could not be installed. $($bootstrapped.Text)" } - - Write-SetupStatus -Status "running" -Step "Installing the sealed OCI helper and channel image..." -Progress 78 - $unc = "\\wsl.localhost\$RuntimeName" - Copy-Item -LiteralPath $required[0] -Destination "$unc\usr\libexec\1helm-oci-runtime" -Force - Copy-Item -LiteralPath $required[1] -Destination "$unc\etc\1helm\oci-runtime-v1.conf" -Force - Copy-Item -LiteralPath $required[2] -Destination "$unc\usr\lib\1helm-oci\Containerfile.oci" -Force - Copy-Item -LiteralPath $required[3] -Destination "$unc\usr\lib\1helm-oci\channel-machine.oci.tar" -Force - Copy-Item -LiteralPath $required[4] -Destination "$unc\usr\lib\1helm-oci\channel-machine.oci.sha256" -Force - $meta = Join-Path $AppRoot "container\channel-machine.oci.json" - if (Test-Path -LiteralPath $meta -PathType Leaf) { - Copy-Item -LiteralPath $meta -Destination "$unc\usr\lib\1helm-oci\channel-machine.oci.json" -Force - } - $chmod = Get-WslText -ArgumentList @("--distribution", $RuntimeName, "--user", "root", "--exec", "/bin/chmod", "0755", "/usr/libexec/1helm-oci-runtime") - if ($chmod.ExitCode -ne 0) { Fail-Setup "The OCI runtime helper permissions could not be applied. $($chmod.Text)" } - Write-SetupStatus -Status "running" -Step "Restarting the shared runtime into its isolation policy..." -Progress 88 - $terminated = Get-WslText -ArgumentList @("--terminate", $RuntimeName) - if ($terminated.ExitCode -ne 0) { Fail-Setup "The shared runtime could not restart into its isolation policy. $($terminated.Text)" } - Write-SetupStatus -Status "running" -Step "Verifying the shared OCI runtime..." -Progress 94 - $ready = Get-WslText -ArgumentList @("--distribution", $RuntimeName, "--user", "root", "--exec", "/usr/libexec/1helm-oci-runtime", "ready") - if ($ready.ExitCode -ne 0) { Fail-Setup "The shared OCI runtime did not pass readiness verification. $($ready.Text)" } - Write-SetupStatus -Status "complete" -Step "1Helm's shared OCI runtime is installed and ready." -Progress 100 - Write-Host "1Helm's shared OCI runtime is installed and ready." - } finally { - if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } - } -} catch { - $message = $_.Exception.Message - if (-not $message) { $message = "$_" } - Fail-Setup $message -} diff --git a/scripts/package-linux-host.mjs b/scripts/package-linux-host.mjs index 20ce396..2066bd1 100755 --- a/scripts/package-linux-host.mjs +++ b/scripts/package-linux-host.mjs @@ -1,7 +1,8 @@ #!/usr/bin/env node -import { chmodSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, cpSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; -import { join, resolve } from "node:path"; +import { dirname, join, resolve } from "node:path"; import { spawnSync } from "node:child_process"; const root = resolve(import.meta.dirname, ".."); @@ -27,6 +28,50 @@ const sealed = [ "container/channel-machine.oci.sha256", "container/channel-machine.oci.json", ]; +// `git archive` only carries tracked files, so every gitignored build output has +// to be injected into the staging tree the same way the sealed image already is. +// Shipping them means the end-user host never runs `npm run build`. +const builtFiles = [ + "public/bundle.js", + "public/bundle.css", + "public/app.css", + "public/index.html", + "desktop/photon-sidecar.bundle.mjs", +]; +const builtTrees = ["public/excalidraw"]; +// Native addons are compiled inside this image, never on the packaging host: it +// is the oldest glibc we support building against (Debian bookworm, glibc 2.36), +// so the resulting binaries stay forward-compatible with every newer target. +const nativeBuilderImage = process.env.HELM_LINUX_NATIVE_BUILDER_IMAGE || "docker.io/library/node:22"; +const nativeArchitecture = "x64"; +const nativeManifestPath = "resources/linux-native-modules.json"; +const requiredNativeModule = "node_modules/node-pty/build/Release/pty.node"; + +const digestOf = (file) => createHash("sha256").update(readFileSync(file)).digest("hex"); +const symbolCeiling = (file, prefix) => { + const found = new Set(); + const pattern = new RegExp(`${prefix}_([0-9][0-9.]*)`, "g"); + for (const match of readFileSync(file).toString("latin1").matchAll(pattern)) found.add(match[1]); + const ranked = [...found].sort((left, right) => { + const a = left.split(".").map(Number); + const b = right.split(".").map(Number); + for (let index = 0; index < Math.max(a.length, b.length); index += 1) { + if ((a[index] || 0) !== (b[index] || 0)) return (a[index] || 0) - (b[index] || 0); + } + return 0; + }); + return ranked.at(-1) || ""; +}; +const nativeAddons = (directory, base = "") => { + const found = []; + for (const entry of readdirSync(directory, { withFileTypes: true })) { + const relative = base ? `${base}/${entry.name}` : entry.name; + if (entry.isSymbolicLink()) continue; + if (entry.isDirectory()) found.push(...nativeAddons(join(directory, entry.name), relative)); + else if (entry.isFile() && /\/build\/Release\/[^/]+\.node$/.test(`/${relative}`)) found.push(relative); + } + return found; +}; const repository = spawnSync("git", ["rev-parse", "--show-toplevel"], { cwd: root, encoding: "utf8" }); const repositoryRoot = repository.status === 0 ? resolve(String(repository.stdout || "").trim()) : ""; @@ -45,6 +90,29 @@ for (const rel of sealed.slice(0, 2)) { } } +if (process.platform !== "linux" || process.arch !== nativeArchitecture) { + throw new Error(`Linux packaging must run on a linux-${nativeArchitecture} builder so the vendored native addons match the shipped architecture`); +} +const containerRuntime = ["podman", "docker"].find((candidate) => spawnSync(candidate, ["--version"], { stdio: "ignore" }).status === 0); +if (!containerRuntime) { + throw new Error("Linux packaging requires podman or docker so production dependencies compile against the oldest supported glibc"); +} + +// Build the client and sidecar bundles here, on the release builder, so the +// installed release is already runnable. Everything below only copies results. +const clientBuild = spawnSync("npm", ["run", "build"], { cwd: root, stdio: "inherit" }); +if (clientBuild.status !== 0) throw new Error("Could not build the Linux release client and sidecar bundles"); +for (const rel of builtFiles) { + const built = resolve(root, rel); + if (!existsSync(built) || statSync(built).size === 0) throw new Error(`Linux packaging requires the built asset ${rel}`); +} +for (const rel of builtTrees) { + const built = resolve(root, rel); + if (!existsSync(built) || !statSync(built).isDirectory() || readdirSync(built).length === 0) { + throw new Error(`Linux packaging requires the built asset tree ${rel}`); + } +} + mkdirSync(dist, { recursive: true }); rmSync(output, { force: true }); @@ -87,9 +155,73 @@ try { chmodSync(destination, 0o755); } + for (const rel of builtFiles) { + const destination = join(stage, prefix, rel); + mkdirSync(dirname(destination), { recursive: true }); + copyFileSync(resolve(root, rel), destination); + } + for (const rel of builtTrees) { + const destination = join(stage, prefix, rel); + rmSync(destination, { recursive: true, force: true }); + cpSync(resolve(root, rel), destination, { recursive: true }); + } + + // Production dependencies are installed once, here, against the release + // lockfile that `git archive` just staged. The end-user host therefore needs + // neither a compiler nor npm registry access. + const install = spawnSync(containerRuntime, [ + "run", "--rm", "--network=host", + "-v", `${join(stage, prefix)}:/workspace`, + "-w", "/workspace", + "-e", "PUPPETEER_SKIP_DOWNLOAD=1", + "-e", "ELECTRON_SKIP_BINARY_DOWNLOAD=1", + "-e", "npm_config_audit=false", + "-e", "npm_config_fund=false", + "-e", "npm_config_update_notifier=false", + nativeBuilderImage, + "npm", "ci", "--omit=dev", + ], { stdio: "inherit" }); + if (install.status !== 0) throw new Error("Could not install the Linux release production dependencies inside the native builder image"); + + const stagedModules = join(stage, prefix, "node_modules"); + if (!existsSync(stagedModules)) throw new Error("The native builder image did not produce production node_modules"); + const stagedPty = join(stage, prefix, requiredNativeModule); + if (!existsSync(stagedPty)) throw new Error(`The native builder image did not produce ${requiredNativeModule}; terminals would be unavailable on the target host`); + const builderNode = spawnSync(containerRuntime, ["run", "--rm", nativeBuilderImage, "node", "-p", "process.versions.modules + ' ' + process.version"], { encoding: "utf8" }); + const [builderAbi = "", builderVersion = ""] = String(builderNode.stdout || "").trim().split(/\s+/); + if (!/^\d+$/.test(builderAbi)) throw new Error("Could not read the native builder image Node ABI"); + const modules = nativeAddons(stagedModules).map((rel) => { + const file = join(stagedModules, rel); + return { + path: `node_modules/${rel}`, + sha256: digestOf(file), + glibc: symbolCeiling(file, "GLIBC"), + glibcxx: symbolCeiling(file, "GLIBCXX"), + }; + }); + if (!modules.some((entry) => entry.path === requiredNativeModule)) throw new Error(`Could not fingerprint ${requiredNativeModule}`); + const manifest = { + version, + platform: "linux", + arch: nativeArchitecture, + builderImage: nativeBuilderImage, + builderNodeVersion: builderVersion, + nodeAbi: builderAbi, + modules, + }; + const manifestFile = join(stage, prefix, nativeManifestPath); + mkdirSync(dirname(manifestFile), { recursive: true }); + writeFileSync(manifestFile, `${JSON.stringify(manifest, null, 2)}\n`); + for (const entry of modules) { + console.log(`vendored ${entry.path} — max GLIBC ${entry.glibc || "none"} / GLIBCXX ${entry.glibcxx || "none"}`); + } + const pack = spawnSync("tar", ["-czf", output, "-C", stage, prefix], { stdio: "inherit" }); if (pack.status !== 0) throw new Error("Could not write the Linux host archive"); } finally { rmSync(stage, { recursive: true, force: true }); } +const archiveDigest = digestOf(output); +writeFileSync(`${output}.sha256`, `${archiveDigest} ${output.split("/").at(-1)}\n`); +console.log(`sha256 ${archiveDigest}`); console.log(output); diff --git a/scripts/package-windows.cjs b/scripts/package-windows.cjs deleted file mode 100644 index 9ae662b..0000000 --- a/scripts/package-windows.cjs +++ /dev/null @@ -1,192 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); - -const ROOT = path.resolve(__dirname, ".."); -const DIST = path.join(ROOT, "dist"); -const PRODUCT = "1Helm"; -const pkg = JSON.parse(fs.readFileSync(path.join(ROOT, "package.json"), "utf8")); -const VERSION = String(pkg.version || "").trim(); -const REQUIRE_SIGNATURE = process.env.HELM_REQUIRE_WINDOWS_SIGNATURE === "1"; -const CERT_SHA1 = String(process.env.WINDOWS_SIGN_CERT_SHA1 || "").replace(/\s+/g, "").toUpperCase(); -// Keep the Windows connector pin in lockstep with package-mac-dmg.cjs. -const CLOUDFLARED_VERSION = "2026.3.0"; -const CLOUDFLARED_URL = `https://github.com/cloudflare/cloudflared/releases/download/${CLOUDFLARED_VERSION}/cloudflared-windows-amd64.exe`; -const CLOUDFLARED_SHA256 = "59b12880b24af581cf5b1013db601c7d843b9b097e9c78aa5957c7f39f741885"; -// Electron Packager evaluates directories before their children. Keep the -// scripts directory itself traversable, then retain only the three runtime -// files below; otherwise the exact-file exceptions can never be reached. -const IGNORE_NON_RUNTIME_ROOTS = /^\/(?!package\.json$|LICENSE$|NOTICE$|desktop(?:$|\/)|container(?:$|\/(?:Containerfile\.oci|channel-machine\.oci\.(?:tar|sha256|json))$)|deploy(?:$|\/1helm-oci-runtime-v1\.conf$)|src(?:$|\/)|public(?:$|\/)|scripts(?:$|\/(?:1helm-oci-runtime|mnemosyne-bridge\.py|install-wsl-runtime\.ps1|windows-removal\.cjs)$)|node_modules(?:$|\/))/; -// Excalidraw is compiled into public/bundle.js. Shipping its source package as -// well adds deeply nested Radix paths that legacy Squirrel/NuGet cannot -// releasify under Windows' 260-character path limit. -const IGNORE_CLIENT_BUILD_MODULES = /^\/node_modules\/@excalidraw(?:$|\/)/; -// Some production dependencies publish maintainer instruction files. They are -// useful in source checkouts but are not runtime assets and must not enter an -// installed app or release package. -const IGNORE_INSTRUCTION_FILES = /\/AGENTS\.md$/; - -if (process.platform !== "win32" || process.arch !== "x64") throw new Error("Windows packaging must run on Windows x64."); -if (!/^\d+\.\d+\.\d+$/.test(VERSION)) throw new Error("package.json must contain a release version."); -if (REQUIRE_SIGNATURE && !CERT_SHA1) throw new Error("Release packaging requires WINDOWS_SIGN_CERT_SHA1 from the Windows certificate store."); - -function run(command, args, options = {}) { - const safe = args.map((arg, index) => index && args[index - 1] === "/sha1" ? "" : arg); - process.stdout.write(`$ ${command} ${safe.join(" ")}\n`); - const result = spawnSync(command, args, { stdio: "inherit", encoding: "utf8", ...options }); - if (result.status !== 0) throw new Error(`Command failed (${result.status}): ${command}`); -} - -function capture(command, args) { - const result = spawnSync(command, args, { encoding: "utf8" }); - if (result.status !== 0) return ""; - return String(result.stdout || "").trim(); -} - -function sign(target) { - if (!CERT_SHA1) return; - run("signtool.exe", ["sign", "/sha1", CERT_SHA1, "/fd", "SHA256", "/tr", "http://timestamp.digicert.com", "/td", "SHA256", target]); - run("signtool.exe", ["verify", "/pa", "/v", target]); -} - -function signPackagedExecutables(appDir) { - if (!CERT_SHA1) return; - const executable = /\.(?:exe|dll|node)$/i; - const pending = [appDir]; - const targets = []; - while (pending.length) { - const current = pending.pop(); - for (const entry of fs.readdirSync(current, { withFileTypes: true })) { - const target = path.join(current, entry.name); - if (entry.isDirectory()) pending.push(target); - else if (entry.isFile() && executable.test(entry.name)) targets.push(target); - } - } - for (const target of targets.sort()) sign(target); -} - -function prepareCloudflared(destination) { - const root = fs.mkdtempSync(path.join(os.tmpdir(), "1helm-cloudflared-win-")); - const binary = path.join(root, "cloudflared.exe"); - run("curl.exe", ["-fsSL", CLOUDFLARED_URL, "-o", binary]); - const digest = capture("certutil.exe", ["-hashfile", binary, "SHA256"]).split(/\r?\n/).find((line) => /^[a-f0-9 ]{64,}$/i.test(line.trim()))?.replaceAll(" ", "").toLowerCase() || ""; - if (digest !== CLOUDFLARED_SHA256) throw new Error(`Bundled cloudflared digest mismatch (got ${digest || "unavailable"}).`); - fs.copyFileSync(binary, destination); - fs.rmSync(root, { recursive: true, force: true }); -} - -async function main() { - // The photon sidecar bundle is produced by `npm run build`. Fail before - // packaging rather than shipping an app whose sidecar cannot start. - if (!fs.existsSync(path.join(ROOT, "desktop", "photon-sidecar.bundle.mjs"))) throw new Error("desktop/photon-sidecar.bundle.mjs is missing; run `npm run build` first."); - fs.mkdirSync(DIST, { recursive: true }); - const iconRoot = fs.mkdtempSync(path.join(os.tmpdir(), "1helm-win-icon-")); - // Squirrel 1.x expands every package file through legacy .NET APIs limited - // to 260-character paths, both while releasifying here and while installing - // or updating on end-user machines under %LOCALAPPDATA%. Loose node_modules - // exceed that budget, so application code ships inside app.asar and only - // assets consumed by external processes are unpacked (see the asar option - // below). The drive-root scratch directory still keeps Squirrel's own - // staging prefixes short for the unpacked remainder. - const windowsScratch = fs.mkdtempSync(path.join(path.parse(ROOT).root, "1hw-")); - const ico = path.join(iconRoot, "1Helm.ico"); - try { - const pngToIco = require("png-to-ico").default; - const iconBuffer = await pngToIco([ - path.join(ROOT, "public", "icons", "icon-sailboat-192.png"), - path.join(ROOT, "public", "icons", "icon-sailboat-512.png"), - ]); - fs.writeFileSync(ico, iconBuffer); - const { packager } = require("@electron/packager"); - const [appDir] = await packager({ - dir: ROOT, name: PRODUCT, executableName: PRODUCT, appCopyright: "Copyright (c) 2026 Joseph Yaksich", - win32metadata: { CompanyName: "Joseph Yaksich", FileDescription: PRODUCT, OriginalFilename: "1Helm.exe", ProductName: PRODUCT, InternalName: PRODUCT }, - platform: "win32", arch: "x64", out: windowsScratch, overwrite: true, prune: true, icon: ico, - // Everything importable stays inside app.asar so legacy Squirrel sees a - // handful of short paths instead of tens of thousands of loose files. - // Unpacked: assets read by external processes (PowerShell, Python, WSL, - // the plain-Node photon sidecar, Squirrel's uninstall hook) plus native - // modules that must exist on disk to load. - asar: { - unpack: "**/*.node", - unpackDir: "{scripts,container,deploy,public,desktop,node_modules/node-pty}", - }, - ignore: [IGNORE_NON_RUNTIME_ROOTS, IGNORE_CLIENT_BUILD_MODULES, IGNORE_INSTRUCTION_FILES, /\.DS_Store$/, /\.log$/], - }); - const appExe = path.join(appDir, "1Helm.exe"); - if (!fs.existsSync(appExe)) throw new Error("Packaged Windows application is missing 1Helm.exe."); - const cloudflared = path.join(appDir, "resources", "cloudflared.exe"); - prepareCloudflared(cloudflared); - if (!fs.existsSync(cloudflared)) throw new Error("Packaged Windows app is missing cloudflared.exe."); - signPackagedExecutables(appDir); - const asarArchive = path.join(appDir, "resources", "app.asar"); - if (!fs.existsSync(asarArchive)) throw new Error("Packaged Windows app is missing app.asar."); - const unpackedRoot = path.join(appDir, "resources", "app.asar.unpacked"); - const pty = path.join(unpackedRoot, "node_modules", "node-pty", "prebuilds", "win32-x64", "pty.node"); - if (!fs.existsSync(pty)) throw new Error("Packaged Windows app is missing the x64 terminal module."); - const sidecarBundle = path.join(unpackedRoot, "desktop", "photon-sidecar.bundle.mjs"); - if (!fs.existsSync(sidecarBundle)) throw new Error("Packaged Windows app is missing the self-contained photon sidecar bundle."); - if (capture("where.exe", ["dumpbin.exe"])) { - const headers = capture("dumpbin.exe", ["/headers", appExe]); - if (!/machine \(x64\)/i.test(headers)) throw new Error("Packaged Windows application is not x64."); - } - if (capture("where.exe", ["powershell.exe"])) { - const script = path.join(unpackedRoot, "scripts", "install-wsl-runtime.ps1"); - if (!fs.existsSync(script)) throw new Error("Packaged Windows app is missing its WSL setup script."); - for (const required of [ - path.join(unpackedRoot, "scripts", "1helm-oci-runtime"), - path.join(unpackedRoot, "scripts", "mnemosyne-bridge.py"), - path.join(unpackedRoot, "scripts", "windows-removal.cjs"), - path.join(unpackedRoot, "deploy", "1helm-oci-runtime-v1.conf"), - path.join(unpackedRoot, "container", "Containerfile.oci"), - path.join(unpackedRoot, "container", "channel-machine.oci.tar"), - path.join(unpackedRoot, "container", "channel-machine.oci.sha256"), - ]) if (!fs.existsSync(required)) throw new Error(`Packaged Windows app is missing ${path.basename(required)}.`); - } - - const installerDir = path.join(windowsScratch, "w"); - const { createWindowsInstaller } = require("electron-winstaller"); - await createWindowsInstaller({ - appDirectory: appDir, - outputDirectory: installerDir, - authors: "Joseph Yaksich", - exe: "1Helm.exe", - name: "1Helm", - title: "1Helm", - description: pkg.description, - setupExe: `1Helm-${VERSION}-windows-x64-setup.exe`, - setupIcon: ico, - iconUrl: "https://1helm.com/icons/icon-sailboat.ico", - noMsi: true, - loadingGif: undefined, - signWithParams: CERT_SHA1 ? `/sha1 ${CERT_SHA1} /fd SHA256 /tr http://timestamp.digicert.com /td SHA256` : undefined, - }); - const setup = path.join(installerDir, `1Helm-${VERSION}-windows-x64-setup.exe`); - const nupkg = fs.readdirSync(installerDir).map((name) => path.join(installerDir, name)).find((name) => name.endsWith("-full.nupkg")); - const releases = path.join(installerDir, "RELEASES"); - if (!fs.existsSync(setup) || !nupkg || !fs.existsSync(releases)) throw new Error("Windows installer/update artifacts are incomplete."); - sign(setup); - const finalSetup = path.join(DIST, path.basename(setup)); - // update.electronjs.org fetches an asset literally named RELEASES, then - // rewrites the unchanged .nupkg basename embedded inside it to the GitHub - // release download URL. Renaming either artifact breaks Squirrel updates. - const finalNupkg = path.join(DIST, path.basename(nupkg)); - const finalReleases = path.join(DIST, "RELEASES"); - for (const target of [finalSetup, finalNupkg, finalReleases]) fs.rmSync(target, { force: true }); - fs.copyFileSync(setup, finalSetup); - fs.copyFileSync(nupkg, finalNupkg); - fs.copyFileSync(releases, finalReleases); - for (const target of [finalSetup, finalNupkg, finalReleases]) { - process.stdout.write(`${target}\nSHA-256 ${capture("certutil.exe", ["-hashfile", target, "SHA256"]).split(/\r?\n/).find((line) => /^[a-f0-9 ]{64,}$/i.test(line.trim()))?.replaceAll(" ", "") || "unavailable"}\n`); - } - } finally { - fs.rmSync(iconRoot, { recursive: true, force: true }); - fs.rmSync(windowsScratch, { recursive: true, force: true }); - } -} - -main().catch((error) => { console.error(error instanceof Error ? error.stack || error.message : error); process.exit(1); }); diff --git a/scripts/windows-removal.cjs b/scripts/windows-removal.cjs deleted file mode 100644 index cca3133..0000000 --- a/scripts/windows-removal.cjs +++ /dev/null @@ -1,77 +0,0 @@ -#!/usr/bin/env node -"use strict"; - -const fs = require("node:fs"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); -const { DatabaseSync } = require("node:sqlite"); - -const dataRootArg = String(process.argv[2] || ""); -const runtimeRootArg = String(process.argv[3] || ""); -if (!path.isAbsolute(dataRootArg) || !path.isAbsolute(runtimeRootArg)) throw new Error("Exact Windows data roots are required."); -const dataRoot = path.resolve(dataRootArg); -const runtimeRoot = path.resolve(runtimeRootArg); -const database = path.join(dataRoot, "ctrl-pane.db"); -if (!fs.existsSync(database)) process.exit(0); -const db = new DatabaseSync(database, { readOnly: true }); -const installation = String(db.prepare("SELECT installation_id FROM workspace WHERE id=1").get()?.installation_id || ""); -if (!/^[a-f0-9]{16}$/.test(installation)) throw new Error("Could not verify this installation's OCI runtime identity."); -const runtimeName = `1helm-${installation}-runtime`; -const prefix = `1helm-${installation}-channel-`; -const installDirectory = path.resolve(runtimeRoot, runtimeName); -if (path.dirname(installDirectory) !== runtimeRoot) throw new Error("Refusing a runtime directory outside 1Helm's private root."); -const wsl = process.env.SystemRoot ? path.join(process.env.SystemRoot, "System32", "wsl.exe") : "wsl.exe"; - -function run(args, timeout = 5 * 60_000) { - return spawnSync(wsl, args, { encoding: "utf8", windowsHide: true, timeout }); -} -function runtime(args, timeout) { - return run(["--distribution", runtimeName, "--user", "root", "--exec", "/usr/libexec/1helm-oci-runtime", ...args], timeout); -} -function output(result) { - return `${String(result.stdout || "")}\n${String(result.stderr || "")}`.replaceAll("\0", "").trim(); -} -function removeInstallDirectory() { - const pause = new Int32Array(new SharedArrayBuffer(4)); - for (let attempt = 0; fs.existsSync(installDirectory) && attempt < 120; attempt++) { - try { fs.rmSync(installDirectory, { recursive: true, force: true }); } - catch (error) { if (!["EBUSY", "EPERM", "ENOTEMPTY"].includes(String(error?.code || ""))) throw error; } - if (fs.existsSync(installDirectory)) Atomics.wait(pause, 0, 0, 250); - } - if (fs.existsSync(installDirectory)) throw new Error(`WSL released ${runtimeName}, but its private virtual disk remained locked.`); -} - -const listed = run(["--list", "--quiet"], 90_000); -if (listed.status !== 0) throw new Error("Could not list WSL distributions during 1Helm removal."); -const names = String(listed.stdout || "").replaceAll("\0", "").split(/\r?\n/).map((line) => line.trim()).filter(Boolean); -let deleted = 0; -if (names.includes(runtimeName)) { - const version = runtime(["version"], 90_000); - if (version.status !== 0 || output(version) !== "1helm-oci-runtime-v1") { - throw new Error(`Refusing to remove ${runtimeName}: its installed OCI helper identity does not match.`); - } - const listedContainers = runtime(["list", prefix], 90_000); - if (listedContainers.status !== 0) throw new Error(output(listedContainers) || "Could not list owned channel containers."); - let containers; - try { containers = JSON.parse(String(listedContainers.stdout || "[]")); } - catch { throw new Error("The shared OCI runtime returned an unreadable channel-container list."); } - if (!Array.isArray(containers)) throw new Error("The shared OCI runtime returned an invalid channel-container list."); - for (const name of containers) { - if (typeof name !== "string" || !name.startsWith(prefix) || !/^\d+$/.test(name.slice(prefix.length))) { - throw new Error("Refusing an unsafe OCI channel-container cleanup target."); - } - const channelId = name.slice(prefix.length); - const removed = runtime(["delete", name, `${installation}:${channelId}`]); - if (removed.status !== 0) throw new Error(output(removed) || `Could not delete owned OCI container ${name}.`); - deleted++; - } - const remaining = runtime(["list", prefix], 90_000); - if (remaining.status !== 0 || String(remaining.stdout || "").trim() !== "[]") { - throw new Error("Owned channel containers remained; the shared runtime was preserved."); - } - if (run(["--terminate", runtimeName], 90_000).status !== 0) throw new Error(`Could not stop shared runtime ${runtimeName}.`); - if (run(["--unregister", runtimeName], 5 * 60_000).status !== 0) throw new Error(`Could not unregister shared runtime ${runtimeName}.`); - removeInstallDirectory(); -} -fs.mkdirSync(dataRoot, { recursive: true }); -fs.writeFileSync(path.join(dataRoot, "windows-removal-status.json"), `${JSON.stringify({ deleted, runtime: runtimeName, at: Date.now() })}\n`, { mode: 0o600 }); diff --git a/site/content.mjs b/site/content.mjs index 4db1b98..97baa49 100644 --- a/site/content.mjs +++ b/site/content.mjs @@ -13,7 +13,7 @@ const doc = (path, title, description, content) => ({ title, description, kind: const security = doc("/manual/security-model", "Security model", "How 1Helm isolates residents, brokers credentials, audits actions, validates skills, and defines the human boundary.", `

Autonomy without architecture is just ambient authority. 1Helm makes routine action cheap inside a narrow world and makes boundary crossings explicit, attributable, and recoverable.

Resident isolation

Each ordinary channel receives a separate persistent Linux world: an Apple container machine with no Mac home mount, or a durable OCI container. Linux runs OCI natively. Windows hosts containers inside one managed WSL 2 runtime whose Windows-drive mounts and interop are disabled. Exact labels, storage mounts, and owner markers gate lifecycle operations. Other residents and the host home are not exposed.

Authoritative files

OCI workspace storage belongs to the runtime and is authoritative. Files and Cowork receive narrow direct access to that channel's storage; command and terminal paths do not copy the whole workspace. Apple's backend retains its bounded, symlink-contained mirror.

Skipper boundary

Skipper owns native host operations, fleet lifecycle, credential brokering, and cross-channel work. A resident calls Skipper directly with the invoking thread; a Captain-authored request is required for host-authorized operations. Skipper returns the result to the resident automatically.

Credentials and connections

Provider, Gmail, and Photon credentials stay in host-owned storage. Residents receive task-scoped tools and permission records, not raw access tokens or the native Messages database. Photon accepts only the configured Captain phone and keeps that direct Skipper conversation in the Captain's private #main.

Skill supply chain

The external catalog is discovery metadata, not executable trust. 1Helm shows the open registry's results without applying its own browse-time allowlist. A selected GitHub source is resolved to an immutable commit, bounded to 256 KiB, scanned for instruction override, exfiltration, remote-pipe execution, broad destructive commands, security disabling, private-host access, and prompt extraction, then hashed and wrapped beneath runtime authority.

Audit and limits

New activity, tool starts/results, and skill installation decisions enter an append-only SHA-256 chain. The chain is tamper-evident, not a remote transparency log: an administrator with database access can still delete or replace the entire database. Historical rows predating the chain are not backfilled.

Known dependency debt

The pinned Photon SDK currently carries moderate OpenTelemetry advisories upstream. It runs in a supervised loopback-only child process with telemetry disabled. 1Helm tracks the exact pin and will upgrade when the required Photon API remains compatible; this is not represented as a clean dependency audit.

Report a vulnerability

Use GitHub's private vulnerability reporting for the 1Helm repository. Do not open a public issue containing credentials, tokens, or an unpatched exploit.

`); -const gettingStarted = doc("/manual/getting-started", "Getting started", "Install 1Helm, connect providers, create the workspace, and give the first resident a real outcome.", `

The normal setup is three product decisions. 1Helm handles the infrastructure around them.

1. Install or connect

On Apple Silicon, download the signed, notarized, and stapled DMG. On Windows 11 x64, download the Setup executable; it is not yet Authenticode signed, so SmartScreen warns once — choose More info → Run anyway. Its status is disclosed in the release notes and v0.0.38 is NotSigned. Ubuntu/Debian hosts use the digest-verified Linux systemd installer. A new desktop installation can host its own workspace or connect to an existing HTTPS 1Helm host. Native mobile apps connect only to an existing configured host. Starting a new host may request one administrator approval for its isolated Linux runtime. On Windows that step also enables WSL 2 for you, which requires one Windows restart partway through setup; reopen 1Helm afterwards and setup continues where it left off. See the Windows guide.

2. Captain

Create the first account. This is the Captain: owner, final authority, and administrator. Public registration closes after the Captain exists.

3. Providers

Connect one or more subscription accounts or API keys. You can add more later, pool accounts, select exact models, and build fallback or round-robin routes. There is no required single “AI brain.”

4. Workspace

Name the workspace. Terminals default on. 1Helm creates #main with the one Skipper, then you create ordinary channels with plain-language purposes. Every ordinary channel gets a private Linux computer.

5. Give an outcome

Try: “Audit this launch folder, turn the notes into a decision brief, resolve obvious gaps yourself, and give me the finished PDF with evidence.” The resident should inspect, execute, create the artifact, and call Skipper itself if it crosses the channel boundary.

`); +const gettingStarted = doc("/manual/getting-started", "Getting started", "Install 1Helm, connect providers, create the workspace, and give the first resident a real outcome.", `

The normal setup is three product decisions. 1Helm handles the infrastructure around them.

1. Install or connect

On Apple Silicon, download the signed, notarized, and stapled DMG. On Windows 11 x64 there is nothing to download and no application to install: open an ordinary PowerShell window and run irm https://1helm.com/install.ps1 | iex, which installs the ordinary Linux build into a WSL 2 distribution and gives you the interface in your browser at http://localhost:8123. Windows asks your permission once, the first run ends by telling you to restart, and you run the same command again afterwards — the Windows guide walks through every step and says what you will see. Ubuntu/Debian hosts use the digest-verified Linux systemd installer. A new desktop installation can host its own workspace or connect to an existing HTTPS 1Helm host. Native mobile apps connect only to an existing configured host. Starting a new Mac or Linux host may request one administrator approval for its isolated Linux runtime.

2. Captain

Create the first account. This is the Captain: owner, final authority, and administrator. Public registration closes after the Captain exists.

3. Providers

Connect one or more subscription accounts or API keys. You can add more later, pool accounts, select exact models, and build fallback or round-robin routes. There is no required single “AI brain.”

4. Workspace

Name the workspace. Terminals default on. 1Helm creates #main with the one Skipper, then you create ordinary channels with plain-language purposes. Every ordinary channel gets a private Linux computer.

5. Give an outcome

Try: “Audit this launch folder, turn the notes into a decision brief, resolve obvious gaps yourself, and give me the finished PDF with evidence.” The resident should inspect, execute, create the artifact, and call Skipper itself if it crosses the channel boundary.

`); const architecture = doc("/manual/architecture", "Architecture", "The 1Helm control plane, resident computers, Skipper, model fabric, memory, obligations, connections, and audit chain.", `

1Helm is a compact local control plane around many persistent employee worlds.

Captain
   └─ #main / Skipper ── host · credentials · fleet · cross-channel
        ├─ #product / resident ── private Linux computer ── /workspace
@@ -31,7 +31,7 @@ const connections = doc("/manual/connections", "Connections", "How Gmail, Photon
 
 const installMac = doc("/manual/install-macos", "Install on macOS", "Install the signed, notarized Apple Silicon 1Helm app and initialize per-channel Linux computers.", `

The native consumer product currently targets Apple Silicon Macs.

Requirements

  • Apple Silicon Mac (arm64).
  • macOS 26 for Apple's container runtime.
  • Administrator approval once during verified runtime installation.

Install

  1. Download the current DMG.
  2. Open it and drag 1Helm to Applications.
  3. Open 1Helm. Gatekeeper verifies the Developer ID signature and notarization ticket.
  4. Complete Captain → Providers → Workspace. Approve Apple's signed runtime inline if requested.

Data and upgrades

This generation stores application state under ~/Library/Application Support/1Helm-OCI-v1. Profile → Check for updates asks the Mac hosting 1Helm to download and verify the signed, notarized update. When the host reports it ready, Restart & install quiesces the local service and replaces the app. The browser is never given a DMG as the update action, and Application Support remains in place.

Removal

Use Settings → Admin → Prepare to remove 1Helm before trashing the app. This removes only verified 1Helm-owned channel machines while preserving the application state for a future reinstall.

${button("/download/macos", "Download current DMG", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); const installLinux = doc("/manual/install-linux", "Install on Linux", "Install 1Helm as a durable systemd service with one OCI container per resident.", `

Linux is a supported headless host product. It persists the control plane under systemd and gives every ordinary channel its own durable Podman container.

Supported baseline

Ubuntu or Debian with systemd and apt, cgroup v2, an x86-64 or arm64 CPU, 4 GiB RAM minimum (8 GiB recommended), and 20 GiB free disk. Each real workload needs additional storage. Nested deployments must permit Podman and delegated cgroups.

${code("linux-install", "curl -fsSLo /tmp/1helm-install.sh https://1helm.com/install.sh\nless /tmp/1helm-install.sh\nsudo bash /tmp/1helm-install.sh", "bash")}

The installer verifies architecture, installs an exact official Node runtime after checking its published SHA-256 manifest, installs Podman and the fixed root-owned OCI helper, creates a restricted 1helm service account, stores control-plane and runtime state in /var/lib/1helm-oci-v1, and atomically switches /opt/1helm/current.

Host-owned updates

A Captain update action creates one private request file. The host—not the browser—downloads the exact stable Linux release artifact, requires GitHub's SHA-256 asset digest, applies the fixed application and OCI contract, restarts, health-checks, and restores the prior release and runtime files on failure.

Open the UI

By default the service listens on port 8123. Use a firewall and an HTTPS reverse proxy before exposing it to the public internet. First boot opens Captain creation.

${code("linux-status", "sudo systemctl status 1helm --no-pager\ncurl -fsS http://127.0.0.1:8123/api/setup/status\nsudo journalctl -u 1helm -f", "bash")}

Back up and remove

Stop the service, then copy /var/lib/1helm-oci-v1 as one coherent unit. The installed /opt/1helm/uninstall-host.sh deletes only exact ownership-checked channel containers and preserves durable recovery state.

`); -const installWsl = doc("/manual/install-windows", "Install on Windows + WSL", "Native Windows hosting with one shared WSL 2 runtime and one OCI container per resident, including the one-time restart during first-run setup.", `

Windows 11 hosts one installation-scoped WSL 2 runtime. Every ordinary channel owns a distinct durable OCI container inside it. You do not need WSL installed first — 1Helm sets it up during first run, which includes one Windows restart.

Requirements

  • Windows 11 on an x64 processor. Arm64 Windows is not supported by this build.
  • Administrator approval once, during first-run setup.
  • One Windows restart during first-run setup on a PC where WSL was not already enabled.
  • Virtualization enabled in firmware, as WSL 2 requires.
  • Internet access to fetch Microsoft's pinned WSL 2 package and the pinned Ubuntu root filesystem.

Install

  1. Download the current Setup executable.
  2. Run it on Windows 11 x64. Windows Setup is not yet Authenticode signed, so SmartScreen shows “Windows protected your PC”. Choose More infoRun anyway. Every release discloses its Authenticode status; v0.0.38 is NotSigned. Only run an installer downloaded from this site or the project's GitHub releases.
  3. Open 1Helm and complete Captain → Providers → Workspace.

First run: the WSL 2 runtime and the restart

A typical new Windows 11 PC has both WSL and VirtualMachinePlatform turned off. 1Helm enables them itself when you reach the Workspace step — you do not install WSL beforehand. What you will see:

  1. One administrator prompt (UAC). Approve it. Only the Windows optional features and Microsoft's signed WSL package cross that boundary; the Linux distribution is imported as your ordinary signed-in account.
  2. A PowerShell window opens and reports progress. Leave it open until it finishes. 1Helm mirrors the same progress in the app.
  3. Windows asks to restart. Enabling those features is not complete until Windows restarts, so 1Helm stops and says so. This is the normal path on a new PC — it is not an error and nothing is lost.
  4. Restart, sign back in as the same Windows user, and open 1Helm. Continue setup and it picks up where it left off; already-completed steps are detected and skipped.

Setup then downloads Microsoft's pinned WSL 2 package and a pinned Ubuntu 24.04 root filesystem, verifies both against recorded SHA-256 digests, additionally requires a valid Microsoft Authenticode signature on the WSL package, imports the runtime, and installs Podman inside it.

Implemented host contract

  • Native x64 Electron host with Squirrel update packaging.
  • Administrator approval once to enable and verify Microsoft's pinned WSL 2 runtime.
  • One SHA-256-pinned Canonical Ubuntu root filesystem owned by the signed-in Windows account.
  • One separately labeled, mounted, and ownership-checked container per resident.
  • Windows-drive automount and Windows process interop disabled.
  • Exact installation/channel ownership checks for stop, deletion, and app removal.

The resident runs as UID/GID 1000 in /workspace. App state lives under %APPDATA%\\1Helm-OCI-v1, and the shared runtime disk lives under %LOCALAPPDATA%\\1Helm-Runtime.

Updates

Updates arrive through the app’s native update feed on the machine hosting 1Helm. The release notes record the installer’s Authenticode status. Application data under %APPDATA%\\1Helm-OCI-v1 and the runtime disk under %LOCALAPPDATA%\\1Helm-Runtime are preserved across updates.

Removal

Use Settings → Admin → Prepare to remove 1Helm first, then uninstall from Windows Settings → Apps → Installed apps. The uninstaller removes only 1Helm's own containers, unregisters its WSL runtime, and deletes %LOCALAPPDATA%\\1Helm-Runtime. Export irreplaceable channel files before you start.

Troubleshooting

Setup asked me to restart. Expected. Restart, sign back in as the same user, reopen 1Helm, and continue.

The PowerShell window closed early. Choose Retry shared runtime setup in onboarding or Settings → Channel computers. Setup is idempotent and resumes rather than starting over.

Setup reported a failure. Read the PowerShell window, then check %APPDATA%\\1Helm-OCI-v1\\windows-wsl-setup.log before retrying. A WSL 2 error mentioning an unavailable service or a missing required feature almost always means the restart has not happened yet.

${button("/download/windows", "Download for Windows", "primary")}${button("https://github.com/gitcommit90/1Helm/releases", "Release history ↗")}
`); +const installWsl = doc("/manual/install-windows", "Install on Windows", "Install 1Helm on Windows 11 x64 with one PowerShell command, run twice around a single restart. No application, no installer, no SmartScreen - your browser is the interface.", `

There is nothing to download and no Windows application to install. 1Helm runs its ordinary Linux build inside a WSL 2 distribution named 1helm, and your browser is the interface at http://localhost:8123. Because no .exe ships, nothing needs code signing and SmartScreen never appears.

What you need

  • Windows 11 on an x64 processor. Arm64 Windows is not supported by this build.
  • Virtualization enabled in firmware, as WSL 2 requires.
  • Internet access, and roughly 10 GB of free disk.
  • One Windows restart, partway through.
  • You do not need to install WSL first. The command below does that for you.

Install

The whole install is one command, run twice, with a Windows restart in between. This is the command:

${code("win-install", "irm https://1helm.com/install.ps1 | iex", "powershell")}

Now, in order:

  1. Open PowerShell. The ordinary one — do not choose “Run as Administrator”.
  2. Paste the command above and press Enter.
  3. A Windows permission pop-up appears. Click Yes.
  4. Wait about a minute. The window ends by printing “Restart required” and a short numbered list. That is normal. It is not an error and nothing is lost.
  5. Restart the PC.
  6. Sign back in as the same Windows user, and open PowerShell again.
  7. Paste the same command again and press Enter.
  8. Wait about six and a half minutes. Pages of apt output scroll past; that is normal progress. When it is finished it prints the address and opens your default browser on the onboarding page.
  9. Create the Captain, connect a provider, and name the workspace.

Measured end to end on a real Windows 11 machine: about 8 minutes 49 seconds, restart included.

Why the permission pop-up, and why only there

Exactly two operations need administrator rights: turning on the Windows optional features Microsoft-Windows-Subsystem-Linux and VirtualMachinePlatform, and installing Microsoft’s own WSL package. Those run in one separate elevated pass, and that pass is the only pop-up you see. Microsoft’s WSL installer is checked against a pinned SHA-256 and required to carry a valid Microsoft Authenticode signature before it is run.

Everything after that — importing the distribution, installing 1Helm inside it, registering the keepalive — deliberately runs as the signed-in user, because WSL state is per-user. A distribution imported by an elevated session started with different credentials would belong to that administrator instead of to the person using the machine. That is also why step 6 says the same Windows user.

Why the restart

Windows cannot activate those two features without restarting. The first run says so plainly and stops; the second run detects what is already done and carries on from there. Every step is idempotent, so running the command again is always safe.

Things you may see

  • Microsoft’s “Welcome to WSL” window. It may open during the second run. It is Microsoft’s own window, it is harmless, and you can close it.
  • A wait after 1Helm reports it is running. The channel-computer runtime needs roughly another 40 seconds to finish preparing before your first channel computer can be created. It has not hung.
  • Pages of apt output. That is the Linux installer doing the long step — the container runtime and the channel image.

If you download the script instead of piping it

Windows blocks running downloaded .ps1 files, so a saved copy needs the explicit form:

${code("win-bypass", "powershell -NoProfile -ExecutionPolicy Bypass -File .\\install.ps1", "powershell")}

The irm … | iex one-liner is unaffected, because it pipes a string into PowerShell rather than executing a file.

Using it

Open http://localhost:8123 in any browser on that PC; the installer also adds a Start Menu shortcut that opens the same address in your default browser. WSL tears an idle distribution down seconds after its last session closes, so the installer registers a per-user scheduled task — the keepalive — that holds the distribution open, starts again when you sign in, and restarts 1Helm’s service if it stops.

One behaviour difference from earlier versions: #main’s Terminal is now bash inside the WSL distribution, not cmd.exe. Windows commands do not work there. That is deliberate — the host is Linux now.

Updates

A Windows host updates exactly like a Linux host, because it is one: the root-owned updater inside the distribution downloads the exact stable Linux release artifact, requires its published SHA-256 digest, installs into a versioned directory, switches atomically, health-checks, and restores the previous release if anything fails. There is no Windows update feed and no Windows artifact, so there is nothing to sign and no signing status to disclose.

Uninstall

From an ordinary PowerShell window, signed in as the user who installed it:

${code("win-uninstall", "irm https://1helm.com/uninstall.ps1 | iex", "powershell")}

It stops the keepalive, runs 1Helm’s own Linux uninstaller inside the distribution so its containers and services come out cleanly, then unregisters the 1helm distribution and removes C:\\1helm and the Start Menu shortcut. Other WSL distributions on the PC are never touched, and Windows’ own WSL feature is left installed.

This destroys data. Unregistering the distribution deletes its virtual disk, and every channel’s files, the workspace database, and your provider credentials all live on that disk. There is no undo and nothing is copied to Windows first, so download anything irreplaceable from http://localhost:8123 before you start. The script asks you to type remove first; -Force skips that prompt and exists only for scripted removal.

Troubleshooting

It printed “Restart required” and stopped. That is the expected halfway point, not a failure. Restart, sign back in as the same Windows user, open PowerShell, and run the same command again.

“Running scripts is disabled on this system.” You are running a downloaded .ps1 file. Use the -ExecutionPolicy Bypass -File form above, or use the irm … | iex one-liner, which is not affected.

A “Welcome to WSL” window opened. That is Microsoft’s, not ours. Close it and carry on.

Port 8123 is already in use. Windows and every WSL distribution share one network namespace, so anything already listening on 8123 — another distribution, or an ordinary Windows process — stops 1Helm binding it. The installer refuses to continue rather than half-install, and names the port. Stop whatever owns it, then run the command again.

Do I need to install WSL first? No. Nothing to prepare, nothing to download.

The browser cannot reach the address. Give it a moment after the installer finishes, then ask the service inside the distribution how it is doing:

${code("win-status", "wsl -d 1helm -u root --exec systemctl status 1helm", "powershell")}

Arm64 Windows. Not supported by this build. The installer checks first and stops with that exact reason.

${button("/manual/getting-started", "Getting started", "primary")}${button("/manual/install-linux", "Linux guide")}
`); const selfHosting = doc("/manual/self-hosting", "Self-hosting", "Ports, state, backups, HTTPS, upgrades, health checks, and platform boundaries for self-hosted 1Helm.", `

1Helm is the server. The public 1helm.com website is documentation and release distribution, not a dependency of your installed workspace.

Ports

The source runtime defaults to 8123. Native desktop apps choose an ephemeral loopback port. The standalone product website uses 8130. These are separate processes and data trees.

State

Set CTRL_DATA_DIR to a persistent, restricted directory. Never place it in a public web root. Back it up only while the service is stopped or with a filesystem/database-consistent snapshot.

HTTPS

Use Settings → Domains for a workspace-managed Cloudflare tunnel, or put a conventional HTTPS reverse proxy in front of a headless host. Preserve WebSocket upgrades and do not strip Authorization headers.

Health

${code("health", "curl -fsS http://127.0.0.1:8123/api/setup/status\nsystemctl is-active 1helm\njournalctl -u 1helm --since '15 minutes ago'", "bash")}

Upgrades

Use a unique released version. Stop the service, take a state backup, install the tagged source, run npm ci and npm run build, then restart and verify health. Database migrations are additive, but rollback still requires the pre-upgrade data backup.

Resource guidance

A minimal control plane can run in 4 GiB RAM; 8 GiB is a more practical baseline. Model inference usually remains at connected providers, but browser automation, builds, media processing, and several concurrent residents increase CPU, RAM, and storage demand.

`); export const pages = { diff --git a/site/manual.html b/site/manual.html index 83fc990..1771a59 100644 --- a/site/manual.html +++ b/site/manual.html @@ -130,7 +130,7 @@

Files & Terminal

HostIsolation macOS (Apple Silicon)One Apple container machine per resident, no home-folder mount LinuxOne durable Podman OCI container per resident - WindowsOne shared managed WSL 2 runtime with one OCI container per resident; drive mounts and interop disabled + WindowsThe same Linux host, running inside a WSL 2 distribution: one durable Podman OCI container per resident

Different plumbing, same architecture: as far as the resident knows, one computer exists — its own. It can't see into yours.

@@ -178,34 +178,36 @@

Workflows & follow-ups

Updates & your data

-

Mac releases are signed, notarized, and verified before install — Profile → Check for updates downloads on the helm machine and offers Restart & install only when ready. Windows uses its native Squirrel feed and discloses Authenticode status in each release. Linux installs use a root-owned updater that verifies a release digest, installs into a versioned directory, switches atomically, health-checks, and rolls back if needed.

+

Mac releases are signed, notarized, and verified before install — Profile → Check for updates downloads on the helm machine and offers Restart & install only when ready. Linux installs use a root-owned updater that verifies a release digest, installs into a versioned directory, switches atomically, health-checks, and rolls back if needed. A Windows host uses that same Linux updater, because a Windows host is a Linux host running inside a WSL 2 distribution.

Every update preserves your data root:

macOS:  ~/Library/Application Support/1Helm-OCI-v1
 Linux:  /var/lib/1helm-oci-v1
-Windows: %APPDATA%\1Helm-OCI-v1
-
carefulThat directory holds databases, credentials, workspaces, and resident state. Never delete it during a reinstall or migration — and before removing 1Helm entirely, use the built-in removal flow and export irreplaceable channel files first.
+Windows: /var/lib/1helm-oci-v1, inside the "1helm" WSL distribution
+
carefulThat directory holds databases, credentials, workspaces, and resident state. Never delete it during a reinstall or migration — and before removing 1Helm entirely, use the built-in removal flow and export irreplaceable channel files first. On Windows the whole data root lives on the WSL distribution's virtual disk, so unregistering that distribution destroys it.

Security

    -
  • Resident computers have no access to your real machine — no home mount on macOS, ownership-checked OCI storage on Linux, and no drive mounts or interop on Windows.
  • +
  • Resident computers have no access to your real machine — no home mount on macOS, and ownership-checked OCI storage on Linux, including the Linux inside a Windows host's WSL 2 distribution.
  • Credentials and connectors are host-owned and minimally brokered; tokens never enter chat or resident computers.
  • Channel membership gates files, terminals, messages, and live events. Private coworker channels aren't Captain-readable without invitation.
  • External skills are revision-pinned, bounded, scanned, hashed, and wrapped.
  • Operational history enters a SHA-256 hash chain — Settings → Audit verifies it and pinpoints tampering.
  • -
  • Mac artifacts are Developer ID signed, notarized, stapled, and Gatekeeper-verified; Linux assets are digest-verified, and every Windows release discloses its Authenticode status.
  • +
  • Mac artifacts are Developer ID signed, notarized, stapled, and Gatekeeper-verified, and Linux assets are digest-verified. Windows ships no artifact of its own; its installer verifies Microsoft's WSL package against a pinned digest and a valid Microsoft Authenticode signature, and the pinned Ubuntu root filesystem against its digest.

Troubleshooting

-

Windows asked me to restart during setup

-

That's expected, and it's the normal path on a new PC. Windows 11 ships with WSL and VirtualMachinePlatform turned off; 1Helm turns them on for you, and Windows can't use them until it restarts. Restart, sign back in as the same Windows user, open 1Helm, and continue — setup picks up where it left off and skips what's already done. Nothing is lost.

-

Windows says "Windows protected your PC"

-

Windows Setup isn't Authenticode signed yet, so SmartScreen warns about it. Choose More infoRun anyway. Every release discloses its Authenticode status — v0.0.38 is NotSigned. Only run an installer you got from 1helm.com or the project's GitHub releases.

-

A PowerShell window opened on Windows

-

That window is the setup, and it shows live progress. Leave it open until it finishes — closing it early aborts the step. If it did close, choose Retry shared runtime setup in onboarding or Settings → Channel computers; setup resumes rather than starting over. The log is at %APPDATA%\1Helm-OCI-v1\windows-wsl-setup.log.

+

Windows printed "Restart required" and stopped

+

That's the expected halfway point of a Windows install, not a failure. Windows 11 ships with WSL and VirtualMachinePlatform turned off; the installer turns them on for you, and Windows can't use them until it restarts. Restart, sign back in as the same Windows user, open PowerShell, and run the same command again — it continues from where it stopped and skips what's already done. Nothing is lost.

+

Windows won't run install.ps1

+

If you downloaded the script, Windows blocks running .ps1 files and says running scripts is disabled. Run it as powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 instead. The irm https://1helm.com/install.ps1 | iex one-liner is unaffected, because it pipes a string into PowerShell rather than executing a file.

+

A "Welcome to WSL" window opened

+

That window is Microsoft's, not ours — WSL shows it the first time it sets itself up. It's harmless. Close it and let the install carry on.

+

Port 8123 is already in use on Windows

+

Windows and every WSL distribution share one network namespace, so anything already listening on 8123 — another distribution, or an ordinary Windows process — stops 1Helm binding it. The installer refuses to continue rather than half-install, and names the port. Stop whatever owns it, then run the command again.

macOS won't open the app

The Mac release is Developer ID signed, notarized, and stapled, so Gatekeeper should accept it once it's in Applications. If it doesn't, you likely have a partial or re-hosted download — get the DMG again from 1helm.com. Don't strip quarantine attributes or re-sign an ad-hoc build to get around a failed check.

The Linux installer refuses to run

@@ -232,17 +234,17 @@

FAQ

What operating systems does it run on? -

All three, day one: macOS (signed Apple Silicon app), Windows 11 (x64 installer with one shared WSL 2/Podman runtime), and Linux (native Podman with a systemd host and verified updater). Resident computers work the same way everywhere — one isolated computer per agent.

+

All three, day one: macOS (signed Apple Silicon app), Windows 11 x64 (one PowerShell command that installs the Linux build into a WSL 2 distribution, with your browser as the interface), and Linux (native Podman with a systemd host and verified updater). Resident computers work the same way everywhere — one isolated computer per agent.

Do I need to install WSL before installing on Windows? -

No. A stock Windows 11 has WSL and VirtualMachinePlatform turned off, and 1Helm enables them itself during setup. Expect three things: one administrator (UAC) prompt, a PowerShell window showing progress that you should leave open, and one Windows restart — Windows can't use the features it just enabled until it restarts. Restart, sign back in as the same user, reopen 1Helm, and setup continues where it left off. Setup executables aren't Authenticode signed yet, so SmartScreen warns once: More info → Run anyway. Full detail is in the Windows guide.

+

No — and there's nothing to download either. Open an ordinary PowerShell window (not "Run as Administrator") and run irm https://1helm.com/install.ps1 | iex. Expect three things: one Windows permission pop-up, which you click Yes to; a first run of about a minute that ends by printing "Restart required"; and then, after you restart and sign back in as the same user, the same command again, which takes about six and a half minutes and finishes by opening your browser at http://localhost:8123. About nine minutes in total. Because no Windows application ships, there's no installer to sign and SmartScreen never appears. Full detail is in the Windows guide.

How do I uninstall 1Helm? -

Start with Settings → Admin → Prepare to remove 1Helm, which prepares resident machines for safe deletion — export anything irreplaceable first. Then: on macOS drag the app to the Trash; on Windows uninstall from Settings → Apps, which also removes 1Helm's containers and its WSL runtime; on Linux run sudo /opt/1helm/uninstall-host.sh. macOS and Linux deliberately leave your data root in place for recovery — delete it yourself if you truly want it gone.

+

Start with Settings → Admin → Prepare to remove 1Helm, which prepares resident machines for safe deletion — export anything irreplaceable first. Then: on macOS drag the app to the Trash; on Linux run sudo /opt/1helm/uninstall-host.sh; on Windows run irm https://1helm.com/uninstall.ps1 | iex from an ordinary PowerShell window. macOS and Linux deliberately leave your data root in place for recovery — delete it yourself if you truly want it gone. Windows is the exception: it unregisters the 1helm WSL distribution, and that deletes the virtual disk your whole data root lives on, so it asks you to type remove first and there is no undo.

@@ -277,7 +279,7 @@

FAQ

What if my helm computer dies? -

Your data root (~/Library/Application Support/1Helm-OCI-v1 on macOS, /var/lib/1helm-oci-v1 on Linux, or %APPDATA%\1Helm-OCI-v1 plus %LOCALAPPDATA%\1Helm-Runtime on Windows) holds the control plane and resident worlds. Back it up as one coherent installation, reinstall 1Helm, and your crew comes back. Never delete those paths during a move.

+

Your data root (~/Library/Application Support/1Helm-OCI-v1 on macOS, or /var/lib/1helm-oci-v1 on Linux — including inside the 1helm WSL distribution on Windows) holds the control plane and resident worlds. Back it up as one coherent installation, reinstall 1Helm, and your crew comes back. Never delete that path during a move.

diff --git a/site/public/apply-linux-release.sh b/site/public/apply-linux-release.sh index ae7ff7c..6f82201 100755 --- a/site/public/apply-linux-release.sh +++ b/site/public/apply-linux-release.sh @@ -13,6 +13,11 @@ STATE_ROOT="/var/lib/1helm-oci-v1" SERVICE_USER="1helm" SERVICE_NAME="1helm.service" PORT="8123" +case "$(uname -m)" in + x86_64|amd64) NATIVE_ARCH="x64" ;; + aarch64|arm64) NATIVE_ARCH="arm64" ;; + *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; +esac RELEASE_ROOT="$(readlink -f "${1:-}" 2>/dev/null || true)" TARGET_VERSION="${2:-}" STATUS_FILE="$STATE_ROOT/host-update-status.json" @@ -138,6 +143,48 @@ cleanup_transaction() { } trap cleanup_transaction EXIT +# A retained release is ready to run: it carries its own production +# node_modules, its built client assets, and native addons compiled against the +# oldest supported glibc. Nothing is built here, so prove the release can +# actually run before the current symlink moves and the service restarts. This +# runs before the transaction is armed, so a release that fails this leaves the +# host exactly as it was and still reports why. +refuse_unrunnable_release() { + write_status "error" "$1" "$1" || true + echo "$1" >&2 + exit 1 +} +[[ -d "$RELEASE_ROOT/node_modules" \ + && -f "$RELEASE_ROOT/resources/linux-native-modules.json" \ + && -s "$RELEASE_ROOT/public/bundle.js" \ + && -s "$RELEASE_ROOT/public/app.css" ]] \ + || refuse_unrunnable_release "The retained v$TARGET_VERSION release is not ready to run: it is missing its vendored node_modules or its built client assets." +"$NODE_LINK/bin/node" - "$RELEASE_ROOT" "$NATIVE_ARCH" <<'NODE' \ + || refuse_unrunnable_release "The retained v$TARGET_VERSION release's Linux native addons cannot be loaded on this host, so terminals would not work." +const { existsSync, readFileSync } = require("node:fs"); +const { join } = require("node:path"); +const [, , releaseRoot, hostArch] = process.argv; +const manifest = JSON.parse(readFileSync(join(releaseRoot, "resources/linux-native-modules.json"), "utf8")); +if (manifest.platform !== "linux" || manifest.arch !== hostArch) { + throw new Error(`release ships ${manifest.platform}-${manifest.arch} native addons, host is linux-${hostArch}`); +} +if (String(manifest.nodeAbi) !== process.versions.modules) { + throw new Error(`release native addons target Node ABI ${manifest.nodeAbi}, installed Node reports ${process.versions.modules}`); +} +const required = "node_modules/node-pty/build/Release/pty.node"; +const paths = (manifest.modules || []).map((entry) => String(entry.path)); +if (!paths.includes(required)) throw new Error(`manifest does not list ${required}`); +for (const relative of paths) { + const file = join(releaseRoot, relative); + if (!existsSync(file)) throw new Error(`missing native addon ${relative}`); + process.dlopen({ exports: {} }, file); +} +if (typeof require(join(releaseRoot, "node_modules/node-pty")).spawn !== "function") { + throw new Error("node-pty did not expose spawn()"); +} +console.log(`verified ${paths.length} prebuilt native addons including a loadable node-pty`); +NODE + PREVIOUS_RELEASE="$(readlink -f "$APP_ROOT" 2>/dev/null || true)" [[ "$PREVIOUS_RELEASE" == "$RELEASES_ROOT/"* && -d "$PREVIOUS_RELEASE" ]] \ || { echo "The currently installed 1Helm release is not inside the verified release store." >&2; exit 1; } @@ -150,11 +197,21 @@ mv -Tf "$TEMP_ROOT/current" "$APP_ROOT" HELM_HOST_APPLY_DELEGATED=1 "$RELEASE_ROOT/site/public/install-linux-units.sh" "$RELEASE_ROOT" write_status "restarting" "The host installed v$TARGET_VERSION and is restarting 1Helm." systemctl restart "$SERVICE_NAME" +# Health must prove THIS unit is running, not merely that something answered on +# the port. A foreign listener - on Windows/WSL every distribution shares one +# network namespace - would otherwise let a crash-looping service pass. healthy=0 +unit_state="" for _ in {1..300}; do - if curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >/dev/null; then healthy=1; break; fi + unit_state="$(systemctl is-active "$SERVICE_NAME" 2>/dev/null || true)" + if [[ "$unit_state" == "active" ]] && curl -fsS "http://127.0.0.1:$PORT/api/setup/status" >/dev/null; then healthy=1; break; fi + [[ "$unit_state" == "failed" ]] && break sleep 0.2 done -[[ "$healthy" -eq 1 ]] || { echo "1Helm v$TARGET_VERSION failed its host health check." >&2; exit 1; } +if [[ "$healthy" -ne 1 ]]; then + echo "1Helm v$TARGET_VERSION failed its host health check; $SERVICE_NAME reports '${unit_state:-unknown}'." >&2 + journalctl -u "$SERVICE_NAME" -n 40 --no-pager >&2 2>/dev/null || true + exit 1 +fi TRANSACTION_ACTIVE=0 write_status "current" "This 1Helm host is running v$TARGET_VERSION." diff --git a/site/public/install.ps1 b/site/public/install.ps1 new file mode 100644 index 0000000..922ddce --- /dev/null +++ b/site/public/install.ps1 @@ -0,0 +1,455 @@ +<# +.SYNOPSIS + Install 1Helm on Windows 11 x64. + +.DESCRIPTION + 1Helm on Windows runs the ordinary Linux build inside a WSL 2 distribution + and serves its interface to the browser at http://localhost:8123. There is + no Windows application to install, so there is nothing to code-sign and + SmartScreen never appears. + + Usage, from an ordinary PowerShell window: + + irm https://1helm.com/install.ps1 | iex + + Windows cannot enable WSL 2 without restarting. When that is required this + script says so plainly and exits; run the same command again afterwards and + it continues from where it stopped. Every step is idempotent. + + Only two operations need administrator rights: enabling the Windows optional + features, and installing Microsoft's WSL package. Those run in a separate + elevated pass. Everything else - importing the distribution, installing + 1Helm inside it, registering the keepalive - deliberately runs as the + signed-in user, because WSL state is per-user: a distribution imported by an + elevated session started with different credentials would belong to that + administrator instead of the person using the machine. + +.PARAMETER InstallerUrl + Override the Linux installer fetched inside the distribution. For testing a + candidate build before it is published. + +.PARAMETER LocalArchive + Install from a local Linux release archive instead of resolving the current + published release. Requires -LocalInstaller. + +.PARAMETER LocalInstaller + Path to a local Linux installer script, used with -LocalArchive. + +.PARAMETER LocalArchiveSha256 + Optional SHA-256 the local archive must match, checked inside the + distribution before anything is installed. Catches a truncated or stale copy. + +.EXAMPLE + irm https://1helm.com/install.ps1 | iex + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 -LocalArchive C:\stage\1Helm-linux-node.tgz -LocalInstaller C:\stage\install.sh +#> +[CmdletBinding()] +param( + [string] $Distro = '1helm', + [string] $InstallRoot = 'C:\1helm', + [int] $HealthPort = 8123, + [string] $InstallerUrl = 'https://1helm.com/install.sh', + [string] $LocalArchive = '', + [string] $LocalInstaller = '', + [string] $LocalArchiveSha256 = '', + [string] $KeepaliveSource = '', + [switch] $HostSetup, + [string] $StatusPath = '' +) + +$ErrorActionPreference = 'Stop' +$env:WSL_UTF8 = '1' + +# Microsoft's WSL package and Canonical's root filesystem are pinned by digest. +# The WSL installer must additionally carry a valid Microsoft Authenticode +# signature: a digest alone only proves we fetched what we expected, not that +# Microsoft produced it. +$WslVersion = '2.7.10.0' +$WslInstallerUrl = 'https://github.com/microsoft/WSL/releases/download/2.7.10/wsl.2.7.10.0.x64.msi' +$WslInstallerSha = '1a62f90a43c03cc5bda47dfd0b6faf496ac70fd4389190518120a4f84fc895cf' +$RootfsUrl = 'https://cloud-images.ubuntu.com/wsl/releases/24.04/20240423/ubuntu-noble-wsl-amd64-wsl.rootfs.tar.gz' +$RootfsSha = '8251e27ffff381a4af5f41dcb94d867de3e0d9774a9241908ab34555d99315ea' + +$WslExe = Join-Path $env:SystemRoot 'System32\wsl.exe' +$Features = @('Microsoft-Windows-Subsystem-Linux', 'VirtualMachinePlatform') + +function Say { param([string]$m, [string]$c = 'Gray') Write-Host $m -ForegroundColor $c } +function Step { param([string]$m) Write-Host "==> $m" -ForegroundColor Cyan } +function Die { param([string]$m) Write-Host "" ; Write-Host "1Helm setup stopped: $m" -ForegroundColor Red ; exit 1 } + +function Test-Elevated { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + return ([Security.Principal.WindowsPrincipal]$id).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) +} + +# wsl.exe does not strip quotes from its own option values: `wsl -d "name"` +# looks for a distribution whose name literally contains the quotes. Quote only +# when whitespace genuinely requires it. +function ConvertTo-WslArg { param([string]$v) if ($v -match '\s') { return '"' + $v + '"' } return $v } + +function Get-FileWithDigest { + param([string]$Url, [string]$Destination, [string]$ExpectedSha) + if ((Test-Path $Destination) -and ((Get-FileHash $Destination -Algorithm SHA256).Hash.ToLowerInvariant() -eq $ExpectedSha)) { + Say " already downloaded and verified: $(Split-Path -Leaf $Destination)" + return + } + Say " downloading $(Split-Path -Leaf $Destination) ..." + $previous = $ProgressPreference + $ProgressPreference = 'SilentlyContinue' # orders of magnitude faster for large files + try { Invoke-WebRequest -Uri $Url -OutFile $Destination -UseBasicParsing } + finally { $ProgressPreference = $previous } + $actual = (Get-FileHash $Destination -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $ExpectedSha) { + Remove-Item $Destination -Force -ErrorAction SilentlyContinue + Die "$(Split-Path -Leaf $Destination) did not match its expected SHA-256. Expected $ExpectedSha, got $actual. Nothing was installed." + } + Say " verified SHA-256" +} + +# NOTE: Get-WindowsOptionalFeature -Online REQUIRES ELEVATION. It is authoritative +# only inside the elevated host-setup pass. The unelevated main pass must never +# call it - use Test-WslReady instead, which proves the same thing from signals a +# normal user can read. +function Test-FeatureEnabled { + param([string]$Name) + try { + $f = Get-WindowsOptionalFeature -Online -FeatureName $Name -ErrorAction Stop + return ([string]$f.State -eq 'Enabled') + } catch { return $false } +} + +# Unelevated-safe readiness probe. If Microsoft's pinned WSL is usable, the VM +# compute service exists, and Windows has no servicing restart pending, then the +# optional features are necessarily already enabled - vmcompute does not exist +# until VirtualMachinePlatform is active. Get-Service and an HKLM read both work +# without administrator rights. +function Test-WslReady { + if (-not (Test-PinnedWsl)) { return $false } + if ($null -eq (Get-Service -Name vmcompute -ErrorAction SilentlyContinue)) { return $false } + if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { return $false } + return $true +} + +# Windows cannot activate these features without a restart: they sit in +# EnablePending, DISM reports RestartRequired as the ambiguous "Possible", and +# the vmcompute service does not exist until the machine reboots. Any of those +# means "restart", not "broken". +function Test-RestartPending { + if ($null -eq (Get-Service -Name vmcompute -ErrorAction SilentlyContinue)) { return $true } + if (Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending') { return $true } + # Feature state is only readable with elevation, so this deeper check runs in + # the elevated pass. Unelevated callers rely on the two signals above. + if (Test-Elevated) { + foreach ($name in $Features) { + try { + $f = Get-WindowsOptionalFeature -Online -FeatureName $name -ErrorAction Stop + if ([string]$f.State -ne 'Enabled') { return $true } + $r = [string]$f.RestartRequired + if ($r -eq 'Required' -or $r -eq '1' -or $r -eq 'True') { return $true } + } catch { continue } + } + } + return $false +} + +function Test-PinnedWsl { + try { + $out = (& $WslExe --version 2>&1 | Out-String) -replace "`0", '' + return ($LASTEXITCODE -eq 0 -and $out -match [regex]::Escape($WslVersion)) + } catch { return $false } +} + +function Get-Distros { + try { + $raw = (& $WslExe --list --quiet 2>$null | Out-String) -replace "`0", '' + return @($raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } catch { return @() } +} + +function Invoke-InDistro { + param([string[]]$ShellArgs, [switch]$AsRoot) + $a = @('-d', (ConvertTo-WslArg $Distro)) + if ($AsRoot) { $a += @('-u', 'root') } + $a += @('--exec') + $ShellArgs + # The in-distro command's own output must go straight to the console, NOT + # into this function's output stream. A PowerShell function returns + # everything it emits, so without Out-Host `$code = Invoke-InDistro ...` + # collects every line the command printed *and* the exit code into one + # array. `$code -ne 0` is then an array filter, which is truthy for any + # non-empty output, so a completely successful install is reported as a + # failure with the whole transcript interpolated into the error message. + & $WslExe @a | Out-Host + return $LASTEXITCODE +} + +# --------------------------------------------------------------------------- +# Elevated pass: optional features and Microsoft's WSL package only. +# --------------------------------------------------------------------------- +if ($HostSetup) { + try { + if (-not (Test-Elevated)) { exit 3 } + $enabledNow = $false + foreach ($name in $Features) { + if (-not (Test-FeatureEnabled $name)) { + Write-Host " enabling $name ..." + $null = Enable-WindowsOptionalFeature -Online -FeatureName $name -All -NoRestart + $enabledNow = $true + } + } + if (-not (Test-PinnedWsl)) { + $tmp = Join-Path $env:TEMP ("1helm-wsl-" + [Guid]::NewGuid().ToString('N') + '.msi') + try { + $previous = $ProgressPreference; $ProgressPreference = 'SilentlyContinue' + try { Invoke-WebRequest -Uri $WslInstallerUrl -OutFile $tmp -UseBasicParsing } finally { $ProgressPreference = $previous } + $actual = (Get-FileHash $tmp -Algorithm SHA256).Hash.ToLowerInvariant() + if ($actual -ne $WslInstallerSha) { Write-Host "WSL installer digest mismatch: $actual"; exit 4 } + $sig = Get-AuthenticodeSignature -LiteralPath $tmp + if ($sig.Status -ne 'Valid' -or $null -eq $sig.SignerCertificate -or + $sig.SignerCertificate.Subject -notmatch '(^|,\s*)CN=Microsoft Corporation(,|$)') { + Write-Host "WSL installer is not validly signed by Microsoft Corporation"; exit 5 + } + Write-Host " installing Microsoft WSL $WslVersion ..." + $p = Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\msiexec.exe') ` + -ArgumentList @('/i', "`"$tmp`"", '/qn', '/norestart') -Wait -PassThru + # 0 ok; 1641/3010 ok + reboot; 1638 a same/newer package is present. + if ($p.ExitCode -in @(1641, 3010)) { $enabledNow = $true } + elseif ($p.ExitCode -notin @(0, 1638) -and -not (Test-PinnedWsl)) { + Write-Host "msiexec failed with exit code $($p.ExitCode)"; exit 6 + } + } finally { Remove-Item $tmp -Force -ErrorAction SilentlyContinue } + } + if ($enabledNow -or (Test-RestartPending)) { exit 10 } + exit 0 + } catch { + Write-Host "elevated host setup failed: $($_.Exception.Message)" + exit 7 + } +} + +# --------------------------------------------------------------------------- +# Main pass: runs as the signed-in user. +# --------------------------------------------------------------------------- +Write-Host "" +Say "1Helm for Windows" 'White' +Say "Runs the Linux build inside WSL 2. Your browser is the interface." 'DarkGray' +Write-Host "" + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +if ($identity.User.Value -eq 'S-1-5-18' -or $identity.Name -match '^NT AUTHORITY') { + Die "this installer must run as your own Windows account, not as $($identity.Name). 1Helm's WSL state is per-user." +} +if ([Environment]::Is64BitOperatingSystem -ne $true -or $env:PROCESSOR_ARCHITECTURE -notin @('AMD64', 'x86')) { + Die "1Helm requires 64-bit x64 Windows. Arm64 Windows is not supported by this build." +} +if ([Environment]::OSVersion.Version.Build -lt 22000) { + Die "1Helm requires Windows 11 (build 22000 or newer). This is build $([Environment]::OSVersion.Version.Build)." +} +if ($LocalArchive -and -not $LocalInstaller) { Die "-LocalArchive requires -LocalInstaller." } + +$null = New-Item -ItemType Directory -Path $InstallRoot -Force + +# --- 1. Windows features and Microsoft's WSL package (elevated) ------------- +Step "Checking Windows prerequisites" +if (Test-WslReady) { + Say " WSL 2 is already enabled and Microsoft WSL $WslVersion is installed" +} else { + $self = $PSCommandPath + if (-not $self) { + # Running from `irm | iex`, so there is no file on disk to re-invoke. + # Persist a copy so the elevated pass and any post-restart resume can + # both run the exact same script. + $self = Join-Path $InstallRoot 'install.ps1' + $MyInvocation.MyCommand.ScriptBlock.ToString() | Set-Content -LiteralPath $self -Encoding UTF8 + Say " saved a copy of this installer to $self" + } + Say " Windows needs administrator approval once, to enable WSL 2." 'Yellow' + # Deliberately not named $args: that is a PowerShell automatic variable. + $childArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', "`"$self`"", '-HostSetup', + '-Distro', (ConvertTo-WslArg $Distro), '-InstallRoot', "`"$InstallRoot`"") + $child = Start-Process -FilePath 'powershell.exe' -ArgumentList ($childArgs -join ' ') -Verb RunAs -Wait -PassThru + # -Wait is not dependable for an elevated ShellExecute launch, so block on + # the real handle before trusting the exit code. + if ($null -ne $child) { try { $child.WaitForExit() } catch { } } + $code = if ($null -eq $child) { $null } else { $child.ExitCode } + + if ($null -eq $code) { Die "administrator approval was cancelled, or Windows did not start the elevated step." } + if ($code -eq 10 -or (Test-RestartPending)) { + Write-Host "" + Say "Restart required" 'Yellow' + Say "Windows has enabled WSL 2 but cannot finish until it restarts. Nothing is lost." 'Yellow' + Write-Host "" + Say " 1. Restart this PC." + Say " 2. Sign back in as the same Windows user." + Say " 3. Run the same install command again - it continues from here." + Write-Host "" + exit 10 + } + if ($code -ne 0) { + switch ($code) { + 3 { Die "the elevated step did not receive administrator rights." } + 4 { Die "Microsoft's WSL installer did not match its expected digest. Nothing was installed." } + 5 { Die "Microsoft's WSL installer was not validly signed by Microsoft. Nothing was installed." } + 6 { Die "Microsoft's WSL installer failed. Try `wsl --install` manually, then run this again." } + default { Die "the elevated step failed (exit $code)." } + } + } + if (-not (Test-PinnedWsl)) { Die "Microsoft WSL $WslVersion is not usable in your session yet. Restart this PC and run the install command again." } + Say " WSL 2 is ready" +} + +# --- 2. Port pre-flight ----------------------------------------------------- +# Every WSL distribution shares ONE network namespace with Windows, so anything +# already listening on this port - another distribution, or a Windows process - +# will stop 1Helm binding it, and would answer the health probe in its place. +Step "Checking port $HealthPort is available" +$listener = $null +try { + $listener = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, $HealthPort) + $listener.Start() + Say " port $HealthPort is free" +} catch { + $owner = '' + try { + $owner = (Get-NetTCPConnection -LocalPort $HealthPort -State Listen -ErrorAction SilentlyContinue | + Select-Object -First 1 | ForEach-Object { (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName }) + } catch { } + if ((Get-Distros) -contains $Distro) { + Say " port $HealthPort is in use - assuming it is this 1Helm installation" 'Yellow' + } else { + Die "port $HealthPort is already in use$(if ($owner) { " by '$owner'" }). Windows and every WSL distribution share one network namespace, so 1Helm cannot bind it. Stop whatever is using port $HealthPort and run this again." + } +} finally { + if ($null -ne $listener) { try { $listener.Stop() } catch { } } +} + +# --- 3. The Linux distribution --------------------------------------------- +Step "Preparing the Linux runtime" +if ((Get-Distros) -contains $Distro) { + Say " distribution '$Distro' already exists" +} else { + $rootfs = Join-Path $InstallRoot 'ubuntu-noble-wsl-amd64.rootfs.tar.gz' + Get-FileWithDigest -Url $RootfsUrl -Destination $rootfs -ExpectedSha $RootfsSha + $distroDir = Join-Path $InstallRoot 'distro' + $null = New-Item -ItemType Directory -Path $distroDir -Force + Say " importing '$Distro' (this takes about a minute) ..." + & $WslExe --import (ConvertTo-WslArg $Distro) (ConvertTo-WslArg $distroDir) (ConvertTo-WslArg $rootfs) + if ($LASTEXITCODE -ne 0) { Die "could not import the Linux distribution (wsl --import exited $LASTEXITCODE)." } + Remove-Item $rootfs -Force -ErrorAction SilentlyContinue +} + +# systemd must be on before 1Helm's service can be managed. Writing wsl.conf +# needs a restart of the distribution to take effect. +$wslConfProbe = Invoke-InDistro -AsRoot -ShellArgs @('/bin/sh', '-c', 'grep -q "systemd=true" /etc/wsl.conf 2>/dev/null') +if ($wslConfProbe -ne 0) { + Say " enabling systemd ..." + $null = Invoke-InDistro -AsRoot -ShellArgs @('/bin/sh', '-c', 'printf "[boot]\nsystemd=true\n" > /etc/wsl.conf') + & $WslExe --terminate (ConvertTo-WslArg $Distro) | Out-Null + Start-Sleep -Seconds 3 +} +$initProbe = (& $WslExe -d (ConvertTo-WslArg $Distro) -u root --exec /bin/sh -c 'ps -p 1 -o comm=' 2>&1 | Out-String).Trim() +if ($initProbe -notmatch 'systemd') { Die "systemd is not running inside '$Distro' (PID 1 is '$initProbe'). Try `wsl --terminate $Distro` and run this again." } +Say " systemd is running" + +# --- 4. 1Helm itself, installed by the Linux installer --------------------- +Step "Installing 1Helm inside the Linux runtime" +Say " this is the long step - it installs the container runtime and imports the channel image" 'DarkGray' +if ($LocalArchive) { + if (-not (Test-Path $LocalArchive)) { Die "-LocalArchive not found: $LocalArchive" } + if (-not (Test-Path $LocalInstaller)) { Die "-LocalInstaller not found: $LocalInstaller" } + $stage = Join-Path $InstallRoot 'stage' + $null = New-Item -ItemType Directory -Path $stage -Force + Copy-Item $LocalArchive (Join-Path $stage (Split-Path -Leaf $LocalArchive)) -Force + Copy-Item $LocalInstaller (Join-Path $stage 'install-local.sh') -Force + $stageInDistro = '/mnt/' + $stage.Substring(0,1).ToLowerInvariant() + ($stage.Substring(2) -replace '\\','/') + if ($LocalArchiveSha256 -and $LocalArchiveSha256 -notmatch '^[a-fA-F0-9]{64}$') { + Die "-LocalArchiveSha256 is not a SHA-256 digest: $LocalArchiveSha256" + } + $shaEnv = if ($LocalArchiveSha256) { "HELM_RELEASE_SHA256=$($LocalArchiveSha256.ToLowerInvariant()) " } else { '' } + $code = Invoke-InDistro -AsRoot -ShellArgs @('/bin/sh', '-c', + "set -e; cp -f '$stageInDistro/install-local.sh' /tmp/i.sh; tr -d '\r' < /tmp/i.sh > /tmp/install.sh; ${shaEnv}bash /tmp/install.sh '$stageInDistro/$(Split-Path -Leaf $LocalArchive)'") +} else { + $code = Invoke-InDistro -AsRoot -ShellArgs @('/bin/bash', '-c', + "set -o pipefail; curl -fsSL '$InstallerUrl' | bash") +} +if ($code -ne 0) { Die "the Linux installer failed (exit $code). The output above shows why; nothing further was changed." } + +# --- 5. Keepalive ---------------------------------------------------------- +# WSL tears down an idle distribution seconds after its last session closes, +# which would stop the server. A per-user scheduled task holds it open. +Step "Registering the 1Helm keepalive" +$keepaliveDir = Join-Path $InstallRoot 'keepalive' +$null = New-Item -ItemType Directory -Path $keepaliveDir -Force +$keepaliveFiles = @('keepalive-install.ps1', 'keepalive-run.ps1', 'keepalive-remove.ps1', 'keepalive-hold.sh') + +# Fetched from the same origin as this installer, so the pair can never be +# mismatched. This is NOT optional: without the keepalive, WSL tears the +# distribution down when it goes idle and 1Helm silently stops answering. +if ($KeepaliveSource) { + foreach ($f in $keepaliveFiles) { + $src = Join-Path $KeepaliveSource $f + if (-not (Test-Path $src)) { Die "keepalive file missing from -KeepaliveSource: $src" } + Copy-Item $src (Join-Path $keepaliveDir $f) -Force + } + Say " using keepalive scripts from $KeepaliveSource" +} else { + $base = ($InstallerUrl -replace '/install\.sh$', '') + '/keepalive' + foreach ($f in $keepaliveFiles) { + $dest = Join-Path $keepaliveDir $f + try { + $previous = $ProgressPreference; $ProgressPreference = 'SilentlyContinue' + try { Invoke-WebRequest -Uri "$base/$f" -OutFile $dest -UseBasicParsing } finally { $ProgressPreference = $previous } + } catch { Die "could not download the keepalive component '$f' from $base ($($_.Exception.Message)). Without it 1Helm stops when its distribution goes idle." } + if (-not (Test-Path $dest) -or (Get-Item $dest).Length -eq 0) { Die "the keepalive component '$f' downloaded empty from $base." } + } + Say " downloaded keepalive components" +} + +# bash refuses CRLF in a shebang script. +$hold = Join-Path $keepaliveDir 'keepalive-hold.sh' +[IO.File]::WriteAllText($hold, ([IO.File]::ReadAllText($hold) -replace "`r`n", "`n"), (New-Object Text.UTF8Encoding($false))) + +& powershell.exe -NoProfile -ExecutionPolicy Bypass -File (Join-Path $keepaliveDir 'keepalive-install.ps1') ` + -Distro $Distro -HealthPort $HealthPort -InstallDir $keepaliveDir +if ($LASTEXITCODE -ne 0) { Die "the keepalive could not be registered (exit $LASTEXITCODE). 1Helm would stop when its distribution goes idle." } + +# --- 6. Shortcut and finish ---------------------------------------------- +Step "Finishing up" +try { + $programs = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' + $null = New-Item -ItemType Directory -Path $programs -Force + # A .lnk cannot address a URL. WScript.Shell accepts the assignment without + # complaint and then saves a shortcut whose TargetPath is empty, so the Start + # Menu entry appears and does nothing when clicked. An Internet Shortcut + # (.url) is the supported way to put an address in the Start Menu, and it + # opens in whichever browser the user has chosen as default. + $url = Join-Path $programs '1Helm.url' + Set-Content -LiteralPath $url -Encoding ASCII -Value @( + '[InternetShortcut]' + "URL=http://localhost:$HealthPort" + ) + # Clear the broken .lnk an earlier version of this installer left behind. + Remove-Item (Join-Path $programs '1Helm.lnk') -Force -ErrorAction SilentlyContinue + Say " added a Start Menu shortcut" +} catch { Say " could not create the Start Menu shortcut: $($_.Exception.Message)" 'Yellow' } + +$ready = $false +for ($i = 0; $i -lt 60; $i++) { + try { + $r = Invoke-WebRequest -Uri "http://localhost:$HealthPort/api/setup/status" -UseBasicParsing -TimeoutSec 5 + if ($r.StatusCode -eq 200) { $ready = $true; break } + } catch { } + Start-Sleep -Seconds 2 +} + +Write-Host "" +if ($ready) { + Say "1Helm is running at http://localhost:$HealthPort" 'Green' + try { Start-Process "http://localhost:$HealthPort" } catch { } +} else { + Say "1Helm is installed, but http://localhost:$HealthPort did not answer yet." 'Yellow' + Say "Give it a moment and open that address. If it stays unavailable, check:" 'Yellow' + Say " wsl -d $Distro -u root --exec systemctl status 1helm" +} +Write-Host "" diff --git a/site/public/install.sh b/site/public/install.sh index 62b13dd..58d4620 100644 --- a/site/public/install.sh +++ b/site/public/install.sh @@ -10,6 +10,17 @@ STATE_ROOT="/var/lib/1helm-oci-v1" SERVICE_USER="1helm" NODE_VERSION="22.23.1" RELEASE_METADATA_URL="https://1helm.com/api/releases/linux/latest" +# Install from a release archive already on this machine instead of resolving the +# current published release: +# +# install.sh /path/to/1Helm--linux-node.tgz +# +# This is how a candidate build is exercised before it is published, and it is +# what the Windows installer's -LocalArchive depends on. Resolving the published +# release stays the default. A local archive needs no network trust - it is +# already on this filesystem - so its version is read from its own package.json; +# set HELM_RELEASE_SHA256 to have the digest checked as well. +LOCAL_ARCHIVE="${1:-${HELM_LOCAL_ARCHIVE:-}}" HOST_CONTRACT_PATHS=( /usr/libexec/1helm-oci-runtime /etc/1helm/oci-runtime-v1.conf @@ -41,18 +52,29 @@ if ! command -v apt-get >/dev/null; then echo "1Helm's isolated Linux host currently requires Ubuntu or Debian with apt and systemd." >&2 exit 1 fi +# Checked here rather than where the archive is read, so a mistyped path fails +# now instead of after several minutes of package and Node installation. +if [[ -n "$LOCAL_ARCHIVE" && ! -f "$LOCAL_ARCHIVE" ]]; then + echo "Local release archive not found: $LOCAL_ARCHIVE" >&2 + exit 1 +fi case "$(uname -m)" in x86_64|amd64) NODE_ARCH="x64" ;; aarch64|arm64) NODE_ARCH="arm64" ;; *) echo "Unsupported architecture: $(uname -m)" >&2; exit 1 ;; esac -need=(curl tar xz sha256sum flock make c++ python3 podman crun fuse-overlayfs setfacl getfacl sudo visudo) +# No compiler is probed or installed. The Linux release arrives with its native +# addons already compiled against the oldest supported glibc, so nothing on this +# machine builds 1Helm and the C/C++ toolchain package is no longer a host +# dependency. python3 and python3-venv stay: install-oci-runtime.sh requires +# python3 and durable memory creates its own virtual environment at first use. +need=(curl tar xz sha256sum flock python3 podman crun fuse-overlayfs setfacl getfacl sudo visudo) missing=() for command in "${need[@]}"; do command -v "$command" >/dev/null || missing+=("$command"); done if ((${#missing[@]})) || ! python3 -c 'import ensurepip' >/dev/null 2>&1; then apt-get update - DEBIAN_FRONTEND=noninteractive apt-get install -y curl xz-utils ca-certificates util-linux build-essential python3 \ + DEBIAN_FRONTEND=noninteractive apt-get install -y curl xz-utils ca-certificates util-linux python3 \ python3-venv acl aardvark-dns crun fuse-overlayfs netavark podman uidmap sudo rsync fi @@ -105,7 +127,9 @@ rollback_host_contract() { if [[ "$(cat "$RUNTIME_BACKUP/units/1helm.service.active" 2>/dev/null || true)" == "active" ]]; then restored_healthy=0 for _ in {1..300}; do - if curl -fsS http://127.0.0.1:8123/api/setup/status >/dev/null; then restored_healthy=1; break; fi + # "connection refused" is the expected state while it comes back up, so + # curl's own errors are noise here, not diagnosis. + if curl -fsS http://127.0.0.1:8123/api/setup/status >/dev/null 2>&1; then restored_healthy=1; break; fi sleep 0.2 done fi @@ -146,6 +170,23 @@ fi ln -sfn "$NODE_RELEASE" "$TEMP_ROOT/node-current" mv -Tf "$TEMP_ROOT/node-current" "$NODE_LINK" +if [[ -n "$LOCAL_ARCHIVE" ]]; then + RELEASE_ARCHIVE="$LOCAL_ARCHIVE" + # The digest is not a trust check here - it names the release directory, and it + # lets an operator who pinned HELM_RELEASE_SHA256 catch a truncated or stale copy. + RELEASE_SHA256="$(sha256sum -- "$RELEASE_ARCHIVE" | awk '{ print $1 }')" + [[ "$RELEASE_SHA256" =~ ^[a-f0-9]{64}$ ]] \ + || { echo "Could not compute the SHA-256 of $RELEASE_ARCHIVE." >&2; exit 1; } + if [[ -n "${HELM_RELEASE_SHA256:-}" && "$HELM_RELEASE_SHA256" != "$RELEASE_SHA256" ]]; then + echo "$RELEASE_ARCHIVE does not match the digest you pinned in HELM_RELEASE_SHA256." >&2 + echo "Expected $HELM_RELEASE_SHA256, got $RELEASE_SHA256. Nothing was installed." >&2 + exit 1 + fi + # Resolved from the archive's own package.json once it is unpacked, below. + VERSION="" +else +# Resolve the current published release. Left unindented because the embedded +# heredoc's NODE terminator has to stay at column 0. curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$TEMP_ROOT/release.json" "$RELEASE_METADATA_URL" RELEASE_OUTPUT="$("$NODE_LINK/bin/node" - "$TEMP_ROOT/release.json" <<'NODE' const fs = require("node:fs"); @@ -170,12 +211,28 @@ RELEASE_ARCHIVE="$TEMP_ROOT/1Helm-$VERSION-linux-node.tgz" curl -fsSL --proto '=https' --tlsv1.2 --retry 3 -o "$RELEASE_ARCHIVE" "$RELEASE_URL" printf '%s %s\n' "$RELEASE_SHA256" "$(basename "$RELEASE_ARCHIVE")" \ | (cd "$TEMP_ROOT" && sha256sum -c -) +fi RELEASE_STAGE="$TEMP_ROOT/source" install -d -o "$SERVICE_USER" -g "$SERVICE_USER" -m 0750 "$RELEASE_STAGE" tar -xzf "$RELEASE_ARCHIVE" -C "$RELEASE_STAGE" --strip-components=1 PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' "$RELEASE_STAGE/package.json" 2>/dev/null || true)" -[[ "$PACKAGE_VERSION" == "$VERSION" ]] || { echo "The verified Linux artifact version does not match v$VERSION." >&2; exit 1; } +if [[ -z "$VERSION" ]]; then + # Local archive: it carries no release metadata, so it names its own version. + [[ "$PACKAGE_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || { + echo "$RELEASE_ARCHIVE does not declare a release version in its package.json." >&2 + echo "Nothing was installed. Rebuild it with npm run package:linux." >&2 + exit 1 + } + VERSION="$PACKAGE_VERSION" + echo "Installing 1Helm v$VERSION from $RELEASE_ARCHIVE" +else + [[ "$PACKAGE_VERSION" == "$VERSION" ]] || { + echo "This archive contains 1Helm ${PACKAGE_VERSION:-an unreadable version} but the release metadata resolved v$VERSION." >&2 + echo "Nothing was installed. Retry, or report the mismatch if it persists: the published artifact does not match its release." >&2 + exit 1 + } +fi [[ -x "$RELEASE_STAGE/site/public/apply-linux-release.sh" \ && -x "$RELEASE_STAGE/site/public/install-oci-runtime.sh" \ && -x "$RELEASE_STAGE/site/public/install-linux-units.sh" \ @@ -188,8 +245,56 @@ PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' " && -x "$RELEASE_STAGE/resources/cloudflared-linux-$NODE_ARCH" ]] \ || { echo "The verified Linux artifact is missing its complete OCI runtime contract." >&2; exit 1; } chown -R "$SERVICE_USER:$SERVICE_USER" "$RELEASE_STAGE" -runuser -u "$SERVICE_USER" -- env HOME="$STATE_ROOT" PATH="$NODE_LINK/bin:/usr/bin:/bin" PUPPETEER_SKIP_DOWNLOAD=1 "$NODE_LINK/bin/npm" --prefix "$RELEASE_STAGE" ci -runuser -u "$SERVICE_USER" -- env HOME="$STATE_ROOT" PATH="$NODE_LINK/bin:/usr/bin:/bin" "$NODE_LINK/bin/npm" --prefix "$RELEASE_STAGE" run build + +# The release arrives ready to run: no npm ci, no npm run build, no compiler and +# no npm registry on the install path. The archive carries its own production +# node_modules whose native addons were compiled against the oldest supported +# glibc, plus every built client asset. This fails closed instead of falling +# back to an on-host build: the host no longer installs a compiler, so a +# fallback would spend several minutes reaching the same failure with a worse +# message. +verify_ready_to_run() { + local candidate="$1" + [[ -d "$candidate/node_modules" && -f "$candidate/resources/linux-native-modules.json" ]] \ + || { echo "This 1Helm archive carries no vendored node_modules, so it predates ready-to-run Linux releases." >&2 + echo "Download the current release; this installer no longer builds 1Helm on your machine." >&2 + return 1; } + [[ -s "$candidate/public/bundle.js" && -s "$candidate/public/app.css" ]] \ + || { echo "This 1Helm archive is missing its built client assets (public/bundle.js, public/app.css)." >&2 + echo "Download the current release; this installer no longer builds 1Helm on your machine." >&2 + return 1; } + if ! "$NODE_LINK/bin/node" - "$candidate" "$NODE_ARCH" <<'NODE' +const { existsSync, readFileSync } = require("node:fs"); +const { join } = require("node:path"); +const [, , releaseRoot, hostArch] = process.argv; +const manifest = JSON.parse(readFileSync(join(releaseRoot, "resources/linux-native-modules.json"), "utf8")); +if (manifest.platform !== "linux" || manifest.arch !== hostArch) { + throw new Error(`release ships ${manifest.platform}-${manifest.arch} native addons, host is linux-${hostArch}`); +} +if (String(manifest.nodeAbi) !== process.versions.modules) { + throw new Error(`release native addons target Node ABI ${manifest.nodeAbi}, installed Node reports ${process.versions.modules}`); +} +const required = "node_modules/node-pty/build/Release/pty.node"; +const paths = (manifest.modules || []).map((entry) => String(entry.path)); +if (!paths.includes(required)) throw new Error(`manifest does not list ${required}`); +for (const relative of paths) { + const file = join(releaseRoot, relative); + if (!existsSync(file)) throw new Error(`missing native addon ${relative}`); + process.dlopen({ exports: {} }, file); +} +if (typeof require(join(releaseRoot, "node_modules/node-pty")).spawn !== "function") { + throw new Error("node-pty did not expose spawn()"); +} +console.log(`verified ${paths.length} prebuilt native addons including a loadable node-pty`); +NODE + then + echo "The Linux native addons shipped with this release cannot be loaded on this host, so" >&2 + echo "terminals and channel computers would not work. The installation was refused." >&2 + return 1 + fi +} +verify_ready_to_run "$RELEASE_STAGE" || exit 1 + RELEASE_ROOT="$RELEASES_ROOT/$VERSION-$RELEASE_SHA256" if [[ -e "$RELEASE_ROOT" ]]; then EXISTING_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' "$RELEASE_ROOT/package.json" 2>/dev/null || true)" @@ -198,6 +303,10 @@ if [[ -e "$RELEASE_ROOT" ]]; then && -f "$RELEASE_ROOT/container/channel-machine.oci.sha256" \ && -x "$RELEASE_ROOT/resources/cloudflared-linux-$NODE_ARCH" ]] \ || { echo "Existing release directory does not match the verified v$VERSION Linux artifact." >&2; exit 1; } + # A retained directory from an interrupted earlier run can be incomplete even + # though its name carries the verified digest. Prove it is still runnable + # before any host file is touched. + verify_ready_to_run "$RELEASE_ROOT" || exit 1 else mv "$RELEASE_STAGE" "$RELEASE_ROOT" fi @@ -225,14 +334,51 @@ ln -s "$RELEASE_ROOT" "$TEMP_ROOT/current" mv -Tf "$TEMP_ROOT/current" "$APP_ROOT" "$RELEASE_ROOT/site/public/install-linux-units.sh" "$RELEASE_ROOT" systemctl enable --now 1helm-update.path -systemctl restart 1helm.service + +# Stop first so the port check below sees the truth, then refuse to start on top +# of a foreign listener. WSL 2 puts every distribution in ONE shared network +# namespace, so a service in another distribution - or on Windows itself - can +# own 8123 here. Without this check the readiness probe is answered by that +# foreign listener and the install reports success while 1helm.service +# crash-loops on EADDRINUSE. The probe binds the port the way the service will +# rather than parsing `ss`, which is not guaranteed to be installed and would +# otherwise skip this check in silence; `ss` is used only to name the culprit. +systemctl stop 1helm.service >/dev/null 2>&1 || true +if ! "$NODE_LINK/bin/node" -e ' +const server = require("node:net").createServer(); +server.once("error", (error) => { console.error(error.code || error.message); process.exit(1); }); +server.listen(8123, () => server.close(() => process.exit(0)));' >/dev/null 2>&1; then + echo "Port 8123 is already in use before 1Helm started, so 1helm.service cannot bind it." >&2 + # Only mention the shared network namespace where it is actually true. On a + # native host this advice is confusing noise; under WSL it is the likely cause, + # because Windows and every distribution share one namespace. + if [[ "$(systemd-detect-virt --container 2>/dev/null || true)" == "wsl" ]]; then + echo "Windows and every WSL distribution share one network namespace, so the listener may be" >&2 + echo "in another distribution or a Windows process. Stop whatever owns port 8123." >&2 + else + echo "Stop whatever is listening on port 8123, then run this again." >&2 + fi + command -v ss >/dev/null 2>&1 && ss -ltnp 2>/dev/null | grep -E "[:.]8123[[:space:]]" >&2 || true + exit 1 +fi +systemctl start 1helm.service + +# Readiness must prove THIS service is healthy, not merely that something +# answered on 8123. healthy=0 +unit_state="" for _ in {1..300}; do - if curl -fsS http://127.0.0.1:8123/api/setup/status >/dev/null; then healthy=1; break; fi + unit_state="$(systemctl is-active 1helm.service 2>/dev/null || true)" + # Same here: until the unit finishes binding the port every attempt fails, and + # curl printing that hundreds of times buries the real outcome. + if [[ "$unit_state" == "active" ]] && curl -fsS http://127.0.0.1:8123/api/setup/status >/dev/null 2>&1; then healthy=1; break; fi + [[ "$unit_state" == "failed" ]] && break sleep 0.2 done if [[ "$healthy" -ne 1 ]]; then echo "1Helm v$VERSION did not become healthy; the previous release was restored when available." >&2 + echo "1helm.service reports '${unit_state:-unknown}'. Recent journal:" >&2 + journalctl -u 1helm.service -n 40 --no-pager >&2 2>/dev/null || true exit 1 fi TRANSACTION_ACTIVE=0 diff --git a/site/public/keepalive/keepalive-hold.sh b/site/public/keepalive/keepalive-hold.sh new file mode 100644 index 0000000..f66e829 --- /dev/null +++ b/site/public/keepalive/keepalive-hold.sh @@ -0,0 +1,39 @@ +#!/bin/sh +# --------------------------------------------------------------------------- +# 1Helm WSL keepalive - in-distro anchor + service watchdog. +# +# Runs INSIDE the WSL distro, as root, started by keepalive-run.ps1 via: +# wsl.exe -d -u root --exec /bin/sh +# +# It has exactly two jobs: +# 1. EXIST. WSL tears a distro down ~15s after its last session closes. +# As long as this process is alive the distro stays booted. +# 2. WATCH. Make sure the 1Helm systemd unit is actually active, and start +# it again if something inside the distro killed it. +# +# Deliberately /bin/sh + coreutils only: no bash-isms, no extra packages. +# --------------------------------------------------------------------------- +SERVICE="${1:-1helm.service}" +INTERVAL="${2:-20}" +TAG="1helm-keepalive" + +log() { logger -t "$TAG" -- "$*" 2>/dev/null || true; } + +log "anchor started (pid $$) service=$SERVICE interval=${INTERVAL}s" + +# Give systemd a moment to finish booting on a cold distro start before the +# first verdict, otherwise we race the unit's own auto-start. +i=0 +while [ "$i" -lt 30 ]; do + [ "$(systemctl is-system-running 2>/dev/null)" = "starting" ] || break + i=$((i + 1)) + sleep 1 +done + +while :; do + if ! systemctl is-active --quiet "$SERVICE" 2>/dev/null; then + log "$SERVICE is not active - starting it" + systemctl start "$SERVICE" >/dev/null 2>&1 || log "failed to start $SERVICE" + fi + sleep "$INTERVAL" +done diff --git a/site/public/keepalive/keepalive-install.ps1 b/site/public/keepalive/keepalive-install.ps1 new file mode 100644 index 0000000..f71bdc5 --- /dev/null +++ b/site/public/keepalive/keepalive-install.ps1 @@ -0,0 +1,188 @@ +<# +.SYNOPSIS + Install the 1Helm WSL keepalive as a per-user Scheduled Task and start it. + +.DESCRIPTION + Registers "\1Helm\1Helm-WSL-Keepalive" running as the *signed-in user* + (LogonType Interactive, RunLevel Limited): + + Trigger 1 At logon of this user -> requirement 1 + Trigger 2 Every 1 minute, indefinitely -> requirement 3 (crash recovery) + Settings MultipleInstances = IgnoreNew -> requirement 4 (single instance) + ExecutionTimeLimit = unlimited (default 72h would kill it) + RestartOnFailure 3 x 1 min -> second recovery layer + + RunLevel Limited + a task owned by the calling user means registration needs + no administrator rights and raises no UAC prompt. + + Idempotent: safe to run repeatedly. It tears down any previous supervisor + and anchor first, so a second run replaces rather than stacks. + + -Unattended switches the principal to LogonType S4U ("run whether the user + is logged on or not", no stored password). Still the user's own account, so + WSL state stays user-scoped - it is NOT Local System - but there is no + interactive desktop. Only for kiosk/headless boxes where nobody signs in. + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File .\keepalive-install.ps1 + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File .\keepalive-install.ps1 -Unattended +#> +[CmdletBinding()] +param( + [string] $Distro = '1helm', + [string] $Service = '1helm.service', + [int] $HealthPort = 8123, + [string] $InstallDir = 'C:\1helm\keepalive', + [string] $TaskPath = '\1Helm\', + [string] $TaskName = '1Helm-WSL-Keepalive', + [int] $WaitSeconds = 180, + [switch] $Unattended +) + +$ErrorActionPreference = 'Stop' +$env:WSL_UTF8 = '1' + +function Say { param([string]$m, [string]$c = 'Gray') Write-Host $m -ForegroundColor $c } + +# --- guard: never Local System ------------------------------------------- +$me = [Security.Principal.WindowsIdentity]::GetCurrent() +if ($me.User.Value -eq 'S-1-5-18' -or $me.Name -match 'NT AUTHORITY') { + throw "REFUSING to install as '$($me.Name)'. 1Helm's WSL backend is user-scoped; the keepalive must run as the signed-in user, never as Local System." +} +$userId = $me.Name # DOMAIN\user +Say "Installing 1Helm WSL keepalive as: $userId" 'Cyan' + +# --- lay down the payload ------------------------------------------------- +if (-not $PSScriptRoot) { $PSScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path } +$null = New-Item -ItemType Directory -Path $InstallDir -Force + +foreach ($f in 'keepalive-run.ps1', 'keepalive-hold.sh', 'keepalive-remove.ps1') { + $src = Join-Path $PSScriptRoot $f + $dst = Join-Path $InstallDir $f + if ((Test-Path $src) -and ($src -ne $dst)) { Copy-Item $src $dst -Force } + if (-not (Test-Path $dst)) { throw "missing required file: $dst" } +} + +# bash will not tolerate CRLF in a shebang script - force LF, no BOM. +$holdDst = Join-Path $InstallDir 'keepalive-hold.sh' +$txt = [IO.File]::ReadAllText($holdDst) -replace "`r`n", "`n" +[IO.File]::WriteAllText($holdDst, $txt, (New-Object Text.UTF8Encoding($false))) +Say "Payload installed in $InstallDir" + +$runScript = Join-Path $InstallDir 'keepalive-run.ps1' + +# --- tear down anything already running (no stacking) --------------------- +$existing = Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue +if ($existing) { + Say "Existing task found - stopping and replacing it" 'Yellow' + try { Stop-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue } catch { } + try { Unregister-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue } catch { } +} +Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -like '*keepalive-run.ps1*' -and $_.ProcessId -ne $PID } | + ForEach-Object { Say " killing stale supervisor pid $($_.ProcessId)"; Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } +Get-CimInstance Win32_Process -Filter "Name='wsl.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -like '*keepalive-hold.sh*' } | + ForEach-Object { Say " killing stale anchor pid $($_.ProcessId)"; Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + +# --- build and register the task ----------------------------------------- +$psExe = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' +$argLine = '-NoProfile -NonInteractive -WindowStyle Hidden -ExecutionPolicy Bypass -File "{0}" -Distro "{1}" -Service "{2}" -HealthPort {3}' -f ` + $runScript, $Distro, $Service, $HealthPort + +$action = New-ScheduledTaskAction -Execute $psExe -Argument $argLine -WorkingDirectory $InstallDir + +$trgLogon = New-ScheduledTaskTrigger -AtLogOn -User $userId + +# "Repeat every 1 minute, indefinitely". Note: -RepetitionDuration +# ([TimeSpan]::MaxValue) serializes to P99999999DT23H59M59S and the Task +# Scheduler rejects it outright. A *null* Duration is the encoding for +# "indefinitely", so build the trigger with a throwaway duration and clear it. +$trgRepeat = New-ScheduledTaskTrigger -Once -At (Get-Date).Date ` + -RepetitionInterval (New-TimeSpan -Minutes 1) ` + -RepetitionDuration (New-TimeSpan -Days 1) +$trgRepeat.Repetition.Duration = $null +$trgRepeat.Repetition.StopAtDurationEnd = $false + +$triggers = @($trgLogon, $trgRepeat) +if ($Unattended) { $triggers += (New-ScheduledTaskTrigger -AtStartup) } + +# Default: InteractiveToken. The task runs only while the user is signed in, +# which is exactly right for the browser-GUI model - no signed-in user means no +# browser to serve, and it guarantees we can never end up running as SYSTEM. +# +# -Unattended: LogonType S4U ("run whether the user is logged on or not", +# *without* a stored password). Still the user's own account and user-scoped WSL +# state - NOT Local System - but it runs in session 0 with no interactive +# desktop. Use only for kiosk/headless boxes where nobody ever signs in. +# Registering S4U needs elevation; the default Interactive path does not. +if ($Unattended) { + Say "Mode: UNATTENDED (LogonType S4U - runs as $userId with no interactive session)" 'Yellow' + $principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType S4U -RunLevel Limited +} else { + $principal = New-ScheduledTaskPrincipal -UserId $userId -LogonType Interactive -RunLevel Limited +} + +$settings = New-ScheduledTaskSettingsSet ` + -MultipleInstances IgnoreNew ` + -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries ` + -StartWhenAvailable -DontStopOnIdleEnd ` + -ExecutionTimeLimit ([TimeSpan]::Zero) ` + -RestartCount 3 -RestartInterval (New-TimeSpan -Minutes 1) +$settings.DisallowStartOnRemoteAppSession = $false +$settings.Enabled = $true + +# Register-ScheduledTask is CIM-backed, so $ErrorActionPreference = 'Stop' does +# NOT make it terminating: without an explicit -ErrorAction it writes an error +# and execution continues, and the script goes on to report success. That +# matters because registration genuinely fails with Access denied (0x80070005) +# when the task folder was created by an elevated process and this run is not +# elevated - a real upgrade path. Never infer registration from the health +# probe below either: a stale task left in place also answers on the port. +try { + $null = Register-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName ` + -Action $action -Trigger $triggers ` + -Principal $principal -Settings $settings ` + -Description "Holds the $Distro WSL 2 distro open for the signed-in user and keeps $Service active, so http://localhost:$HealthPort stays reachable." ` + -Force -ErrorAction Stop +} catch { + Say "FAILED to register $TaskPath$TaskName : $($_.Exception.Message)" 'Red' + if ($_.Exception.Message -match 'Access is denied|0x80070005') { + Say "The task folder $TaskPath was most likely created by an elevated process." 'Yellow' + Say "Re-run this installer as Administrator, or remove the existing task first with keepalive-remove.ps1." 'Yellow' + } + exit 1 +} + +# Prove the task actually exists and carries our action before claiming success. +$registered = Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue +if (-not $registered) { + Say "FAILED: $TaskPath$TaskName is not present after registration reported no error." 'Red' + exit 1 +} +Say "Registered scheduled task $TaskPath$TaskName" 'Green' + +# --- start it now --------------------------------------------------------- +Start-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName +Say "Task started - waiting up to ${WaitSeconds}s for http://localhost:$HealthPort ..." 'Cyan' + +$deadline = (Get-Date).AddSeconds($WaitSeconds) +$ok = $false +while ((Get-Date) -lt $deadline) { + try { + $r = Invoke-WebRequest -Uri ("http://localhost:{0}/" -f $HealthPort) -UseBasicParsing -TimeoutSec 5 -ErrorAction Stop + if ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) { $ok = $true; break } + } catch { } + Start-Sleep -Seconds 3 +} + +if ($ok) { + Say "OK: $Distro is up and http://localhost:$HealthPort is answering." 'Green' +} else { + Say "WARNING: task registered and started, but :$HealthPort did not answer within ${WaitSeconds}s." 'Red' + Say " Check $InstallDir\keepalive.log" 'Red' +} +Say "Remove with: powershell -NoProfile -ExecutionPolicy Bypass -File $InstallDir\keepalive-remove.ps1" +if (-not $ok) { exit 1 } diff --git a/site/public/keepalive/keepalive-remove.ps1 b/site/public/keepalive/keepalive-remove.ps1 new file mode 100644 index 0000000..6d9d696 --- /dev/null +++ b/site/public/keepalive/keepalive-remove.ps1 @@ -0,0 +1,130 @@ +<# +.SYNOPSIS + Remove the 1Helm WSL keepalive: stop the holder, unregister the task, + leave nothing behind. + +.DESCRIPTION + Reverses keepalive-install.ps1 completely and non-interactively: + 1. stop + unregister the scheduled task, and delete the \1Helm\ task + folder if it is left empty; + 2. kill the supervisor (powershell running keepalive-run.ps1); + 3. kill the Windows-side anchor (wsl.exe running keepalive-hold.sh); + 4. kill the in-distro anchor shell, but only if the distro is already + running - we must not boot it just to clean up; + 5. optionally terminate the distro (-TerminateDistro) and delete the + install directory (-PurgeFiles). + + Without -TerminateDistro the distro is simply left unheld, and WSL will + idle it out on its own in ~15 seconds. + +.NOTES + NEVER calls `wsl --shutdown`. -TerminateDistro is a targeted + `wsl --terminate ` and refuses any distro matching + -ProtectedDistroPattern (default: anything ending in "-runtime"). +#> +[CmdletBinding()] +param( + [string] $Distro = '1helm', + [string] $InstallDir = 'C:\1helm\keepalive', + [string] $TaskPath = '\1Helm\', + [string] $TaskName = '1Helm-WSL-Keepalive', + [string] $ProtectedDistroPattern = '-runtime$', + [switch] $TerminateDistro, + [switch] $PurgeFiles +) + +$ErrorActionPreference = 'Continue' +$env:WSL_UTF8 = '1' +$WslExe = Join-Path $env:SystemRoot 'System32\wsl.exe' +function Say { param([string]$m, [string]$c = 'Gray') Write-Host $m -ForegroundColor $c } + +Say "Removing 1Helm WSL keepalive for distro '$Distro'" 'Cyan' + +# 1. scheduled task --------------------------------------------------------- +$task = Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue +if ($task) { + try { Stop-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue } catch { } + Unregister-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + Say " unregistered $TaskPath$TaskName" 'Green' +} else { + Say " no task $TaskPath$TaskName (already gone)" +} +# drop the \1Helm\ folder if we left it empty +try { + $svc = New-Object -ComObject Schedule.Service + $svc.Connect() + $leaf = $TaskPath.Trim('\') + if ($leaf) { + $root = $svc.GetFolder('\') + $sub = $root.GetFolder($leaf) + if ($sub.GetTasks(1).Count -eq 0 -and $sub.GetFolders(0).Count -eq 0) { + $root.DeleteFolder($leaf, 0) + Say " removed empty task folder $TaskPath" 'Green' + } + } +} catch { } + +# 2. supervisor ------------------------------------------------------------- +$sup = Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -like '*keepalive-run.ps1*' -and $_.ProcessId -ne $PID } +foreach ($p in $sup) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue; Say " killed supervisor pid $($p.ProcessId)" 'Green' } +if (-not $sup) { Say " no supervisor running" } + +# 3. Windows-side anchor ---------------------------------------------------- +$anch = Get-CimInstance Win32_Process -Filter "Name='wsl.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -like '*keepalive-hold.sh*' } +foreach ($p in $anch) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue; Say " killed anchor pid $($p.ProcessId)" 'Green' } +if (-not $anch) { Say " no anchor running" } + +# 4. in-distro anchor - only if the distro is already up -------------------- +$running = $false +try { + $raw = (& $WslExe --list --running --quiet 2>$null | Out-String) -replace "`0", '' + foreach ($l in ($raw -split "`r?`n")) { if ($l.Trim() -eq $Distro) { $running = $true } } +} catch { } +if ($running) { + try { + & $WslExe -d $Distro -u root --exec /usr/bin/pkill -f keepalive-hold.sh 2>&1 | Out-Null + Say " cleaned in-distro anchor shell" 'Green' + } catch { } +} else { + Say " distro '$Distro' is not running - nothing to clean inside it" +} + +# 5. optional distro terminate --------------------------------------------- +if ($TerminateDistro) { + if ([string]::IsNullOrWhiteSpace($Distro) -or $Distro -match $ProtectedDistroPattern) { + Say " REFUSING to terminate protected distro '$Distro'" 'Red' + } else { + & $WslExe --terminate $Distro 2>&1 | Out-Null # never --shutdown + Say " terminated distro '$Distro'" 'Green' + } +} + +# 6. optional file purge ---------------------------------------------------- +foreach ($f in 'keepalive.log', 'keepalive.log.1', 'anchor.out.log', 'anchor.err.log', 'anchor.stdin') { + Remove-Item (Join-Path $InstallDir $f) -Force -ErrorAction SilentlyContinue +} +if ($PurgeFiles) { + if ($PSScriptRoot -and ($PSScriptRoot.TrimEnd('\') -ieq $InstallDir.TrimEnd('\'))) { + # We are running from inside the directory we are deleting; schedule it. + $cmd = 'Start-Sleep -Seconds 3; Remove-Item -LiteralPath "{0}" -Recurse -Force -ErrorAction SilentlyContinue' -f $InstallDir + Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') ` + -ArgumentList ('-NoProfile -WindowStyle Hidden -Command "{0}"' -f $cmd) -WindowStyle Hidden + Say " install directory will be deleted in 3s: $InstallDir" 'Green' + } else { + Remove-Item -LiteralPath $InstallDir -Recurse -Force -ErrorAction SilentlyContinue + Say " deleted $InstallDir" 'Green' + } +} + +# --- residual state report ------------------------------------------------- +Say "" +Say "Residual state:" 'Cyan' +$t = Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue +Say (" scheduled task : {0}" -f $(if ($t) { 'STILL PRESENT' } else { 'gone' })) +$s = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -EA SilentlyContinue | Where-Object { $_.CommandLine -like '*keepalive-run.ps1*' -and $_.ProcessId -ne $PID }) +Say (" supervisor processes: {0}" -f $s.Count) +$a = @(Get-CimInstance Win32_Process -Filter "Name='wsl.exe'" -EA SilentlyContinue | Where-Object { $_.CommandLine -like '*keepalive-hold.sh*' }) +Say (" anchor processes : {0}" -f $a.Count) +Say "Done." 'Green' diff --git a/site/public/keepalive/keepalive-run.ps1 b/site/public/keepalive/keepalive-run.ps1 new file mode 100644 index 0000000..bb104e1 --- /dev/null +++ b/site/public/keepalive/keepalive-run.ps1 @@ -0,0 +1,258 @@ +<# +.SYNOPSIS + 1Helm WSL keepalive supervisor - the process the Scheduled Task runs. + +.DESCRIPTION + Windows tears down an idle WSL 2 distro ~15 seconds after its last session + closes. 1Helm on Windows runs the ordinary Linux build inside a WSL distro + with the browser as the GUI, so something must hold that distro open for + the whole logon session. This script is that something. + + Two supervision layers: + + Windows side (this script) + - keeps exactly one "anchor" wsl.exe session alive, restarting it if it + dies for any reason; + - every DeepCheckEvery ticks, verifies the distro is listed as running + AND http://localhost:/ answers; + - escalates to a guarded distro recycle if that keeps failing. + + Linux side (keepalive-hold.sh, launched as the anchor) + - exists, which is what actually pins the distro up; + - re-starts the systemd unit if it stops. + + Runs as the signed-in user. Never as Local System: WSL state is user-scoped, + so a SYSTEM-context distro would be a different instance with different + localhost forwarding. + +.NOTES + This script NEVER calls `wsl --shutdown` - that would kill every distro on + the box including other users' runtimes. Recovery is only ever a targeted + `wsl --terminate `, and even that refuses to touch a distro whose + name matches -ProtectedDistroPattern. +#> +[CmdletBinding()] +param( + [string] $Distro = '1helm', + [string] $Service = '1helm.service', + [int] $HealthPort = 8123, + [int] $PollSeconds = 10, + [int] $DeepCheckEvery = 6, + [int] $MaxDeepFailures = 6, + [int] $HoldInterval = 20, + [string] $ProtectedDistroPattern = '-runtime$', + [string] $LogPath +) + +# --- bootstrap ------------------------------------------------------------- +$ErrorActionPreference = 'Continue' +$env:WSL_UTF8 = '1' # make wsl.exe emit UTF-8, not UTF-16LE +$WslExe = Join-Path $env:SystemRoot 'System32\wsl.exe' +if (-not $PSScriptRoot) { $PSScriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path } +if (-not $LogPath) { $LogPath = Join-Path $PSScriptRoot 'keepalive.log' } +$HoldScriptWin = Join-Path $PSScriptRoot 'keepalive-hold.sh' + +function Write-Log { + param([string]$Message, [string]$Level = 'INFO') + $line = '{0} [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message + try { + if ((Test-Path $LogPath) -and ((Get-Item $LogPath).Length -gt 1MB)) { + Move-Item -Path $LogPath -Destination "$LogPath.1" -Force -ErrorAction SilentlyContinue + } + Add-Content -Path $LogPath -Value $line -Encoding UTF8 -ErrorAction SilentlyContinue + } catch { } +} + +function Hide-ConsoleWindow { + # Belt and braces: the Scheduled Task action already passes -WindowStyle + # Hidden, this makes sure nothing is left on screen if it is ever launched + # another way. Child console processes we spawn with -NoNewWindow inherit + # this (hidden) console, so they stay invisible too. + try { + if (-not ('OneHelm.Native' -as [type])) { + Add-Type -Namespace OneHelm -Name Native -MemberDefinition @' +[DllImport("kernel32.dll")] public static extern System.IntPtr GetConsoleWindow(); +[DllImport("user32.dll")] public static extern bool ShowWindow(System.IntPtr hWnd, int nCmdShow); +'@ + } + $h = [OneHelm.Native]::GetConsoleWindow() + if ($h -ne [IntPtr]::Zero) { [void][OneHelm.Native]::ShowWindow($h, 0) } + } catch { } +} + +function ConvertTo-WslPath { + param([string]$WindowsPath) + $full = [System.IO.Path]::GetFullPath($WindowsPath) + '/mnt/' + $full.Substring(0, 1).ToLowerInvariant() + ($full.Substring(2) -replace '\\', '/') +} + +function Test-DistroRunning { + try { + $raw = (& $WslExe --list --running --quiet 2>$null | Out-String) -replace "`0", '' + foreach ($l in ($raw -split "`r?`n")) { if ($l.Trim() -eq $Distro) { return $true } } + } catch { } + return $false +} + +function Test-Health { + try { + $r = Invoke-WebRequest -Uri ("http://localhost:{0}/" -f $HealthPort) ` + -UseBasicParsing -TimeoutSec 8 -ErrorAction Stop + return ($r.StatusCode -ge 200 -and $r.StatusCode -lt 500) + } catch { return $false } +} + +$AnchorOut = Join-Path $PSScriptRoot 'anchor.out.log' +$AnchorErr = Join-Path $PSScriptRoot 'anchor.err.log' +$AnchorIn = Join-Path $PSScriptRoot 'anchor.stdin' + +function ConvertTo-WslArg { + # wsl.exe does NOT strip surrounding double quotes from its own option + # values: `wsl -d "my-distro"` fails with WSL_E_DISTRO_NOT_FOUND because it + # looks for a distro whose name literally includes the quote characters. + # So quote only when there is whitespace that actually needs it. + param([string]$Value) + if ($Value -match '\s') { return '"' + $Value + '"' } + return $Value +} + +function Start-Anchor { + $holdWsl = ConvertTo-WslPath $HoldScriptWin + $argLine = '-d {0} -u root --exec /bin/sh {1} {2} {3}' -f ` + (ConvertTo-WslArg $Distro), (ConvertTo-WslArg $holdWsl), (ConvertTo-WslArg $Service), $HoldInterval + + # wsl.exe requires VALID standard handles. If the supervisor was itself + # started without a console or with broken handles (Win32_Process.Create, + # some task hosts, service wrappers), the inherited handles are invalid and + # wsl.exe dies instantly with no message - which looks exactly like "the + # distro refuses to boot". Redirecting all three streams to real files makes + # the anchor behave identically in every host context, and captures the + # anchor's own stderr so a failure is diagnosable instead of silent. + if (-not (Test-Path $AnchorIn)) { New-Item -ItemType File -Path $AnchorIn -Force | Out-Null } + foreach ($f in @($AnchorOut, $AnchorErr)) { + if ((Test-Path $f) -and ((Get-Item $f).Length -gt 256KB)) { Remove-Item $f -Force -ErrorAction SilentlyContinue } + } + + $p = Start-Process -FilePath $WslExe -ArgumentList $argLine -NoNewWindow -PassThru ` + -RedirectStandardInput $AnchorIn ` + -RedirectStandardOutput $AnchorOut ` + -RedirectStandardError $AnchorErr + Write-Log ("anchor started: wsl.exe pid={0} -> {1}" -f $p.Id, $holdWsl) + return $p +} + +function Get-AnchorError { + foreach ($f in @($AnchorErr, $AnchorOut)) { + if (Test-Path $f) { + $t = (Get-Content $f -Tail 3 -ErrorAction SilentlyContinue) -join ' | ' + if ($t -and $t.Trim()) { return $t.Trim() } + } + } + return '(no output captured)' +} + +function Stop-Anchor { + param($Proc) + if ($null -ne $Proc) { + try { if (-not $Proc.HasExited) { Stop-Process -Id $Proc.Id -Force -ErrorAction SilentlyContinue } } catch { } + } + # wsl.exe re-execs itself; sweep any orphan anchors for THIS distro only. + try { + Get-CimInstance Win32_Process -Filter "Name='wsl.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -and $_.CommandLine -like '*keepalive-hold.sh*' -and $_.CommandLine -like "*$Distro*" } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue } + } catch { } +} + +function Restart-Distro { + # SAFETY: targeted terminate only. `wsl --shutdown` is never used anywhere + # in this project - it would kill every distro for every user on the box. + if ([string]::IsNullOrWhiteSpace($Distro) -or $Distro -match $ProtectedDistroPattern) { + Write-Log "REFUSING to terminate protected/blank distro '$Distro'" 'ERROR' + return + } + Write-Log "terminating distro '$Distro' to recover it" 'WARN' + try { & $WslExe --terminate $Distro 2>&1 | Out-Null } catch { Write-Log "terminate failed: $_" 'ERROR' } + Start-Sleep -Seconds 3 +} + +# --- single instance ------------------------------------------------------- +# Local\ (per-session) is exactly the right scope: one holder per logon session, +# and it needs no privilege, unlike Global\. +$mutexName = "Local\1Helm-Keepalive-$Distro" +$mutex = New-Object System.Threading.Mutex($false, $mutexName) +$owned = $false +try { $owned = $mutex.WaitOne(0) } catch [System.Threading.AbandonedMutexException] { $owned = $true } +if (-not $owned) { + Write-Log "another supervisor already holds $mutexName - exiting (single-instance guard)" 'WARN' + exit 0 +} + +Hide-ConsoleWindow +Write-Log "=== supervisor start === pid=$PID user=$env:USERNAME session=$([System.Diagnostics.Process]::GetCurrentProcess().SessionId) distro=$Distro service=$Service" + +if (-not (Test-Path $HoldScriptWin)) { + Write-Log "anchor script missing: $HoldScriptWin" 'ERROR' + exit 1 +} + +# --- main loop ------------------------------------------------------------- +$anchor = $null +$tick = 0 +$deepFail = 0 +try { + while ($true) { + try { + if ($null -eq $anchor -or $anchor.HasExited) { + if ($null -ne $anchor) { + Write-Log ("anchor pid={0} exited (code={1}) - respawning. anchor output: {2}" -f ` + $anchor.Id, $anchor.ExitCode, (Get-AnchorError)) 'WARN' + } + $anchor = Start-Anchor + Start-Sleep -Seconds 5 + } + + $tick++ + if (($tick % $DeepCheckEvery) -eq 0) { + $running = Test-DistroRunning + $healthy = $running -and (Test-Health) + if ($healthy) { + if ($deepFail -gt 0) { Write-Log "recovered: distro running and :$HealthPort answering" } + $deepFail = 0 + } else { + $deepFail++ + Write-Log ("health check FAILED #{0} (distroRunning={1})" -f $deepFail, $running) 'WARN' + # Do not restart a unit that is still coming up. A cold distro + # boot plus 1Helm's own start took 33-80s on an 8-core test box + # and will be slower on modest hardware, so an unconditional + # restart here interrupts the very startup we are waiting for. + # "activating" means systemd is working on it; leave it alone. + if ($running) { + $unitState = '' + try { $unitState = (& $WslExe -d $Distro -u root --exec /usr/bin/systemctl is-active $Service 2>&1 | Out-String).Trim() } catch { } + if ($unitState -match 'activating') { + Write-Log "$Service is still activating - waiting rather than restarting it" + $deepFail-- + } else { + try { & $WslExe -d $Distro -u root --exec /usr/bin/systemctl restart $Service 2>&1 | Out-Null } catch { } + } + } + if ($deepFail -ge $MaxDeepFailures) { + Write-Log "escalating: recycling anchor and distro" 'WARN' + Stop-Anchor $anchor + $anchor = $null + Restart-Distro + $deepFail = 0 + } + } + } + } catch { + Write-Log "loop error: $($_.Exception.Message)" 'ERROR' + } + Start-Sleep -Seconds $PollSeconds + } +} finally { + Write-Log "=== supervisor exiting (pid=$PID) ===" 'WARN' + try { $mutex.ReleaseMutex() } catch { } + try { $mutex.Dispose() } catch { } +} diff --git a/site/public/uninstall.ps1 b/site/public/uninstall.ps1 new file mode 100644 index 0000000..ca0bd77 --- /dev/null +++ b/site/public/uninstall.ps1 @@ -0,0 +1,388 @@ +<# +.SYNOPSIS + Remove 1Helm from Windows 11 x64. + +.DESCRIPTION + Reverses install.ps1. 1Helm on Windows is the ordinary Linux build running + inside a WSL 2 distribution, so removing it means four things, in this order: + + 1. stop and unregister the keepalive that holds the distribution open; + 2. run 1Helm's own Linux uninstaller INSIDE the distribution, so it + removes its containers and systemd units cleanly while it still can; + 3. terminate and unregister the distribution; + 4. delete C:\1helm and the Start Menu entry. + + Usage, from an ordinary PowerShell window signed in as the same Windows user + that installed 1Helm: + + irm https://1helm.com/uninstall.ps1 | iex + + THIS DESTROYS DATA. Unregistering the distribution deletes its virtual disk, + and every channel's files, the workspace database, provider credentials and + resident memory live on that disk. There is no undo and nothing is copied to + Windows first. The script asks for typed confirmation before it starts; pass + -Force to skip the prompt for scripted use. + + Every step is idempotent: running this twice, or running it on a PC where + 1Helm was never installed, is safe and simply reports that there was nothing + to do. + +.PARAMETER Distro + The WSL distribution to remove. Must match an existing distribution name + exactly, character for character, or nothing is unregistered. + +.PARAMETER Force + Skip the typed confirmation. For scripted and unattended removal. + +.NOTES + This script NEVER calls `wsl --shutdown`. That stops every distribution for + every user on the machine, including other people's work that has nothing to + do with 1Helm. Recovery and removal are only ever a targeted + `wsl --terminate ` and `wsl --unregister `, and both refuse a + blank name, a name matching -ProtectedDistroPattern, or any name that is not + an exact match for a currently registered distribution. + +.EXAMPLE + irm https://1helm.com/uninstall.ps1 | iex + +.EXAMPLE + powershell -NoProfile -ExecutionPolicy Bypass -File .\uninstall.ps1 -Force +#> +[CmdletBinding()] +param( + [string] $Distro = '1helm', + [string] $InstallRoot = 'C:\1helm', + [string] $TaskPath = '\1Helm\', + [string] $TaskName = '1Helm-WSL-Keepalive', + [string] $ProtectedDistroPattern = '-runtime$', + [switch] $Force +) + +# A removal must keep going and report honestly rather than stop at the first +# thing that is already gone. Every step below checks its own outcome. +$ErrorActionPreference = 'Continue' +$env:WSL_UTF8 = '1' + +$WslExe = Join-Path $env:SystemRoot 'System32\wsl.exe' + +function Say { param([string]$m, [string]$c = 'Gray') Write-Host $m -ForegroundColor $c } +function Step { param([string]$m) Write-Host "==> $m" -ForegroundColor Cyan } +function Die { param([string]$m) Write-Host "" ; Write-Host "1Helm removal stopped: $m" -ForegroundColor Red ; exit 1 } + +# $Kept is strictly "still on this machine after we finished", because that is +# what a person needs to act on. Anything that went wrong but left nothing behind +# is a warning, not residue. +$Removed = New-Object System.Collections.Generic.List[string] +$Kept = New-Object System.Collections.Generic.List[string] +$Warnings = New-Object System.Collections.Generic.List[string] +function Add-Removed { param([string]$m) $Removed.Add($m) } +function Add-Kept { param([string]$m) $Kept.Add($m) } +function Add-Warning { param([string]$m) $Warnings.Add($m) } + +# wsl.exe does not strip quotes from its own option values: `wsl -d "name"` +# looks for a distribution whose name literally contains the quotes. Quote only +# when whitespace genuinely requires it. +function ConvertTo-WslArg { param([string]$v) if ($v -match '\s') { return '"' + $v + '"' } return $v } + +function Get-Distros { + try { + $raw = (& $WslExe --list --quiet 2>$null | Out-String) -replace "`0", '' + return @($raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ }) + } catch { return @() } +} + +function Test-DistroRunning { + try { + $raw = (& $WslExe --list --running --quiet 2>$null | Out-String) -replace "`0", '' + foreach ($l in ($raw -split "`r?`n")) { if ($l.Trim() -ceq $Distro) { return $true } } + } catch { } + return $false +} + +# SAFETY GATE. Nothing destructive touches a distribution that does not pass +# this, and the failure is reported rather than worked around. The same three +# checks the keepalive supervisor uses before its targeted terminate, plus an +# exact, case-sensitive match against the live registration list - so a typo, a +# similarly named distribution, or an empty -Distro can never take out somebody +# else's runtime. +function Test-SafeTarget { + param([string]$Name) + if ([string]::IsNullOrWhiteSpace($Name)) { + Say " REFUSING to act on a blank distribution name" 'Red' + return $false + } + if ($Name -match $ProtectedDistroPattern) { + Say " REFUSING to act on protected distribution '$Name' (matches $ProtectedDistroPattern)" 'Red' + return $false + } + $exact = @(Get-Distros | Where-Object { $_ -ceq $Name }) + if ($exact.Count -ne 1) { + Say " REFUSING to act on '$Name': it is not an exact match for one registered distribution" 'Red' + return $false + } + return $true +} + +# --------------------------------------------------------------------------- +# Guards +# --------------------------------------------------------------------------- +Write-Host "" +Say "Remove 1Helm for Windows" 'White' +Write-Host "" + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +if ($identity.User.Value -eq 'S-1-5-18' -or $identity.Name -match '^NT AUTHORITY') { + Die "this must run as your own Windows account, not as $($identity.Name). 1Helm's WSL state is per-user, so a SYSTEM session cannot see - or remove - the distribution that belongs to you." +} + +$distroPresent = ((Get-Distros) -contains $Distro) +$installRootThere = (Test-Path -LiteralPath $InstallRoot) +$taskThere = ($null -ne (Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue)) + +if (-not $distroPresent -and -not $installRootThere -and -not $taskThere) { + Say "Nothing to remove: no '$Distro' distribution, no $InstallRoot, no $TaskPath$TaskName." 'Green' + Say "1Helm is not installed for $($identity.Name)." 'Green' + Write-Host "" + exit 0 +} + +# --------------------------------------------------------------------------- +# Confirmation +# --------------------------------------------------------------------------- +if (-not $Force) { + Say "This permanently deletes:" 'Yellow' + Say " - the WSL distribution '$Distro' and its whole virtual disk;" 'Yellow' + Say " - every channel's files and workspace, including anything a resident made;" 'Yellow' + Say " - the 1Helm database: accounts, channels, threads, memory and activity;" 'Yellow' + Say " - the provider accounts and API keys stored on this machine;" 'Yellow' + Say " - $InstallRoot and the Start Menu shortcut." 'Yellow' + Write-Host "" + Say "Nothing is backed up and there is no undo. Copy out anything you want to" 'Yellow' + Say "keep first - open http://localhost:8123 and download it." 'Yellow' + Write-Host "" + $answer = '' + try { $answer = Read-Host "Type remove to continue, or press Enter to cancel" } catch { + Die "this host cannot ask for confirmation, and a removal this destructive is never assumed. Run it again from an ordinary PowerShell window, or add -Force if you are certain." + } + if ($answer.Trim().ToLowerInvariant() -ne 'remove') { + Write-Host "" + Say "Cancelled. Nothing was changed." 'Green' + Write-Host "" + exit 0 + } + Write-Host "" +} + +# --------------------------------------------------------------------------- +# 1. The keepalive +# --------------------------------------------------------------------------- +# First, because it exists to hold the distribution open and to restart +# 1helm.service whenever it stops. Leaving it running would fight every step +# below. +Step "Stopping the 1Helm keepalive" +$keepaliveDir = Join-Path $InstallRoot 'keepalive' +$keepaliveRemove = Join-Path $keepaliveDir 'keepalive-remove.ps1' +$keepaliveDone = $false + +if (Test-Path -LiteralPath $keepaliveRemove) { + # The keepalive owns its own teardown, including its scheduled task, its + # supervisor, both anchors and its task folder. Deliberately without + # -TerminateDistro: the Linux uninstaller still has to run inside it. + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $keepaliveRemove ` + -Distro $Distro -InstallDir $keepaliveDir -TaskPath $TaskPath -TaskName $TaskName | Out-Host + if ($LASTEXITCODE -eq 0) { + $keepaliveDone = $true + Add-Removed "keepalive (via its own keepalive-remove.ps1)" + } else { + Say " keepalive-remove.ps1 exited $LASTEXITCODE - removing the task directly instead" 'Yellow' + } +} + +if (-not $keepaliveDone) { + # No keepalive-remove.ps1 on disk, or it failed. Do the same work here so a + # partial or hand-deleted install still ends up clean. + if ($taskThere) { + try { Stop-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue } catch { } + Unregister-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + if ($null -eq (Get-ScheduledTask -TaskPath $TaskPath -TaskName $TaskName -ErrorAction SilentlyContinue)) { + Say " unregistered $TaskPath$TaskName" 'Green' + Add-Removed "scheduled task $TaskPath$TaskName" + } else { + Say " could not unregister $TaskPath$TaskName" 'Yellow' + Add-Kept "scheduled task $TaskPath$TaskName" + } + } else { + Say " no scheduled task $TaskPath$TaskName" + } + + # Drop the \1Helm\ task folder if this left it empty. + try { + $svc = New-Object -ComObject Schedule.Service + $svc.Connect() + $leaf = $TaskPath.Trim('\') + if ($leaf) { + $root = $svc.GetFolder('\') + $sub = $root.GetFolder($leaf) + if ($sub.GetTasks(1).Count -eq 0 -and $sub.GetFolders(0).Count -eq 0) { + $root.DeleteFolder($leaf, 0) + Say " removed empty task folder $TaskPath" 'Green' + } + } + } catch { } + + # The supervisor and the Windows-side anchor outlive their task. + $sup = @(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -like '*keepalive-run.ps1*' -and $_.ProcessId -ne $PID }) + foreach ($p in $sup) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } + if ($sup.Count -gt 0) { Say " stopped $($sup.Count) keepalive supervisor process(es)" 'Green' } + + $anch = @(Get-CimInstance Win32_Process -Filter "Name='wsl.exe'" -ErrorAction SilentlyContinue | + Where-Object { $_.CommandLine -like '*keepalive-hold.sh*' -and $_.CommandLine -like "*$Distro*" }) + foreach ($p in $anch) { Stop-Process -Id $p.ProcessId -Force -ErrorAction SilentlyContinue } + if ($anch.Count -gt 0) { Say " stopped $($anch.Count) keepalive anchor process(es)" 'Green' } + + if ($taskThere -or $sup.Count -gt 0 -or $anch.Count -gt 0) { Add-Removed "keepalive task and processes" } +} + +# --------------------------------------------------------------------------- +# 2. 1Helm's own Linux uninstaller, inside the distribution +# --------------------------------------------------------------------------- +# It knows its installation id, so it deletes only the containers and units that +# belong to this installation, and it does the ownership checks that a blunt +# `wsl --unregister` cannot. Running it first means the distribution is discarded +# already empty rather than mid-flight. +Step "Removing 1Helm inside the '$Distro' distribution" +if (-not $distroPresent) { + Say " distribution '$Distro' is not registered - nothing to run inside it" +} else { + $d = ConvertTo-WslArg $Distro + & $WslExe -d $d -u root --exec /bin/sh -c 'test -x /opt/1helm/uninstall-host.sh' 2>&1 | Out-Null + if ($LASTEXITCODE -eq 0) { + # systemd may still be coming up if WSL had to cold-boot the distribution + # for this call. The Linux uninstaller tolerates a missing systemctl, but + # it removes its units far more cleanly when systemd is actually there. + for ($i = 0; $i -lt 15; $i++) { + $init = (& $WslExe -d $d -u root --exec /bin/sh -c 'ps -p 1 -o comm=' 2>&1 | Out-String).Trim() + if ($init -match 'systemd') { break } + Start-Sleep -Seconds 2 + } + Say " running /opt/1helm/uninstall-host.sh ..." + & $WslExe -d $d -u root --exec /bin/bash /opt/1helm/uninstall-host.sh | Out-Host + if ($LASTEXITCODE -eq 0) { + Say " the Linux uninstaller finished" 'Green' + Add-Removed "1Helm's Linux services and owned channel containers" + } else { + Say " the Linux uninstaller exited $LASTEXITCODE - continuing" 'Yellow' + Say " the distribution is discarded next, so its contents go with it either way" 'DarkGray' + Add-Warning "uninstall-host.sh exited $LASTEXITCODE inside the distribution; its output is above. Nothing was left on Windows by this." + } + } else { + Say " /opt/1helm/uninstall-host.sh is not present - the install did not get that far" + } +} + +# --------------------------------------------------------------------------- +# 3. The distribution +# --------------------------------------------------------------------------- +Step "Removing the '$Distro' distribution" +if (-not $distroPresent) { + Say " already gone" +} elseif (-not (Test-SafeTarget $Distro)) { + Say " the distribution was left completely untouched" 'Yellow' + Add-Kept "WSL distribution '$Distro' (failed the exact-name safety check)" +} else { + # Targeted terminate only - never `wsl --shutdown`, which would stop every + # distribution on this machine for every user. + if (Test-DistroRunning) { + & $WslExe --terminate $Distro 2>&1 | Out-Null + Start-Sleep -Seconds 2 + Say " terminated '$Distro'" 'Green' + } else { + Say " '$Distro' is not running" + } + + & $WslExe --unregister $Distro 2>&1 | Out-Host + if ((Get-Distros) -contains $Distro) { + Say " '$Distro' is STILL registered" 'Red' + Add-Kept "WSL distribution '$Distro' (wsl --unregister did not remove it)" + } else { + Say " unregistered '$Distro' and deleted its virtual disk" 'Green' + Add-Removed "WSL distribution '$Distro' and all of its data" + } +} + +# --------------------------------------------------------------------------- +# 4. Windows-side files and shortcuts +# --------------------------------------------------------------------------- +Step "Removing Windows files and shortcuts" + +$programs = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs' +foreach ($leaf in '1Helm.url', '1Helm.lnk') { + $shortcut = Join-Path $programs $leaf + if (Test-Path -LiteralPath $shortcut) { + Remove-Item -LiteralPath $shortcut -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $shortcut) { + Say " could not remove $shortcut" 'Yellow' + Add-Kept "Start Menu $leaf" + } else { + Say " removed Start Menu $leaf" 'Green' + Add-Removed "Start Menu $leaf" + } + } +} + +if (Test-Path -LiteralPath $InstallRoot) { + Remove-Item -LiteralPath $InstallRoot -Recurse -Force -ErrorAction SilentlyContinue + if (Test-Path -LiteralPath $InstallRoot) { + # Almost always because this script, or a copy of install.ps1, is being + # read from inside the directory being deleted. Hand the last step to a + # detached process that starts after this one has let go. + $cmd = 'Start-Sleep -Seconds 5; Remove-Item -LiteralPath "{0}" -Recurse -Force -ErrorAction SilentlyContinue' -f $InstallRoot + try { + Start-Process -FilePath (Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe') ` + -ArgumentList ('-NoProfile -WindowStyle Hidden -Command "{0}"' -f $cmd) -WindowStyle Hidden + Say " $InstallRoot is in use; it will be deleted in a few seconds" 'Yellow' + Add-Removed "$InstallRoot (deletion scheduled - it was still in use)" + } catch { + Say " could not remove $InstallRoot - delete it yourself" 'Yellow' + Add-Kept $InstallRoot + } + } else { + Say " removed $InstallRoot" 'Green' + Add-Removed $InstallRoot + } +} else { + Say " no $InstallRoot" +} + +# --------------------------------------------------------------------------- +# Report +# --------------------------------------------------------------------------- +# Deliberately not removed: the Windows optional features (WSL and +# VirtualMachinePlatform) and Microsoft's WSL package. They are Microsoft +# components that other distributions may depend on, and turning them off needs +# administrator rights and another restart. +Write-Host "" +Say "Removed:" 'Cyan' +if ($Removed.Count -eq 0) { Say " (nothing)" } else { foreach ($r in $Removed) { Say " - $r" 'Green' } } + +Write-Host "" +if ($Kept.Count -eq 0) { + Say "Nothing was left behind." 'Green' +} else { + Say "Could NOT remove:" 'Yellow' + foreach ($k in $Kept) { Say " - $k" 'Yellow' } +} + +if ($Warnings.Count -gt 0) { + Write-Host "" + Say "Worth knowing:" 'Yellow' + foreach ($w in $Warnings) { Say " - $w" 'Yellow' } +} + +Write-Host "" +Say "Windows' own WSL feature and Microsoft's WSL package were left installed." 'DarkGray' +Say "Other distributions on this PC were not touched." 'DarkGray' +Write-Host "" +if ($Kept.Count -gt 0) { exit 1 } +exit 0 diff --git a/site/public/update-host.sh b/site/public/update-host.sh index 4f137aa..db1c06c 100755 --- a/site/public/update-host.sh +++ b/site/public/update-host.sh @@ -223,7 +223,7 @@ tar -xzf "$ARTIFACT" -C "$STAGE" --strip-components=1 \ || fail "The verified 1Helm artifact could not be extracted." PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' "$STAGE/package.json" 2>/dev/null || true)" [[ "$PACKAGE_VERSION" == "$TARGET_VERSION" ]] \ - || fail "The verified Linux artifact version does not match its release tag." + || fail "The published artifact contains 1Helm ${PACKAGE_VERSION:-an unreadable version} but is tagged v$TARGET_VERSION, so this host refused it and stayed on its current release." [[ -x "$STAGE/site/public/update-host.sh" ]] \ || fail "The verified Linux artifact is missing its host updater." [[ -x "$STAGE/site/public/apply-linux-release.sh" && -x "$STAGE/site/public/install-oci-runtime.sh" && -x "$STAGE/site/public/install-linux-units.sh" && -x "$STAGE/site/public/uninstall-host.sh" && -x "$STAGE/scripts/1helm-oci-runtime" && -r "$STAGE/deploy/1helm-oci-runtime-v1.conf" && -r "$STAGE/container/Containerfile.oci" && -x "$STAGE/resources/cloudflared-linux-$CONNECTOR_ARCH" ]] \ @@ -231,18 +231,58 @@ PACKAGE_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' " chown -R "$SERVICE_USER:$SERVICE_USER" "$STAGE" write_status "installing" "$TARGET_VERSION" "The host verified v$TARGET_VERSION and is preparing an atomic installation." -runuser -u "$SERVICE_USER" -- env HOME="$STATE_ROOT" PATH="$NODE_LINK/bin:/usr/bin:/bin" \ - PUPPETEER_SKIP_DOWNLOAD=1 "$NODE_LINK/bin/npm" --prefix "$STAGE" ci \ - || fail "The verified 1Helm release dependencies could not be installed." -runuser -u "$SERVICE_USER" -- env HOME="$STATE_ROOT" PATH="$NODE_LINK/bin:/usr/bin:/bin" \ - "$NODE_LINK/bin/npm" --prefix "$STAGE" run build \ - || fail "The verified 1Helm release could not be built on this host." + +# The release arrives ready to run: no npm ci, no npm run build, no compiler and +# no npm registry on the update path. Every staged release must prove it carries +# its vendored production node_modules, its built client assets, and native +# addons this host's Node can actually load. This runs against the staging tree +# before anything is promoted, so a release that cannot run is never moved into +# the release store, never linked as current, and never restarted into. +has_vendored_dependencies() { + [[ -d "$1/node_modules" && -f "$1/resources/linux-native-modules.json" ]] +} +verify_native_addons() { + "$NODE_LINK/bin/node" - "$1" "$CONNECTOR_ARCH" <<'NODE' +const { existsSync, readFileSync } = require("node:fs"); +const { join } = require("node:path"); +const [, , releaseRoot, hostArch] = process.argv; +const manifest = JSON.parse(readFileSync(join(releaseRoot, "resources/linux-native-modules.json"), "utf8")); +if (manifest.platform !== "linux" || manifest.arch !== hostArch) { + throw new Error(`release ships ${manifest.platform}-${manifest.arch} native addons, host is linux-${hostArch}`); +} +if (String(manifest.nodeAbi) !== process.versions.modules) { + throw new Error(`release native addons target Node ABI ${manifest.nodeAbi}, installed Node reports ${process.versions.modules}`); +} +const required = "node_modules/node-pty/build/Release/pty.node"; +const paths = (manifest.modules || []).map((entry) => String(entry.path)); +if (!paths.includes(required)) throw new Error(`manifest does not list ${required}`); +for (const relative of paths) { + const file = join(releaseRoot, relative); + if (!existsSync(file)) throw new Error(`missing native addon ${relative}`); + process.dlopen({ exports: {} }, file); +} +if (typeof require(join(releaseRoot, "node_modules/node-pty")).spawn !== "function") { + throw new Error("node-pty did not expose spawn()"); +} +console.log(`verified ${paths.length} prebuilt native addons including a loadable node-pty`); +NODE +} +has_vendored_dependencies "$STAGE" \ + || fail "1Helm v$TARGET_VERSION does not ship vendored node_modules, so this host cannot install it without building it. Publish a ready-to-run Linux release." +[[ -s "$STAGE/public/bundle.js" && -s "$STAGE/public/app.css" ]] \ + || fail "1Helm v$TARGET_VERSION is missing its built client assets, so this host cannot install it without building it." +verify_native_addons "$STAGE" \ + || fail "The Linux native addons shipped with 1Helm v$TARGET_VERSION cannot be loaded on this host, so terminals would not work. The update was refused before anything changed." RELEASE_ROOT="$RELEASES_ROOT/$TARGET_VERSION-$EXPECTED_SHA" if [[ -e "$RELEASE_ROOT" ]]; then EXISTING_VERSION="$("$NODE_LINK/bin/node" -p 'require(process.argv[1]).version' "$RELEASE_ROOT/package.json" 2>/dev/null || true)" [[ "$EXISTING_VERSION" == "$TARGET_VERSION" ]] \ || fail "An existing host release directory does not match v$TARGET_VERSION." + # A retained directory from an interrupted earlier run can be incomplete even + # though its name carries the verified digest. + { has_vendored_dependencies "$RELEASE_ROOT" && verify_native_addons "$RELEASE_ROOT"; } \ + || fail "The retained v$TARGET_VERSION release directory is not runnable on this host." else mv -- "$STAGE" "$RELEASE_ROOT" fi diff --git a/site/server.mjs b/site/server.mjs index 0e3e2f0..270482f 100644 --- a/site/server.mjs +++ b/site/server.mjs @@ -30,22 +30,28 @@ const ORIGIN = "https://1helm.com"; const REPO = "gitcommit90/1Helm"; const RELEASE_PAGE = `https://github.com/${REPO}/releases/latest`; const RELEASE_CACHE_MS = 10 * 60_000; -// Served only when GitHub's release API is unreachable or rate limited. It +// Served only when GitHub's release API is unreachable or rate limited, and it // must name the current release: a stale fallback silently hands visitors an -// older build from the download links. Update the tag and all six digests in -// the same commit that ships a release. -const RELEASE_FALLBACK_TAG = "v0.0.38"; +// older build from the download links. +// +// The digests cannot be known when the release commit is made - they are the +// digests OF that commit's artifacts - so they are filled in at publish time +// and the site is redeployed. Until then PENDING_DIGEST is deliberately not a +// 64-character hex string, so latestLinuxRelease() rejects it and +// /api/releases/linux/latest answers 503. That fails closed: an installer is +// told no release is available rather than being handed a digest that will not +// match what it downloads. The macOS download link still works, because it +// resolves by asset name and needs no digest. +const PENDING_DIGEST = "pending-release-digest"; +const RELEASE_FALLBACK_TAG = "v0.0.39"; const RELEASE_FALLBACK = { tag_name: RELEASE_FALLBACK_TAG, draft: false, prerelease: false, assets: [ - ["1Helm-0.0.38-arm64.dmg", "468a5b8d59a23c4419331db20dcdb955f088a046add48296b5d5aa8450120527"], - ["1Helm-0.0.38-mac-arm64.zip", "4135defe285f9d7e480802d17d81ff37d083a90f6d7dc928f7061a73f9861302"], - ["1Helm-0.0.38-linux-node.tgz", "12d59534b66c325891d5433eb2c7ab8990ee67c9d56a805b65b9633030c424fc"], - ["1Helm-0.0.38-windows-x64-setup.exe", "0689afadc996319a22d6a3419d39725453bc46d9bdd8857463a56b7bcf738590"], - ["1Helm-0.0.38-full.nupkg", "2b25a71f6b3bbb96d7a62cca4bdbd421daed22a1005b1e93d31a77b3d61e86ae"], - ["RELEASES", "a20bf26fc174a48a2ccd30c4b7ab0289d72c00489761981c89a7eb3c9842f454"], + ["1Helm-0.0.39-arm64.dmg", PENDING_DIGEST], + ["1Helm-0.0.39-mac-arm64.zip", PENDING_DIGEST], + ["1Helm-0.0.39-linux-node.tgz", PENDING_DIGEST], ].map(([name, digest]) => ({ name, digest: `sha256:${digest}`, @@ -96,13 +102,15 @@ async function latestAssetUrl(pattern) { async function latestLinuxRelease() { const release = await latestRelease(); const version = String(release.tag_name).replace(/^v/, ""); + // Windows ships no release artifacts: it installs the Linux archive inside WSL + // via https://1helm.com/install.ps1. Requiring a Setup executable, .nupkg and + // RELEASES here would make this throw for every release after 0.0.38 - and + // because install.sh resolves /api/releases/linux/latest, that would break the + // public Linux AND Windows installers at once. const expectedNames = [ `1Helm-${version}-arm64.dmg`, `1Helm-${version}-mac-arm64.zip`, `1Helm-${version}-linux-node.tgz`, - `1Helm-${version}-windows-x64-setup.exe`, - `1Helm-${version}-full.nupkg`, - "RELEASES", ]; const assets = Array.isArray(release.assets) ? release.assets : []; const matrix = expectedNames.map((name) => assets.find((asset) => asset.name === name)); @@ -397,10 +405,10 @@ const server = createServer(async (req, res) => { .catch(() => redirect(res, RELEASE_PAGE)); return; } + // Windows has no downloadable installer: it is a PowerShell one-liner that + // installs the Linux build into WSL. Send people to the instructions. if (path === "/download/windows") { - latestAssetUrl(/-windows-x64-setup\.exe$/i) - .then((url) => redirect(res, url)) - .catch(() => redirect(res, RELEASE_PAGE)); + redirect(res, "/manual/install-windows"); return; } if (path === "/github") { @@ -429,6 +437,15 @@ const server = createServer(async (req, res) => { } if (path === "/install.sh" && serveFile(req, res, join(SITE_PUBLIC, "install.sh"), "no-cache")) return; + // Windows installs with `irm https://1helm.com/install.ps1 | iex`. That script + // fetches the keepalive payload from this same origin, so both must be served + // uncached: a stale installer would pair with a current release. + if (path === "/install.ps1" && serveFile(req, res, join(SITE_PUBLIC, "install.ps1"), "no-cache")) return; + // Windows removal is `irm https://1helm.com/uninstall.ps1 | iex`. It is the + // documented uninstall route, so it is contract surface exactly like the + // installer: a 404 here leaves a user with no supported way to remove 1Helm. + if (path === "/uninstall.ps1" && serveFile(req, res, join(SITE_PUBLIC, "uninstall.ps1"), "no-cache")) return; + if (path.startsWith("/keepalive/") && serveFile(req, res, safeFile(SITE_PUBLIC, path.slice(1)), "no-cache")) return; if (path.startsWith("/schemas/") && serveFile(req, res, safeFile(SITE_PUBLIC, path.slice(1)), "public, max-age=3600")) return; if (path.startsWith("/assets/") && serveFile(req, res, safeFile(SITE_PUBLIC, path.slice(1)), "public, max-age=604800")) return; if (path.startsWith("/media/") && serveFile(req, res, safeFile(SITE_PUBLIC, path.slice(1)), "public, max-age=604800")) return; diff --git a/src/client/api.ts b/src/client/api.ts index fc47717..7849df4 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -80,20 +80,10 @@ export type ChannelComputerPrepare = { started_at: number; updated_at: number; }; -export type WindowsWslSetup = { - status: "idle" | "running" | "complete" | "failed" | "restart_required"; - step: string; - progress: number; - error: string; - log?: string; - started_at: number; - updated_at: number; -}; export type ChannelRuntime = { backend: "apple" | "oci" | "native" | "mock"; supported: boolean; ready: boolean; engine_ready?: boolean; image_ready?: boolean; image?: string | null; prepare?: ChannelComputerPrepare | null; - windows_setup?: WindowsWslSetup | null; platform?: string; architecture?: string; darwin?: boolean; arm64?: boolean; macos_version?: string | null; cli?: string | null; version?: unknown; system?: unknown; runtime_version?: string | null; installer_url?: string; installer_sha256?: string; shared_runtime?: string | null; storage_authority?: string | null; diff --git a/src/client/onboarding.ts b/src/client/onboarding.ts index 18b86e4..ced402c 100644 --- a/src/client/onboarding.ts +++ b/src/client/onboarding.ts @@ -1,4 +1,4 @@ -import { api, getToken, setToken, type ChannelComputerPrepare, type ChannelRuntime, type RoutingState, type WindowsWslSetup } from "./api.ts"; +import { api, getToken, setToken, type ChannelComputerPrepare, type ChannelRuntime, type RoutingState } from "./api.ts"; import { clear, h } from "./dom.ts"; import { onboardingProviderPicker } from "./routing.ts"; @@ -148,74 +148,6 @@ export function openOnboarding(root: HTMLElement, opts: WizardOptions): void { h("p", { class: "mt-2 text-xs text-muted" }, "This is local image import, not a live package download. Channel creation will not pay this cost again."))); }; - const paintWindowsSetupProgress = (setup: WindowsWslSetup, opts?: { retry?: () => void }): void => { - const width = Math.max(4, Math.min(100, Number(setup.progress) || 0)); - const failed = setup.status === "failed"; - const restart = setup.status === "restart_required"; - const children: Array = [ - h("div", { class: "font-semibold text-fg" }, restart ? "Windows restart required" : failed ? "Shared runtime setup failed" : "Setting up shared Windows runtime"), - h("p", { class: "mt-2 text-sm leading-6 text-muted" }, - restart - ? "Windows finished enabling WSL 2, which needs a restart before it can run. Nothing went wrong and nothing is lost." - : "One-time administrator setup. Keep the PowerShell window open until it finishes; 1Helm tracks progress here."), - h("div", { class: "wizard-progress mt-4" }, h("span", { style: `width:${width}%` })), - h("p", { class: "mt-3 text-sm leading-6 text-fg" }, setup.step || "Working…"), - // A pending restart is an expected step, not a fault: never paint it in - // the danger colour that tells the Captain their installation broke. - setup.error ? h("p", { class: `mt-2 text-sm ${restart ? "text-muted" : "text-danger"}` }, setup.error) : null, - restart - ? h("ol", { class: "mt-3 list-decimal space-y-1 pl-5 text-sm leading-6 text-fg" }, - h("li", {}, "Restart this PC."), - h("li", {}, "Sign back in as the same Windows user."), - h("li", {}, "Open 1Helm and continue — setup picks up where it left off.")) - : null, - h("p", { class: "mt-2 text-xs text-muted" }, restart - ? "1Helm keeps this progress. After the restart it finishes the runtime and prepares the sealed channel image automatically." - : failed - ? "After a successful setup, 1Helm will prepare the sealed channel image automatically." - : "This downloads Microsoft's pinned WSL package and the shared Linux runtime, then installs Podman inside it."), - ]; - if ((failed || restart) && opts?.retry) { - children.push(h("button", { - class: "btn-primary mt-3 w-full py-2 sm:w-auto", - onclick: () => { opts.retry?.(); }, - }, restart ? "I restarted — retry setup" : "Retry shared runtime setup")); - } - status.replaceChildren(h("div", { class: `card p-4 ${failed ? "border-danger/40" : "border-accent/30"}` }, ...children)); - }; - - const runWindowsRuntimeSetup = async (button: HTMLButtonElement): Promise => { - setBusy(button, true, "Setting up shared runtime…"); - status.textContent = ""; - try { - const started = await api<{ installer?: { opened?: boolean; setup?: WindowsWslSetup }; runtime: ChannelRuntime }>("/api/channel-computers/runtime/install", { body: {} }); - const initial = started.installer?.setup || started.runtime.windows_setup; - if (initial) paintWindowsSetupProgress(initial, { retry: () => { void runWindowsRuntimeSetup(button); } }); - const deadline = Date.now() + 60 * 60_000; - while (Date.now() < deadline) { - const snapshot = await api<{ runtime: ChannelRuntime }>("/api/channel-computers/runtime"); - const setup = snapshot.runtime.windows_setup; - if (snapshot.runtime.engine_ready) { - status.replaceChildren(h("div", { class: "wizard-status-ok" }, "Shared Windows runtime is ready. Preparing private computers…")); - await prepareOciComputers(button); - return; - } - if (setup) { - paintWindowsSetupProgress(setup, { retry: () => { void runWindowsRuntimeSetup(button); } }); - if (setup.status === "failed" || setup.status === "restart_required") { - setBusy(button, false); - return; - } - } - await new Promise((resolveWait) => setTimeout(resolveWait, 1_500)); - } - throw new Error("Shared Windows runtime setup timed out. Check the PowerShell window for errors, then retry."); - } catch (error) { - status.replaceChildren(h("div", { class: "wizard-status-err" }, (error as Error).message)); - setBusy(button, false); - } - }; - const prepareOciComputers = async (button: HTMLButtonElement): Promise => { setBusy(button, true, "Preparing private computers…"); status.textContent = ""; @@ -285,45 +217,6 @@ export function openOnboarding(root: HTMLElement, opts: WizardOptions): void { return; } if (!runtime.engine_ready) { - const windows = Boolean(runtime.shared_runtime); - if (windows) { - const setup = runtime.windows_setup; - if (setup?.status === "running") { - paintWindowsSetupProgress(setup, { retry: () => { void runWindowsRuntimeSetup(button); } }); - // Reattach to an in-flight setup instead of launching a second installer. - void (async () => { - setBusy(button, true, "Setting up shared runtime…"); - const deadline = Date.now() + 60 * 60_000; - while (Date.now() < deadline) { - const snapshot = await api<{ runtime: ChannelRuntime }>("/api/channel-computers/runtime"); - if (snapshot.runtime.engine_ready) { - status.replaceChildren(h("div", { class: "wizard-status-ok" }, "Shared Windows runtime is ready. Preparing private computers…")); - await prepareOciComputers(button); - return; - } - const current = snapshot.runtime.windows_setup; - if (current) { - paintWindowsSetupProgress(current, { retry: () => { void runWindowsRuntimeSetup(button); } }); - if (current.status === "failed" || current.status === "restart_required") { - setBusy(button, false); - return; - } - } - await new Promise((resolveWait) => setTimeout(resolveWait, 1_500)); - } - status.replaceChildren(h("div", { class: "wizard-status-err" }, "Shared Windows runtime setup timed out. Retry setup.")); - setBusy(button, false); - })(); - return; - } - if (setup?.status === "failed" || setup?.status === "restart_required") { - setBusy(button, false); - paintWindowsSetupProgress(setup, { retry: () => { void runWindowsRuntimeSetup(button); } }); - return; - } - await runWindowsRuntimeSetup(button); - return; - } setBusy(button, false); const instruction = "The OCI runtime is not ready. Rerun the verified 1Helm Linux host installer, then retry."; status.replaceChildren(h("div", { class: "wizard-status-err" }, runtime.error ? `${instruction} ${runtime.error}` : instruction)); diff --git a/src/client/settings.ts b/src/client/settings.ts index bb22369..f712402 100644 --- a/src/client/settings.ts +++ b/src/client/settings.ts @@ -678,39 +678,11 @@ function computersPanel(): HTMLElement { : "The root-owned OCI runtime and shared channel image are ready. Skipper manages one persistent OCI container per ordinary channel."; const setupCopy = !engineReady ? (windows - ? "Complete 1Helm's one-time Windows administrator setup for the shared OCI runtime." + ? "Rerun 1Helm's Windows installer from https://1helm.com/install.ps1 to repair the WSL distribution and its OCI runtime." : "Rerun the verified 1Helm Linux host installer to repair Podman, cgroups, or the root-owned helper.") : "Load the sealed channel computer image once. Later channels start from that local image instead of touching live package mirrors."; const actionStatus = h("p", { class: "mt-2 text-sm text-muted" }); const chip = fullyReady ? `${label} ready` : !engineReady ? "Setup required" : "Image setup"; - const windowsSetup = windows && !engineReady ? h("button", { class: "btn-primary mt-3 text-sm", onclick: async () => { - actionStatus.textContent = "Starting shared Windows runtime setup… Approve the administrator prompt when Windows asks."; - try { - await api("/api/channel-computers/runtime/install", { body: {} }); - const deadline = Date.now() + 60 * 60_000; - while (Date.now() < deadline) { - const snapshot = await api<{ runtime: ChannelRuntime }>("/api/channel-computers/runtime"); - const setup = snapshot.runtime.windows_setup; - if (snapshot.runtime.engine_ready) { - paintRuntime(snapshot.runtime); - return; - } - if (setup?.status === "restart_required") { - actionStatus.textContent = setup.step || "Restart Windows once, reopen 1Helm, then retry setup."; - return; - } - if (setup?.status === "failed") { - actionStatus.textContent = setup.error || setup.step || "Shared Windows runtime setup failed."; - return; - } - actionStatus.textContent = setup?.step - ? `${setup.step} (${Math.max(0, Math.min(100, Number(setup.progress) || 0))}%)` - : "Shared Windows runtime setup is running…"; - await new Promise((resolveWait) => setTimeout(resolveWait, 1_500)); - } - actionStatus.textContent = "Shared Windows runtime setup timed out. Retry setup."; - } catch (error) { actionStatus.textContent = (error as Error).message; } - } }, "Set up shared runtime") : null; const prepareImage = engineReady && !imageReady ? h("button", { class: "btn-primary mt-3 text-sm", onclick: async () => { actionStatus.textContent = "Loading the sealed channel computer image…"; try { @@ -730,7 +702,6 @@ function computersPanel(): HTMLElement { h("div", { class: "flex flex-wrap items-center gap-2" }, h("h3", { class: "font-semibold text-fg" }, "Channel computers"), h("span", { class: "chip border-accent/25" }, chip)), h("p", { class: "mt-1 text-sm leading-6 text-muted" }, fullyReady ? readyCopy : setupCopy), ...(runtime.error ? [h("p", { class: "mt-2 text-sm text-danger" }, runtime.error)] : []), - ...(windowsSetup ? [windowsSetup] : []), ...(prepareImage ? [prepareImage] : []), actionStatus, ); diff --git a/src/server/channel-computers.ts b/src/server/channel-computers.ts index 16ffbfc..1bd2e90 100644 --- a/src/server/channel-computers.ts +++ b/src/server/channel-computers.ts @@ -1,5 +1,5 @@ import { createHash, randomBytes } from "node:crypto"; -import { spawn, spawnSync, type ChildProcess, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { spawn, spawnSync, type ChildProcessWithoutNullStreams } from "node:child_process"; import { createReadStream, createWriteStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs"; import { cpus as hostCpus, freemem, platform, totalmem } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; @@ -68,7 +68,7 @@ const APPLE_RUNTIME_VERSION = "1.1.0"; export const APPLE_RUNTIME_PACKAGE = `container-${APPLE_RUNTIME_VERSION}-installer-signed.pkg`; export const APPLE_RUNTIME_URL = `https://github.com/apple/container/releases/download/${APPLE_RUNTIME_VERSION}/${APPLE_RUNTIME_PACKAGE}`; export const APPLE_RUNTIME_SHA256 = "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"; -export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.38"; +export const DEFAULT_CHANNEL_IMAGE = process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.39"; const CONTAINER_CANDIDATES = [process.env.HELM_CONTAINER_CLI, "/usr/local/bin/container", "/opt/homebrew/bin/container", "container"].filter(Boolean) as string[]; const OCI_RUNTIME_VERSION = "1helm-oci-runtime-v1"; const OCI_HELPER_CANDIDATES = [ @@ -1605,16 +1605,6 @@ export type ChannelComputerPrepareStatus = { updated_at: number; }; -export type WindowsWslSetupStatus = { - status: "idle" | "running" | "complete" | "failed" | "restart_required"; - step: string; - progress: number; - error: string; - log: string; - started_at: number; - updated_at: number; -}; - let ociPrepareState: ChannelComputerPrepareStatus = { status: "idle", step: "Private channel computers have not been prepared yet.", @@ -1626,58 +1616,6 @@ let ociPrepareState: ChannelComputerPrepareStatus = { }; let ociPreparePass: Promise | null = null; -let windowsWslSetupState: WindowsWslSetupStatus = { - status: "idle", - step: "Shared Windows WSL runtime setup has not started.", - progress: 0, - error: "", - log: "", - started_at: 0, - updated_at: 0, -}; -let windowsWslSetupChild: ChildProcess | null = null; -let windowsWslSetupLogPath = ""; -let windowsWslSetupStatusPath = ""; - -function setWindowsWslSetupState(patch: Partial): WindowsWslSetupStatus { - windowsWslSetupState = { - ...windowsWslSetupState, - ...patch, - updated_at: now(), - }; - return windowsWslSetupState; -} - -export function windowsWslSetupStatus(): WindowsWslSetupStatus { - if (windowsWslSetupStatusPath && existsSync(windowsWslSetupStatusPath)) { - try { - const parsed = JSON.parse(readFileSync(windowsWslSetupStatusPath, "utf8")) as { - status?: string; step?: string; progress?: number; error?: string; - }; - if (windowsWslSetupState.status === "running" && parsed.step) { - const status = parsed.status === "restart_required" || parsed.status === "failed" || parsed.status === "complete" - ? parsed.status as WindowsWslSetupStatus["status"] - : "running"; - setWindowsWslSetupState({ - // The elevated PowerShell pass reports terminal outcomes through the - // shared file before its parent process necessarily closes. Do not - // hide a required reboot (or a real failure) behind the live child. - status, - step: String(parsed.step), - progress: Math.max(0, Math.min(100, Number(parsed.progress) || windowsWslSetupState.progress)), - error: String(parsed.error || windowsWslSetupState.error || ""), - }); - } - } catch { /* status file is best-effort */ } - } - if (windowsWslSetupLogPath && existsSync(windowsWslSetupLogPath)) { - try { - const log = readFileSync(windowsWslSetupLogPath, "utf8").slice(-8_000); - if (log !== windowsWslSetupState.log) setWindowsWslSetupState({ log }); - } catch { /* log is best-effort */ } - } - return { ...windowsWslSetupState }; -} function setOciPrepareState(patch: Partial): ChannelComputerPrepareStatus { ociPrepareState = { @@ -1891,21 +1829,18 @@ export function runtimeReadiness(): Record { }); } } - const windowsSetup = windows ? windowsWslSetupStatus() : null; return { backend, supported, engine_ready: engineReady, image_ready: imageReady, image: DEFAULT_CHANNEL_IMAGE, prepare: channelComputerPrepareStatus(), - windows_setup: windowsSetup, ready: Boolean(engineReady && imageReady), platform: platform(), architecture: process.arch, cli: helper || null, version: version || null, system, runtime_version: OCI_RUNTIME_VERSION, shared_runtime: windows ? installationScopedRuntimeName() : null, storage_authority: windows ? `\\\\wsl.localhost\\${installationScopedRuntimeName()}\\var\\lib\\1helm-oci-v1\\runtime\\oci` : ociHostStateRoot(), status: error ? "error" : !engineReady ? (system ? "running" : "missing") : imageReady ? "running" : "image_pending", error: error - || (windowsSetup && (windowsSetup.status === "failed" || windowsSetup.status === "restart_required") ? windowsSetup.error || windowsSetup.step : null) || (ociPrepareState.status === "failed" ? ociPrepareState.error : null), }; } @@ -1982,158 +1917,6 @@ export async function prepareAppleRuntimeInstaller(): Promise<{ path: string; sh return { path: destination, sha256: digest, opened }; } -/** - * Start (or reattach to) the one-time shared WSL/OCI runtime install on Windows. - * The PowerShell script elevates only for host features/MSI; the signed-in user - * owns the distribution import. Progress is tracked for the onboarding UI. - */ -export async function prepareWindowsWslRuntime(): Promise<{ opened: boolean; setup: WindowsWslSetupStatus }> { - if (platform() !== "win32") throw new Error("WSL 2 setup is available only on Windows."); - if (windowsWslSetupChild && windowsWslSetupState.status === "running") { - return { opened: true, setup: windowsWslSetupStatus() }; - } - const current = runtimeReadiness(); - if (current.engine_ready) { - return { - opened: false, - setup: setWindowsWslSetupState({ - status: "complete", - step: "The shared Windows OCI runtime is already ready.", - progress: 100, - error: "", - started_at: windowsWslSetupState.started_at || now(), - }), - }; - } - const script = join(process.env.HELM_APP_ROOT || process.cwd(), "scripts", "install-wsl-runtime.ps1"); - if (!existsSync(script)) throw new Error("1Helm's signed WSL 2 setup script is missing."); - const runtime = installationScopedRuntimeName(); - const appRoot = process.env.HELM_APP_ROOT || process.cwd(); - const runtimeRoot = join(String(process.env.LOCALAPPDATA || DATA_DIR), "1Helm-Runtime"); - mkdirSync(runtimeRoot, { recursive: true }); - mkdirSync(DATA_DIR, { recursive: true }); - windowsWslSetupStatusPath = join(runtimeRoot, "setup-status.json"); - windowsWslSetupLogPath = join(DATA_DIR, "windows-wsl-setup.log"); - try { - writeFileSync(windowsWslSetupStatusPath, `${JSON.stringify({ - status: "running", - step: "Starting Windows shared-runtime setup...", - progress: 3, - error: "", - updated: new Date().toISOString(), - })}\n`, "utf8"); - writeFileSync(windowsWslSetupLogPath, "", "utf8"); - } catch { /* best-effort seed */ } - - setWindowsWslSetupState({ - status: "running", - step: "Starting Windows shared-runtime setup... Approve the administrator prompt when Windows asks.", - progress: 3, - error: "", - log: "", - started_at: now(), - }); - - const child = spawn("powershell.exe", [ - "-NoProfile", - "-ExecutionPolicy", "Bypass", - "-File", script, - "-RuntimeName", runtime, - "-AppRoot", appRoot, - ], { - env: { - ...process.env, - HELM_WSL_SETUP_STATUS: windowsWslSetupStatusPath, - }, - windowsHide: false, - stdio: ["ignore", "pipe", "pipe"], - }); - windowsWslSetupChild = child; - - const appendLog = (chunk: Buffer | string): void => { - const text = String(chunk); - if (!text) return; - try { writeFileSync(windowsWslSetupLogPath, text, { flag: "a", encoding: "utf8" }); } catch { /* ignore */ } - const combined = `${windowsWslSetupState.log}${text}`.slice(-8_000); - const lines = text.split(/\r?\n/).map((line) => line.trim()).filter(Boolean); - let step = windowsWslSetupState.step; - let progress = windowsWslSetupState.progress; - for (const line of lines) { - if (/^ERROR:/i.test(line)) continue; - step = line.replace(/^ERROR:\s*/i, "").slice(0, 400); - if (/administrator approval/i.test(line)) progress = Math.max(progress, 5); - else if (/Enabling Windows WSL/i.test(line)) progress = Math.max(progress, 8); - else if (/Downloading Microsoft WSL/i.test(line)) progress = Math.max(progress, 12); - else if (/Installing Microsoft WSL/i.test(line)) progress = Math.max(progress, 18); - else if (/default/i.test(line) && /WSL/i.test(line)) progress = Math.max(progress, 22); - else if (/Checking for the shared/i.test(line)) progress = Math.max(progress, 28); - else if (/Downloading shared Linux/i.test(line)) progress = Math.max(progress, 35); - else if (/Importing shared/i.test(line)) progress = Math.max(progress, 48); - else if (/packages|podman/i.test(line)) progress = Math.max(progress, 58); - else if (/sealed OCI|channel image/i.test(line)) progress = Math.max(progress, 78); - else if (/Restarting the shared/i.test(line)) progress = Math.max(progress, 88); - else if (/Verifying the shared/i.test(line)) progress = Math.max(progress, 94); - else if (/installed and ready/i.test(line)) progress = 100; - } - setWindowsWslSetupState({ log: combined, step, progress }); - }; - child.stdout?.on("data", appendLog); - child.stderr?.on("data", appendLog); - child.on("error", (error) => { - windowsWslSetupChild = null; - setWindowsWslSetupState({ - status: "failed", - step: "Windows could not start the shared-runtime setup process.", - progress: Math.max(3, windowsWslSetupState.progress), - error: error.message, - }); - }); - child.on("close", (code, signal) => { - windowsWslSetupChild = null; - const setup = windowsWslSetupStatus(); - if (code === 10 || setup.status === "restart_required") { - setWindowsWslSetupState({ - status: "restart_required", - step: "WSL 2 features are enabled. Restart Windows once, reopen 1Helm, then create the workspace again.", - progress: Math.max(20, setup.progress), - error: "Windows restart required to finish enabling WSL 2.", - }); - return; - } - if (code === 0) { - const readiness = runtimeReadiness(); - if (readiness.engine_ready) { - setWindowsWslSetupState({ - status: "complete", - step: "1Helm's shared OCI runtime is installed and ready.", - progress: 100, - error: "", - }); - return; - } - setWindowsWslSetupState({ - status: "failed", - step: "Setup finished but the shared OCI runtime is still not ready.", - progress: Math.max(90, setup.progress), - error: String(readiness.error || "The shared WSL OCI runtime did not pass readiness after setup."), - }); - return; - } - const logTail = setup.log.trim().split(/\r?\n/).filter(Boolean).slice(-8).join(" "); - const detail = setup.error || logTail || (signal ? `terminated by ${signal}` : `exit code ${code ?? "unknown"}`); - setWindowsWslSetupState({ - status: "failed", - step: setup.step && setup.step !== "Starting Windows shared-runtime setup... Approve the administrator prompt when Windows asks." - ? setup.step - : "Shared Windows runtime setup failed.", - progress: Math.max(5, setup.progress), - error: detail.slice(0, 1000), - }); - }); - - return { opened: true, setup: windowsWslSetupStatus() }; -} - /** Complete runtime activation after the signed package receives approval. */ export async function startAppleRuntime(): Promise> { if (platform() !== "darwin" || process.arch !== "arm64") throw new Error("Apple container machines require an Apple Silicon Mac."); diff --git a/src/server/db.ts b/src/server/db.ts index b9565f2..c08138e 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -939,7 +939,7 @@ export function migrate(): void { const platformBackend = process.platform === "darwin" ? "apple" : "oci"; const configuredBackend = String(process.env.HELM_CHANNEL_COMPUTER_BACKEND || platformBackend); const backend = ["apple", "oci", "native", "mock"].includes(configuredBackend) ? configuredBackend : platformBackend; - const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.38"); + const image = String(process.env.HELM_CHANNEL_MACHINE_IMAGE || "local/1helm-channel-machine:0.0.39"); for (const channel of q(`SELECT c.id FROM channels c JOIN agent_channels ac ON ac.channel_id=c.id WHERE c.kind='channel' AND c.status<>'deleted'`)) { const channelId = Number(channel.id); diff --git a/src/server/index.ts b/src/server/index.ts index 5a37545..f29520a 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -105,8 +105,6 @@ import { runtimeReadiness, refreshChannelWorkspaceMirror, prepareAppleRuntimeInstaller, - prepareWindowsWslRuntime, - windowsWslSetupStatus, startAppleRuntime, wakeDueChannelComputers, shutdownChannelComputers, @@ -2040,14 +2038,8 @@ const server = createServer(async (req, res) => { if (p === "/api/channel-computers/runtime/install" && m === "POST") { if (!user.is_admin) return json(res, 403, { error: "Captain/admin only" }); const runtime = runtimeReadiness(); - if (runtime.backend === "oci" && platform() === "win32") { - const installer = await prepareWindowsWslRuntime(); - return json(res, 200, { - ok: true, - installer: { opened: installer.opened, setup: installer.setup || windowsWslSetupStatus() }, - runtime: runtimeReadiness(), - }); - } + // A Windows host is the Linux host inside WSL 2, installed and verified by + // the site-served install.ps1, so no in-app runtime installer exists here. if (runtime.backend !== "apple") return json(res, 409, { error: "The root-owned OCI runtime is installed and verified by the 1Helm Linux host installer." }); const installer = await prepareAppleRuntimeInstaller(); return json(res, 200, { ok: true, installer: { sha256: installer.sha256, opened: installer.opened }, runtime: runtimeReadiness() }); diff --git a/test/channel-computers.mjs b/test/channel-computers.mjs index 7ab91a4..c2fbbbf 100644 --- a/test/channel-computers.mjs +++ b/test/channel-computers.mjs @@ -181,7 +181,7 @@ test("Apple channel-computer contract preserves isolation, files, wakes, archive test("runtime digest and packaged image recipe stay pinned", async () => { assert.equal(computers.APPLE_RUNTIME_SHA256, "0ca1c42a2269c2557efb1d82b1b38ac553e6a3a3da1b1179c439bcee1e7d6714"); assert.match(computers.APPLE_RUNTIME_URL, /\/1\.1\.0\/container-1\.1\.0-installer-signed\.pkg$/); - assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.38"); + assert.equal(computers.DEFAULT_CHANNEL_IMAGE, "local/1helm-channel-machine:0.0.39"); const packaging = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); assert.match(packaging, /container\(\?:\$\|\\\/\)/, "release packaging includes container/ image assets"); const image = await readFile(join(root, "container", "Containerfile"), "utf8"); diff --git a/test/desktop.mjs b/test/desktop.mjs index 67ead7a..0a0836b 100644 --- a/test/desktop.mjs +++ b/test/desktop.mjs @@ -115,12 +115,12 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(bootstrap, /ensureChannelWorkspace\(Number\(channel\.id\), \{ initializeRuntimeStorage: false \}\)/, "retained OCI directories cannot synchronously wake WSL before the HTTP control plane is ready"); assert.match(server, /\["native-macos", "native-windows"\]\.includes\(update\.mode\)/, "both packaged host updaters quiesce the local server before replacement"); assert.match(nativeUpdater, /update\.electronjs\.org\/gitcommit90\/1Helm\/\$\{feedPlatform\}/); - assert.match(nativeUpdater, /win32-x64/, "Windows checks and installs on its host through the native updater"); - assert.match(source, /handleSquirrelEvent/); - assert.match(source, /setAppUserModelId\("com\.squirrel\.1Helm\.1Helm"\)/, "Windows uses Squirrel's stable taskbar identity"); + assert.match(nativeUpdater, /const feedPlatform = "darwin-arm64"/, "only the Apple Silicon host has a native Electron update feed"); + assert.doesNotMatch(nativeUpdater, /win32/, "Windows has no native Electron updater: it updates through the Linux systemd updater inside WSL 2"); + assert.doesNotMatch(source, /squirrel/i, "no Squirrel install/update/uninstall lane survives in the desktop shell"); assert.match(source, /DATA_NAMESPACE = "1Helm-OCI-v1"/, "the clean-slate build uses a fresh durable application-data namespace"); - assert.match(source, /SERVER_READY_TIMEOUT_MS = process\.platform === "win32" \? 3 \* 60_000 : 30_000/, "Windows gives a bounded cold WSL host enough time without weakening other desktop startup budgets"); - assert.match(source, /windows-removal\.cjs/, "Windows uninstall invokes ownership-checked shared-runtime cleanup before removing shortcuts"); + assert.match(source, /SERVER_READY_TIMEOUT_MS = process\.platform === "win32" \? 3 \* 60_000 : 30_000/, "a cold WSL host gets a bounded startup budget without weakening other desktop startup budgets"); + assert.doesNotMatch(source, /windows-removal/, "the retired Windows uninstall hook is gone; removal is the site-served uninstall.ps1"); const onboardingClient = await readFile(join(root, "src", "client", "onboarding.ts"), "utf8"); const clientApi = await readFile(join(root, "src", "client", "api.ts"), "utf8"); const publicIndex = await readFile(join(root, "public", "index.html"), "utf8"); @@ -144,86 +144,16 @@ test("desktop entrypoint keeps the renderer sandboxed and data on the Mac", asyn assert.match(channelComputers, /windowsSystemAccount\(\)[\s\S]*cannot use WSL while running as Windows Local System/, "WSL fails with an actionable host-identity error before invoking an unsupported SYSTEM session"); assert.match(embeddedTerminal, /hostPlatform === "win32"[\s\S]*ComSpec[\s\S]*cmd\.exe[\s\S]*\["\/d", "\/s", "\/c", command\]/, "Windows host commands and #main Terminal use the native cmd shell contract"); assert.match(embeddedTerminal, /Native terminal shell could not start/, "a terminal spawn failure returns an HTTP error instead of crashing the server"); - const windowsPackager = await readFile(join(root, "scripts", "package-windows.cjs"), "utf8"); const macPackager = await readFile(join(root, "scripts", "package-mac-dmg.cjs"), "utf8"); - const windowsRemoval = await readFile(join(root, "scripts", "windows-removal.cjs"), "utf8"); - const windowsRuntime = await readFile(join(root, "scripts", "install-wsl-runtime.ps1"), "utf8"); - const windowsRuntimeStatusTest = await readFile(join(root, "test", "windows-wsl-status.ps1"), "utf8"); - assert.match(windowsPackager, /HELM_REQUIRE_WINDOWS_SIGNATURE/); - assert.match(windowsPackager, /require\("png-to-ico"\)\.default/, "Windows packaging uses the module's CommonJS default export"); - assert.match(windowsPackager, /1Helm-\$\{VERSION\}-windows-x64-setup\.exe/); - assert.match(windowsPackager, /win32-x64/); - assert.match(windowsPackager, /path\.join\(DIST, "RELEASES"\)/, "the GitHub asset keeps Squirrel's required literal RELEASES name"); - assert.match(windowsPackager, /path\.join\(DIST, path\.basename\(nupkg\)\)/, "the uploaded package keeps the exact basename referenced by RELEASES"); - assert.match(windowsPackager, /signPackagedExecutables\(appDir\)/, "release signing covers nested Windows executables before packaging"); - assert.match(windowsPackager, /cloudflared-windows-amd64\.exe/, "Windows packaging ships the Cloudflare tunnel connector"); - assert.match(windowsPackager, /resources.*cloudflared\.exe|cloudflared\.exe.*resources/, "cloudflared.exe is placed next to the packaged Electron resources root"); - assert.match(windowsPackager, /IGNORE_CLIENT_BUILD_MODULES/, "Windows packaging omits client source already compiled into the browser bundle"); - assert.match(windowsPackager, /IGNORE_INSTRUCTION_FILES/, "Windows packaging omits dependency maintainer instructions"); - assert.match(windowsPackager, /\/AGENTS\\\.md\$/, "Windows packages never include dependency AGENTS.md files"); - assert.match(windowsPackager, /fs\.mkdtempSync\(path\.join\(path\.parse\(ROOT\)\.root, "1hw-"\)\)/, "the packaged app and Squirrel staging tree share a drive-root scratch path compatible with legacy Windows path handling"); - for (const [source, requiredPaths] of [ - [windowsPackager, ["/scripts", "/scripts/1helm-oci-runtime", "/scripts/mnemosyne-bridge.py", "/scripts/install-wsl-runtime.ps1", "/scripts/windows-removal.cjs", "/deploy/1helm-oci-runtime-v1.conf", "/container/Containerfile.oci"]], - [macPackager, ["/scripts", "/scripts/mnemosyne-bridge.py"]], - ]) { - const literal = source.match(/const IGNORE_NON_RUNTIME_ROOTS\s*=\s*(\/\^[^;]+\/);/)?.[1]; - assert.ok(literal, "desktop packager exposes a testable runtime-root filter"); - const filter = Function(`"use strict"; return (${literal})`)(); - for (const requiredPath of requiredPaths) assert.equal(filter.test(requiredPath), false, `${requiredPath} survives desktop packaging`); - assert.equal(filter.test("/scripts/release-only-helper.cjs"), true, "non-runtime release helpers stay outside the desktop app"); - assert.equal(filter.test("/test"), true, "tests stay outside the desktop app"); + const macFilterLiteral = macPackager.match(/const IGNORE_NON_RUNTIME_ROOTS\s*=\s*(\/\^[^;]+\/);/)?.[1]; + assert.ok(macFilterLiteral, "the desktop packager exposes a testable runtime-root filter"); + const macFilter = Function(`"use strict"; return (${macFilterLiteral})`)(); + for (const requiredPath of ["/scripts", "/scripts/mnemosyne-bridge.py"]) { + assert.equal(macFilter.test(requiredPath), false, `${requiredPath} survives desktop packaging`); } - assert.match(windowsRemoval, /installation_id/); - assert.match(windowsRemoval, /ctrl-pane\.db/, "Windows removal reads the real durable 1Helm database"); - assert.doesNotMatch(windowsRemoval, /helm\.sqlite/); - assert.match(windowsRemoval, /--unregister/); - assert.match(channelComputers, /"--exec", "\/usr\/libexec\/1helm-oci-runtime", \.\.\.args/, "Windows enters the installed OCI helper with direct argv instead of an intervening shell"); - assert.match(windowsRemoval, /"--exec", "\/usr\/libexec\/1helm-oci-runtime"/, "Windows removal delegates ownership checks to the narrow installed OCI helper"); - assert.match(windowsRuntime, /VirtualMachinePlatform/); - assert.match(windowsRuntime, /2\.7\.10\.0/); - // A pending Windows reboot must never surface to the Captain as a broken - // installation: -Wait can return while the elevated child is still enabling - // features, and probing for WSL in that window reports a false failure. - assert.match(windowsRuntime, /\$hostProcess\.WaitForExit\(\)/, "the signed-in pass blocks on the real elevated process handle, not only Start-Process -Wait"); - assert.match(windowsRuntime, /function Test-PendingWslRestart/, "the installer can recognise a reboot Windows has not taken yet"); - assert.match(windowsRuntime, /vmcompute[\s\S]{0,400}EnablePending|EnablePending[\s\S]{0,400}vmcompute|-ne "Enabled"/, "pending feature activation counts as a restart, not a failure"); - assert.match(windowsRuntime, /if \(Test-PendingWslRestart\) \{ Require-WindowsRestart \}[\s\S]{0,200}Fail-Setup "Microsoft WSL \$wslVersion is not ready/, "the user-session WSL probe asks for a restart before declaring failure"); - assert.match(windowsRuntime, /Restart this PC to finish enabling WSL 2/, "the restart status carries plain-language instructions"); - assert.match(windowsRuntime, /-Status "restart_required"/, "the restart path reports the dedicated restart status"); - assert.match(windowsRuntime, /@\("restart_required", "failed", "complete"\) -contains/, "the parent waits for a terminal child status instead of racing its last write"); - const onboardingRuntimeUi = await readFile(join(root, "src", "client", "onboarding.ts"), "utf8"); - assert.match(onboardingRuntimeUi, /restart \? "text-muted" : "text-danger"/, "a pending restart is not painted as an error"); - assert.match(onboardingRuntimeUi, /\$\{failed \? "border-danger\/40" : "border-accent\/30"\}/, "only real failures get the danger card treatment"); - assert.match(onboardingRuntimeUi, /Restart this PC\./, "the restart state gives the Captain explicit numbered steps"); - assert.match(windowsRuntime, /github\.com\/microsoft\/WSL\/releases\/download\/2\.7\.10\/wsl\.2\.7\.10\.0\.x64\.msi/); - assert.match(windowsRuntime, /1a62f90a43c03cc5bda47dfd0b6faf496ac70fd4389190518120a4f84fc895cf/); - assert.match(windowsRuntime, /Get-AuthenticodeSignature/); - assert.match(windowsRuntime, /CN=Microsoft Corporation/); - assert.match(windowsRuntime, /msiexec\.exe/); - assert.match(windowsRuntime, /--set-default-version["'\s,]+2/); - assert.match(windowsRuntime, /-HostSetup[\s\S]*-Verb RunAs[\s\S]*Get-WslDistributionNames/, "only WSL host components cross UAC while the signed-in owner imports the shared distribution"); - assert.match(windowsRuntime, /function Fetch-File[\s\S]*if \(\$HostSetup\)/, "Fetch-File is script-scoped so the signed-in owner path can download the Ubuntu rootfs after elevation"); - assert.match(windowsRuntime, /Write-SetupStatus/, "Windows setup writes machine-readable progress for the app UI"); - assert.match(windowsRuntime, /\$hostExitCode = if \(\$null -eq \$hostProcess\)[\s\S]*Get-HostSetupOutcome/, "cancelled UAC is reported through the shared host-outcome transaction instead of a silent no-op"); - assert.match(windowsRuntime, /Get-WslText|replace \[char\]0/, "WSL UTF-16 NUL output is normalized before version and distro matching"); - assert.match(windowsRuntime, /function Get-WslText[\s\S]*ErrorActionPreference = "Continue"[\s\S]*\$LASTEXITCODE[\s\S]*ErrorActionPreference = \$previousErrorAction/, "a fresh host's expected failing WSL probe cannot terminate setup before the pinned runtime is installed"); - assert.match(windowsRuntime, /Test-PinnedWslRuntime/, "host setup verifies the pinned Microsoft WSL build"); - assert.match(windowsRuntime, /if \(\$HostSetup\)[\s\S]*try \{[\s\S]*catch \{[\s\S]*Fail-Setup \$message/, "unexpected elevated host-setup errors are written to shared status instead of collapsing to an unexplained exit code"); - assert.match(windowsRuntime, /StatusPath/, "elevated HostSetup receives the shared status path so real errors reach the app"); - assert.match(windowsRuntime, /1603[\s\S]*Test-PinnedWslRuntime|Test-PinnedWslRuntime[\s\S]*1603/, "MSI 1603 falls back to re-verifying an already-present pinned WSL runtime"); - assert.match(windowsRuntime, /\$enabledWslFeatureNow[\s\S]*\$enabledVmFeatureNow[\s\S]*\$restartRequired/, "features enabled in the current pass force a reboot before WSL import regardless of DISM enum formatting"); - assert.match(windowsRuntime, /Get-Service -Name vmcompute[\s\S]*\$restartRequired = \$true/, "an enabled-but-not-registered WSL VM compute service stops setup at the reboot boundary"); - assert.match(windowsRuntime, /HCS_E_SERVICE_NOT_AVAILABLE[\s\S]*Require-WindowsRestart|Test-WslRestartFailure[\s\S]*Require-WindowsRestart/, "an unavailable VM compute service is reported as restart-required instead of a broken runtime"); - assert.match(windowsRuntime, /Get-HostSetupOutcome[\s\S]*Status -eq "restart_required"[\s\S]*Test-PinnedWslRuntime/, "the signed-in setup honors the elevated shared reboot result before probing WSL"); - assert.doesNotMatch(channelComputers, /status: windowsWslSetupChild \? "running" : status/, "a live parent process cannot mask a terminal shared WSL setup status"); - assert.match(windowsRuntimeStatusTest, /Get-HostSetupOutcome -ExitCode 0[\s\S]*restart_required[\s\S]*Get-HostSetupOutcome -ExitCode 10/, "the executable PowerShell regression proves shared restart status wins over an unreliable zero exit code"); - assert.match(windowsRuntime, /\.1helm-partial-import[\s\S]*\$ownedPartial[\s\S]*Remove-Item -LiteralPath \$installDirectory/, "an app-owned partial WSL import can recover safely after reboot"); - assert.match(windowsRuntime, /\[automount\][\s\S]*enabled=false[\s\S]*\[interop\][\s\S]*enabled=false/, "the shared runtime exposes neither Windows drives nor process interop"); - assert.doesNotMatch(windowsRuntime, /--update/); - assert.match(channelComputers, /HELM_WSL_SETUP_STATUS/, "Windows runtime install is tracked through a status file instead of fire-and-forget Start-Process"); - assert.match(channelComputers, /windows_setup/, "runtime readiness exposes Windows shared-runtime setup progress"); - assert.match(channelComputers, /spawn\("powershell\.exe"/, "Windows setup is a tracked child process owned by the app server"); - assert.doesNotMatch(channelComputers, /Start-Process -FilePath 'powershell\.exe' -ArgumentList @\('-NoProfile','-ExecutionPolicy','Bypass','-File'/, "Windows setup no longer fire-and-forgets an untracked PowerShell window"); + assert.equal(macFilter.test("/scripts/release-only-helper.cjs"), true, "non-runtime release helpers stay outside the desktop app"); + assert.equal(macFilter.test("/test"), true, "tests stay outside the desktop app"); + assert.match(channelComputers, /"--exec", "\/usr\/libexec\/1helm-oci-runtime", \.\.\.args/, "the shared WSL runtime is entered with direct argv instead of an intervening shell"); const helperInstall = await readFile(join(root, "scripts", "ensure-node-pty-helper.cjs"), "utf8"); assert.match(helperInstall, /process\.platform === "darwin"/); assert.match(helperInstall, /chmodSync\(helper, 0o755\)/, "Mac installs restore node-pty's executable spawn helper before terminals open"); @@ -334,32 +264,25 @@ test("release packaging is fail-closed and records stable product identity", asy assert.match(source, /codesign", \["--verify", "--strict", "--verbose=2", cloudflared\]/, "release packaging verifies the bundled connector's post-signing seal"); }); -test("Windows packaging ships app code in asar and keeps external consumers on real files", async (t) => { - const windowsPackager = await readFile(join(root, "scripts", "package-windows.cjs"), "utf8"); +test("the desktop shell keeps external consumers on real files and the sidecar bundle is self-contained", async (t) => { const mainCjs = await readFile(join(root, "desktop", "main.cjs"), "utf8"); const photon = await readFile(join(root, "src", "server", "photon.ts"), "utf8"); const pkg = JSON.parse(await readFile(join(root, "package.json"), "utf8")); - // Legacy Squirrel expands every loose package file through 260-character - // .NET path APIs while releasifying and again on end-user machines during - // install/update. Application code must ship inside app.asar. - assert.doesNotMatch(windowsPackager, /asar: false/, "loose-file Windows packaging exceeds legacy Squirrel's path budget"); - assert.match(windowsPackager, /unpack: "\*\*\/\*\.node"/, "native modules are unpacked so they can load from disk"); - assert.match(windowsPackager, /unpackDir: "\{scripts,container,deploy,public,desktop,node_modules\/node-pty\}"/, "assets read by external processes stay on real disk"); - assert.match(windowsPackager, /missing app\.asar/, "packaging proves the archive was actually created"); - assert.match(windowsPackager, /app\.asar\.unpacked/, "packaging verifies required assets in the unpacked tree"); - assert.match(windowsPackager, /photon-sidecar\.bundle\.mjs is missing/, "packaging refuses to run without the sidecar bundle"); assert.ok(String(pkg.scripts.build).includes("build:sidecar"), "the standard build produces the sidecar bundle"); assert.ok(String(pkg.scripts["build:sidecar"]).includes("--bundle"), "the sidecar bundle is self-contained"); + assert.equal(pkg.scripts["package:windows"], undefined, "no Windows packaging lane exists: Windows installs the Linux build into WSL 2"); + assert.equal(pkg.scripts["package:windows:release"], undefined, "no Windows release packaging lane exists"); + for (const retired of ["electron-winstaller", "png-to-ico"]) { + assert.equal(pkg.devDependencies[retired], undefined, `${retired} only existed for Squirrel/Windows packaging`); + } assert.match(mainCjs, /function unpackedPath\(/, "the desktop shell can translate asar paths for external consumers"); assert.match(mainCjs, /const assetRoot = unpackedPath\(appRoot\)/, "external-asset root leaves the asar in packaged builds"); assert.match(mainCjs, /process\.env\.HELM_APP_ROOT = assetRoot/, "server asset consumers receive the real on-disk root"); assert.match(mainCjs, /process\.chdir\(assetRoot\)/, "chdir into a virtual asar path would throw at startup"); - assert.match(mainCjs, /unpackedPath\(path\.resolve\(__dirname, "\.\.", "scripts", "windows-removal\.cjs"\)\)/, "the Squirrel uninstall hook runs the removal script from real disk"); assert.ok(photon.includes('join(String(process.env.HELM_APP_ROOT || process.cwd()), "desktop", "photon-sidecar.bundle.mjs")'), "asar builds start the sidecar from the unpacked self-contained bundle"); - if (process.platform === "win32") return; // Prove the sidecar bundle actually builds and resolves every module on its // own: run it from a foreign directory with no credentials and require an // application-level outcome, never a module-resolution failure. diff --git a/test/release-governance.mjs b/test/release-governance.mjs index f3f7c30..e15d8c7 100644 --- a/test/release-governance.mjs +++ b/test/release-governance.mjs @@ -37,6 +37,15 @@ test("one retained Mac Studio owns the complete macOS release gate", () => { assert.match(tracked, /Application Support preservation/i); }); +// The tracked release authority: three published artifacts, and Windows accepted +// by behaviour because it publishes nothing at all. +const RELEASE_DOCS = { + "docs/release-checklist.md": read("docs/release-checklist.md"), + "docs/release-lifecycle.md": read("docs/release-lifecycle.md"), + "docs/GOVERNANCE.md": read("docs/GOVERNANCE.md"), + "docs/release-notes-template.md": read("docs/release-notes-template.md"), +}; + test("desktop releases fail closed unless Mac, Linux, and Windows ship together", () => { const checklist = read("docs/release-checklist.md"); const lifecycle = read("docs/release-lifecycle.md"); @@ -47,15 +56,86 @@ test("desktop releases fail closed unless Mac, Linux, and Windows ship together" for (const source of [checklist, lifecycle, governance, notes, vision]) { assert.match(source, /Mac(?:OS)?[^\n]+Linux[^\n]+Windows|macOS[^\n]+Linux[^\n]+Windows/i); } - for (const artifact of [ - "arm64.dmg", "mac-arm64.zip", "linux-node.tgz", - "windows-x64-setup.exe", "full.nupkg", "RELEASES", - ]) assert.match(checklist + notes, new RegExp(artifact.replaceAll(".", "\\."), "i")); assert.match(checklist + lifecycle + governance, /pause (?:the )?(?:whole|entire) release/i); - assert.match(checklist, /package:windows:release/); - assert.match(checklist, /Authenticode/i); - assert.match(checklist + lifecycle + governance, /NotSigned[\s\S]+must not block|unsigned is accepted/i); assert.match(checklist + governance, /Never[\s\S]+self-signed|Never[\s\S]+self-sign/i); - assert.match(checklist, /Squirrel/i); assert.doesNotMatch(notes, /Other published artifact, or `Not applicable`/i); }); + +test("every release document names the same three published artifacts", () => { + for (const [path, source] of Object.entries(RELEASE_DOCS)) { + for (const artifact of ["arm64.dmg", "mac-arm64.zip", "linux-node.tgz"]) { + assert.match(source, new RegExp(`1Helm-[^\\s\`]*${artifact.replaceAll(".", "\\.")}`, "i"), + `${path} must name the ${artifact} release artifact`); + } + assert.match(source, /three[\s\S]{0,40}(?:artifacts?|files?|rows?)/i, + `${path} must state that the desktop matrix is exactly three artifacts`); + assert.doesNotMatch(source, /six[- ](?:file|artifact)|complete six/i, + `${path} must not describe a six-artifact desktop matrix`); + } + const checklist = RELEASE_DOCS["docs/release-checklist.md"]; + assert.match(checklist, /for artifact in "\$DMG" "\$UPDATE_ZIP" "\$HEADLESS"; do/, + "the checklist verifies exactly the three built artifacts"); + assert.match(checklist, /gh release create "v\$\{VERSION\}" \\\n "\$DMG" "\$UPDATE_ZIP" "\$HEADLESS" \\\n --title/, + "the publish command attaches exactly the three artifacts"); +}); + +test("release documents never reintroduce a Windows artifact, installer or signing lane", () => { + // Windows has no executable, so there is nothing to package, no update + // manifest to publish and no signature status to disclose. Any of these tokens + // reappearing means the retired Electron/Squirrel lane crept back in. + const retired = [ + [/squirrel/i, "Squirrel"], + [/\.nupkg/i, "a .nupkg package"], + [/\bRELEASES\b/, "a RELEASES manifest"], + [/authenticode/i, "Authenticode signing"], + [/setup\.exe|windows[- ]x64[- ]setup/i, "a Windows Setup executable"], + [/package:windows/, "a Windows packaging npm script"], + [/NotSigned/, "a Windows NotSigned signature disclosure"], + [/electron-winstaller/i, "the Squirrel installer builder"], + [/native-windows|Windows update feed/i, "a Windows update feed"], + ]; + for (const [path, source] of Object.entries(RELEASE_DOCS)) { + for (const [pattern, description] of retired) { + assert.doesNotMatch(source, pattern, `${path} must not reintroduce ${description}`); + } + assert.match(source, /Windows (?:publishes|owns) no/i, + `${path} must state plainly that Windows publishes no release artifact`); + } +}); + +test("Windows is accepted by behaviour through the site-served PowerShell installer", () => { + const checklist = RELEASE_DOCS["docs/release-checklist.md"]; + const governance = RELEASE_DOCS["docs/GOVERNANCE.md"]; + const lifecycle = RELEASE_DOCS["docs/release-lifecycle.md"]; + const notes = RELEASE_DOCS["docs/release-notes-template.md"]; + + for (const [path, source] of Object.entries(RELEASE_DOCS)) { + assert.match(source, /https:\/\/1helm\.com\/install\.ps1/, + `${path} must install Windows from the site-served install.ps1`); + } + // Each numbered behavioural requirement of the Windows acceptance lane. + for (const [pattern, requirement] of [ + [/irm https:\/\/1helm\.com\/install\.ps1 \| iex/, "the exact PowerShell one-liner"], + [/non-elevated/i, "a non-elevated PowerShell window"], + [/(?:one|single|exactly one) UAC prompt/i, "exactly one UAC prompt"], + [/restart/i, "the mid-install restart"], + [/resum/i, "the resumed second run"], + [/keepalive/i, "the keepalive"], + [/reboot/i, "keepalive survival across a reboot"], + [/http:\/\/localhost:8123/, "the browser reaching localhost:8123"], + [/\/var\/lib\/1helm-oci-v1/, "the retained data root"], + [/uninstall\.ps1/, "removal via uninstall.ps1"], + ]) { + for (const [path, source] of [["docs/release-checklist.md", checklist], ["docs/GOVERNANCE.md", governance]]) { + assert.match(source, pattern, `${path} must require ${requirement} for Windows acceptance`); + } + } + assert.match(notes, /irm https:\/\/1helm\.com\/install\.ps1 \| iex/, + "release notes tell users the one command that installs Windows"); + assert.match(notes + governance + checklist, /uninstall\.ps1[\s\S]{0,400}wsl --shutdown|wsl --shutdown[\s\S]{0,400}uninstall\.ps1/, + "removal must be documented as never calling wsl --shutdown"); + assert.match(lifecycle + checklist, /served from the site|site-served|serves? .{0,40}install\.ps1/i, + "install.ps1 is served by the site rather than attached to a release"); + assert.match(checklist + lifecycle, /Linux (?:artifact|archive)[\s\S]{0,120}blocks Windows/i, + "a failed Linux artifact must block Windows, which installs it"); +}); diff --git a/test/release-license.mjs b/test/release-license.mjs index f1507cc..c9e567b 100644 --- a/test/release-license.mjs +++ b/test/release-license.mjs @@ -54,10 +54,7 @@ test("public surfaces state the MIT-to-AGPL release boundary honestly", () => { test("desktop package filters retain LICENSE and NOTICE and the Mac DMG exposes both", () => { const mac = read("scripts/package-mac-dmg.cjs"); - const windows = read("scripts/package-windows.cjs"); - for (const source of [mac, windows]) { - assert.match(source, /LICENSE\$\|NOTICE\$/); - } + assert.match(mac, /LICENSE\$\|NOTICE\$/); assert.match(mac, /path\.join\(ROOT, "LICENSE"\), path\.join\(stage, "LICENSE\.txt"\)/); assert.match(mac, /path\.join\(ROOT, "NOTICE"\), path\.join\(stage, "NOTICE\.txt"\)/); }); diff --git a/test/site.mjs b/test/site.mjs index 428ec2f..2f2ced6 100644 --- a/test/site.mjs +++ b/test/site.mjs @@ -60,14 +60,35 @@ test("standalone 1helm.com website serves independent product and documentation assert.equal(response.headers.get("cache-control"), "no-cache", `${path} must never be browser-cached`); } const gettingStarted = await (await fetch(`${base}/manual/getting-started`)).text(); - assert.match(gettingStarted, /On Windows 11 x64, download the Setup executable/i); - // The disclosed Authenticode status is release-coupled: assert against the - // shipping version so a patch bump cannot silently leave a stale claim. - const shippingVersion = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; - assert.match(gettingStarted, new RegExp(`v${shippingVersion.replaceAll(".", "\\.")} is NotSigned`, "i")); + // Windows ships no application and no artifact, so the one thing a Windows + // reader needs from this page is the exact install command. The retired + // Setup-executable story must not come back either: SmartScreen and + // Authenticode cannot apply to a product that ships no .exe, so repeating + // them here would warn people about a file that does not exist and tell them + // to click through a dialog they will never see. + assert.match(gettingStarted, /irm https:\/\/1helm\.com\/install\.ps1 \| iex/); + assert.doesNotMatch(gettingStarted, /Setup executable|SmartScreen|Authenticode|NotSigned|Squirrel/i); assert.match(gettingStarted, /connect to an existing HTTPS 1Helm host/i); - assert.doesNotMatch(gettingStarted, /signed Setup executable/i); assert.doesNotMatch(gettingStarted, /withheld/i); + // The Windows guide is the only install instructions Windows has. Each of + // these is something the user sees on screen and would otherwise read as a + // failure: the deliberate stop for a restart, Microsoft's own WSL window, + // the runtime's post-install prepare, and the ExecutionPolicy wall that only + // appears when the script is downloaded rather than piped. + const installWindows = await (await fetch(`${base}/manual/install-windows`)).text(); + assert.match(installWindows, /irm https:\/\/1helm\.com\/install\.ps1 \| iex/); + assert.match(installWindows, /irm https:\/\/1helm\.com\/uninstall\.ps1 \| iex/); + assert.match(installWindows, /Restart required/); + assert.match(installWindows, /Welcome to WSL/); + assert.match(installWindows, /40 seconds/); + assert.match(installWindows, /ExecutionPolicy Bypass -File/); + assert.match(installWindows, /localhost:8123/); + // SmartScreen may only be mentioned to say it does not happen. Telling a + // Windows user to click through "More info -> Run anyway", or naming an + // artifact or an %APPDATA% data root that no longer exists, would send them + // looking for a file this product does not ship. + assert.match(installWindows, /SmartScreen never appears/); + assert.doesNotMatch(installWindows, /Setup executable|Squirrel|NotSigned|Run anyway|%APPDATA%/i); assert.equal((await fetch(`${base}/assets/site.css`)).status, 200); const windowsIcon = await fetch(`${base}/icons/icon-sailboat.ico`); assert.equal(windowsIcon.status, 200); @@ -76,6 +97,18 @@ test("standalone 1helm.com website serves independent product and documentation assert.equal(benchmarkSchema.$id, "https://1helm.com/schemas/autonomy-benchmark-v1.json"); assert.deepEqual(benchmarkSchema.required, ["schema", "product", "kind", "started_at", "finished_at", "deterministic", "scope", "summary", "checks"]); assert.equal((await fetch(`${base}/install.sh`)).status, 200); + // Windows installs via `irm https://1helm.com/install.ps1 | iex`, and that + // script downloads its keepalive payload from this same origin. If either + // route 404s the install fails partway, so both are contract surface. + assert.equal((await fetch(`${base}/install.ps1`)).status, 200); + // Removal is `irm https://1helm.com/uninstall.ps1 | iex` and there is no + // Add/Remove Programs entry to fall back on, so a 404 here strands every + // Windows installation with no supported way off the machine. + assert.equal((await fetch(`${base}/uninstall.ps1`)).status, 200); + for (const part of ["keepalive-install.ps1", "keepalive-run.ps1", "keepalive-remove.ps1", "keepalive-hold.sh"]) { + assert.equal((await fetch(`${base}/keepalive/${part}`)).status, 200, `/keepalive/${part} must be served`); + } + assert.equal((await fetch(`${base}/keepalive/../server.mjs`)).status, 404, "the keepalive route must not escape its directory"); const linuxRelease = await (await fetch(`${base}/api/releases/linux/latest`)).json(); assert.deepEqual(linuxRelease, { version: "0.0.31", @@ -94,9 +127,12 @@ test("standalone 1helm.com website serves independent product and documentation const download = await fetch(`${base}/download/macos`, { redirect: "manual" }); assert.equal(download.status, 302); assert.match(download.headers.get("location") || "", /1Helm-[\d.]+-arm64\.dmg$|\/releases\/latest$/); + // Windows ships no downloadable installer any more - it is a PowerShell + // one-liner that installs the Linux build into WSL - so this must lead to + // the instructions, never to a Setup executable that no longer exists. const windowsDownload = await fetch(`${base}/download/windows`, { redirect: "manual" }); assert.equal(windowsDownload.status, 302); - assert.match(windowsDownload.headers.get("location") || "", /-windows-x64-setup\.exe$|\/releases\/latest$/); + assert.equal(windowsDownload.headers.get("location"), "/manual/install-windows"); } finally { child.kill("SIGTERM"); await new Promise((resolve) => child.once("exit", resolve)); } }); @@ -111,15 +147,25 @@ test("release metadata stays available when GitHub's unauthenticated API is exha const base = `http://127.0.0.1:${port}`; await waitFor(`${base}/health`); const response = await fetch(`${base}/api/releases/linux/latest`); - assert.equal(response.status, 200); // Derived from package.json rather than pinned: the point of this contract // is that the offline fallback serves the SHIPPING release, so hardcoding a // version here would keep passing while the fallback silently went stale. const version = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).version; - const offline = await response.json(); - assert.equal(offline.version, version, "the offline fallback serves the shipping version"); - assert.equal(offline.url, `https://github.com/gitcommit90/1Helm/releases/download/v${version}/1Helm-${version}-linux-node.tgz`); - assert.match(offline.sha256, /^[a-f0-9]{64}$/, "the offline fallback carries a real digest"); + const server = readFileSync(join(root, "site", "server.mjs"), "utf8"); + const pending = server.includes('const PENDING_DIGEST = "pending-release-digest"') + && new RegExp(`\\["1Helm-${version.replaceAll(".", "\\.")}-linux-node\\.tgz", PENDING_DIGEST\\]`).test(server); + if (pending) { + // Digests are the digests OF the release commit's artifacts, so they are + // filled in at publish. Until then this must fail closed rather than hand + // an installer a digest that cannot match what it downloads. + assert.equal(response.status, 503, "a fallback with pending digests must refuse, not serve a wrong digest"); + } else { + assert.equal(response.status, 200); + const offline = await response.json(); + assert.equal(offline.version, version, "the offline fallback serves the shipping version"); + assert.equal(offline.url, `https://github.com/gitcommit90/1Helm/releases/download/v${version}/1Helm-${version}-linux-node.tgz`); + assert.match(offline.sha256, /^[a-f0-9]{64}$/, "the offline fallback carries a real digest"); + } } finally { child.kill("SIGTERM"); await new Promise((resolve) => child.once("exit", resolve)); @@ -198,14 +244,25 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(installer, /RELEASE_METADATA_URL="https:\/\/1helm\.com\/api\/releases\/linux\/latest"/, "fresh installs resolve the complete current release from the product site"); assert.match(installer, /expectedUrl = `https:\/\/github\.com\/gitcommit90\/1Helm\/releases\/download\/v\$\{version\}\/\$\{name\}`/, "fresh installs accept only the canonical artifact URL for the resolved version"); assert.match(installer, /RELEASE_SHA256[\s\S]*sha256sum -c -[\s\S]*tar -xzf/, "fresh installs verify the Linux release digest before extraction"); - assert.match(installer, /install-oci-runtime\.sh[\s\S]*channel-machine\.oci\.tar[\s\S]*npm[^\n]*ci/, "fresh installs reject an artifact without the complete OCI runtime before running release code"); + assert.match(installer, /install-oci-runtime\.sh[\s\S]*channel-machine\.oci\.tar[\s\S]*verify_ready_to_run "\$RELEASE_STAGE"/, "fresh installs reject an artifact without the complete OCI runtime before executing anything the archive shipped"); assert.match(installer, /resources\/cloudflared-linux-\$NODE_ARCH/, "fresh Linux installs reject archives without the connector for the current architecture"); assert.match(installer, /NETWORK_BACKEND_FILE[\s\S]*cat "\$NETWORK_BACKEND_FILE"[\s\S]*printf '%s' netavark[\s\S]*install-oci-runtime\.sh/, "the web bootstrap repairs v0.0.30's newline-terminated Podman backend before invoking release code"); assert.doesNotMatch(installer, /git clone|git checkout/, "fresh installs never combine the current installer with an older source-only tag"); assert.doesNotMatch(installer, /api\.github\.com/, "fresh installs do not depend on unauthenticated GitHub API quota"); - assert.match(installer, /need=\([^\n]*flock[^\n]*make[^\n]*c\+\+[^\n]*python3[^\n]*\)/, "the host updater and native dependency toolchain are probed even when download prerequisites already exist"); + assert.match(installer, /need=\([^\n]*flock[^\n]*python3[^\n]*\)/, "the host updater and Python prerequisites are probed even when download prerequisites already exist"); + assert.doesNotMatch(installer, /need=\([^\n]*(?:\bmake\b|c\+\+)/, "no compiler is probed: the release ships native addons already compiled against the oldest supported glibc"); + assert.doesNotMatch(installer, /build-essential/, "the Linux host never installs a C/C++ toolchain, because nothing on the user's machine builds 1Helm"); assert.match(installer, /import ensurepip[\s\S]*python3-venv/, "the Linux host installs Python's venv support required by durable memory instead of accepting a python3 executable alone"); - assert.doesNotMatch(installer, /npm[^\n]*ci[^\n]*--omit=optional/, "platform-specific optional build packages are retained"); + // The ready-to-run release archive replaces the on-host build entirely. These + // assertions are the user-visible contract: no compiler, no npm registry, and + // a refusal rather than a broken install when the archive cannot actually run. + assert.doesNotMatch(installer, /bin\/npm/, "fresh installs consume the ready-to-run release archive instead of building it on the user's machine"); + assert.match(installer, /node_modules[\s\S]*resources\/linux-native-modules\.json[\s\S]*public\/bundle\.js/, "fresh installs fail closed on a source-only archive that carries neither vendored dependencies nor built client assets"); + assert.match(installer, /manifest\.arch !== hostArch[\s\S]*manifest\.nodeAbi[\s\S]*process\.versions\.modules[\s\S]*process\.dlopen[\s\S]*node-pty[\s\S]*spawn/, "fresh installs prove every shipped native addon loads on this host's exact Node ABI and that node-pty can spawn, so terminals cannot silently be dead"); + assert.match(installer, /verify_ready_to_run "\$RELEASE_STAGE"[\s\S]*RELEASE_ROOT="\$RELEASES_ROOT/, "the staged archive is proven runnable before any release directory is promoted"); + assert.match(installer, /systemctl stop 1helm\.service[\s\S]*createServer\(\)[\s\S]*listen\(8123[\s\S]*network namespace[\s\S]*systemctl start 1helm\.service/, "installs stop first and then refuse to start behind a foreign listener that would answer the readiness probe instead of 1Helm"); + assert.doesNotMatch(installer, /if command -v ss[^\n]*then\n\s*echo "Port 8123/, "the port-collision check never depends on ss being installed, which would skip it in silence"); + assert.match(installer, /systemctl is-active 1helm\.service[\s\S]*api\/setup\/status[\s\S]*journalctl -u 1helm\.service/, "readiness requires this unit to be active rather than any HTTP answer on the port, and prints the journal when it is not"); assert.match(installer, /EXISTING_VERSION=.*package\.json[\s\S]*EXISTING_VERSION.*VERSION/, "repeat installs verify the retained release version"); assert.doesNotMatch(installer, /chown -R[^\n]*\$STATE_ROOT/, "repeat installs never recursively rewrite root-owned OCI channel storage"); assert.match(installer, /chown -R "\$SERVICE_USER:\$SERVICE_USER" "\$RELEASE_ROOT"/, "the extracted application release remains service-owned"); @@ -230,6 +287,17 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(updater, /install-linux-units\.sh/, "updates retain one coherent host service contract"); assert.match(updater, /snapshot_host_contract[\s\S]*rollback_host_contract[\s\S]*cleanup_transaction/, "updates transactionally restore runtime files, symlink, and unit state"); assert.doesNotMatch(updater, /eval|curl[^\n]*\|[^\n]*(?:sh|bash)/, "the root updater never evaluates remote shell content"); + // Updates take the same ready-to-run path as fresh installs. Without this the + // first install is fast but every later update is slow and needs a compiler. + assert.doesNotMatch(updater, /bin\/npm/, "host updates consume the ready-to-run release archive instead of building it on the user's machine"); + assert.match(updater, /has_vendored_dependencies "\$STAGE"[\s\S]*bundle\.js[\s\S]*verify_native_addons "\$STAGE"[\s\S]*mv -- "\$STAGE" "\$RELEASE_ROOT"/, "a staged release proves it is runnable before it is promoted into the release store, so a failure leaves nothing half-promoted"); + assert.match(updater, /manifest\.nodeAbi[\s\S]*process\.versions\.modules[\s\S]*process\.dlopen[\s\S]*node-pty[\s\S]*spawn/, "updates prove every shipped native addon loads on this host's exact Node ABI before promoting the release"); + assert.match(releaseApply, /node_modules[\s\S]*linux-native-modules\.json[\s\S]*bundle\.js[\s\S]*process\.dlopen[\s\S]*snapshot_host_contract/, "the delegated transaction proves the retained release is runnable before it opens the transaction that moves the current symlink"); + assert.match(releaseApply, /systemctl is-active "\$SERVICE_NAME"[\s\S]*api\/setup\/status[\s\S]*journalctl/, "the delegated transaction requires its own unit to be active rather than any HTTP answer on the port"); + const linuxPackaging = readFileSync(`${root}/scripts/package-linux-host.mjs`, "utf8"); + assert.match(linuxPackaging, /"npm", "ci", "--omit=dev"/, "the release archive vendors exactly the production dependency tree the host will run"); + assert.doesNotMatch(linuxPackaging, /--omit=optional/, "platform-specific optional build packages are retained in the shipped dependency tree"); + assert.match(linuxPackaging, /nodeAbi[\s\S]*modules,/, "the shipped manifest records the Node ABI and every native addon the host must be able to load"); const ociInstaller = readFileSync(`${root}/site/public/install-oci-runtime.sh`, "utf8"); const ociHelper = readFileSync(`${root}/scripts/1helm-oci-runtime`, "utf8"); const ociManifest = readFileSync(`${root}/deploy/1helm-oci-runtime-v1.conf`, "utf8"); @@ -282,6 +350,19 @@ test("installer assets are explicit and syntax-valid", () => { assert.match(uninstaller, /"\$HELPER" delete "\$name" "\$INSTALLATION_ID:\$channel_id"/, "uninstall deletes only exact installation-owned containers"); assert.match(uninstaller, /Preserved %s/, "uninstall preserves durable workspace state"); assert.match(uninstaller, /cmp -s[\s\S]*\/etc\/apparmor\.d\/local\/crun[\s\S]*apparmor_parser -r/, "uninstall removes and reloads only the exact app-managed crun profile"); + // Windows has no Add/Remove Programs entry behind it, so this script IS the + // uninstaller. Its destructive half is `wsl --unregister`, which deletes the + // virtual disk holding every channel's files and the entire database - and + // `wsl --shutdown` would additionally stop every other distribution on the + // machine for every user. Both need to stay behind their guards. + const windowsUninstaller = readFileSync(`${root}/site/public/uninstall.ps1`, "utf8"); + assert.doesNotMatch(windowsUninstaller, /(?:&\s*\$WslExe|wsl\.exe)[^\n]*--shutdown/, "removal never invokes `wsl --shutdown`, which would stop every distribution for every user on the machine"); + assert.match(windowsUninstaller, /NEVER calls `wsl --shutdown`/, "the shutdown ban is stated where the next maintainer will read it"); + assert.match(windowsUninstaller, /S-1-5-18/, "removal refuses to run as SYSTEM, which cannot see the user's per-user WSL state"); + assert.match(windowsUninstaller, /function Test-SafeTarget[\s\S]*IsNullOrWhiteSpace[\s\S]*ProtectedDistroPattern[\s\S]*-ceq \$Name/, "a blank, protected, or inexactly matching distribution name is refused before anything destructive"); + assert.match(windowsUninstaller, /Test-SafeTarget \$Distro[\s\S]*--terminate[\s\S]*--unregister/, "targeted terminate and unregister both sit behind that exact-name gate"); + assert.match(windowsUninstaller, /if \(-not \$Force\)[\s\S]*Read-Host[\s\S]*-ne 'remove'/, "destroying the data root requires typed confirmation unless -Force is passed"); + assert.match(windowsUninstaller, /keepalive-remove\.ps1[\s\S]*uninstall-host\.sh[\s\S]*--unregister/, "the keepalive stops, then 1Helm's own Linux uninstaller runs inside the distribution, and only then is the distribution discarded"); }); test("standalone deployment runs the website and tunnel without root process authority", () => { @@ -319,16 +400,28 @@ test("the website's offline release fallback names the shipping version", () => const block = server.match(/const RELEASE_FALLBACK = \{[\s\S]*?\n\};/)?.[0]; assert.ok(block, "site/server.mjs still exposes a release fallback block"); assert.match(server, new RegExp(`RELEASE_FALLBACK_TAG = "v${version.replaceAll(".", "\\.")}"`), "the fallback tag matches package.json"); + // Three artifacts, not six: Windows publishes nothing, it installs the Linux + // archive inside WSL. A fallback still naming a Setup executable or .nupkg + // would advertise files the release does not contain. for (const asset of [ `1Helm-${version}-arm64.dmg`, `1Helm-${version}-mac-arm64.zip`, `1Helm-${version}-linux-node.tgz`, - `1Helm-${version}-windows-x64-setup.exe`, - `1Helm-${version}-full.nupkg`, ]) { assert.ok(block.includes(asset), `the release fallback names ${asset}`); } + for (const gone of ["windows-x64-setup.exe", "full.nupkg", '"RELEASES"']) { + assert.ok(!block.includes(gone), `the release fallback must not advertise ${gone}`); + } const digests = [...block.matchAll(/"([a-f0-9]{64})"/g)].map((m) => m[1]); - assert.equal(digests.length, 6, "all six desktop artifacts carry a fallback digest"); - assert.equal(new Set(digests).size, 6, "no two fallback digests are duplicated"); + const pendingCount = [...block.matchAll(/PENDING_DIGEST/g)].length; + if (pendingCount) { + // Pre-publish: every digest must be pending, never a mix. A half-filled + // fallback would serve one real and two wrong digests. + assert.equal(pendingCount, 3, "either all three fallback digests are pending or none are"); + assert.equal(digests.length, 0, "a pending fallback must not also carry a stale real digest"); + } else { + assert.equal(digests.length, 3, "all three desktop artifacts carry a fallback digest"); + assert.equal(new Set(digests).size, 3, "no two fallback digests are duplicated"); + } }); diff --git a/test/update-service.mjs b/test/update-service.mjs index 5574f2e..a2ac676 100644 --- a/test/update-service.mjs +++ b/test/update-service.mjs @@ -42,11 +42,17 @@ test("native Mac updater owns check, download state, and restart installation", assert.deepEqual(calls.at(-1), ["install", [false, true]]); }); -test("native Windows updater uses the host-owned win32-x64 feed", () => { - const { updater } = harness({ platform: "win32", arch: "x64" }); - assert.equal(updater.initialize(), true); - assert.equal(updater.state().mode, "native-windows"); - assert.match(updater.feedUrl, /gitcommit90\/1Helm\/win32-x64\/1\.2\.3$/); +test("the native updater has no Windows lane and never offers a win32 feed", () => { + // Windows is the Linux host inside WSL 2 and updates through the + // in-distribution systemd updater. No Electron host, feed, or artifact exists. + const { updater, calls } = harness({ platform: "win32", arch: "x64" }); + assert.equal(updater.initialize(), false, "a Windows platform must never activate the Electron host updater"); + assert.equal(updater.state().status, "unsupported"); + assert.equal(updater.state().mode, "native-macos"); + assert.doesNotMatch(updater.feedUrl, /win32/, "no Windows update feed is ever constructed"); + assert.match(updater.feedUrl, /gitcommit90\/1Helm\/darwin-arm64\/1\.2\.3$/); + updater.check(); + assert.equal(calls.length, 0, "an unsupported host performs no feed or check calls"); }); test("native updater refuses unsupported placement and sanitizes errors", () => { diff --git a/test/windows-wsl-status.ps1 b/test/windows-wsl-status.ps1 deleted file mode 100644 index 8363583..0000000 --- a/test/windows-wsl-status.ps1 +++ /dev/null @@ -1,41 +0,0 @@ -$ErrorActionPreference = "Stop" -$installerPath = Join-Path (Split-Path -Parent $PSScriptRoot) "scripts\install-wsl-runtime.ps1" -$tokens = $null -$errors = $null -$ast = [System.Management.Automation.Language.Parser]::ParseFile($installerPath, [ref]$tokens, [ref]$errors) -if ($errors.Count) { throw "install-wsl-runtime.ps1 did not parse: $($errors[0].Message)" } - -foreach ($name in @("Read-ReportedSetupStatus", "Get-HostSetupOutcome")) { - $definition = $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and $node.Name -eq $name }, $true) | Select-Object -First 1 - if ($null -eq $definition) { throw "Missing $name in the Windows runtime installer." } - . ([scriptblock]::Create($definition.Extent.Text)) -} - -$temporary = Join-Path ([System.IO.Path]::GetTempPath()) ("1helm-wsl-status-test-" + [Guid]::NewGuid().ToString("N")) -New-Item -ItemType Directory -Path $temporary | Out-Null -try { - $statusPath = Join-Path $temporary "setup-status.json" - $env:HELM_WSL_SETUP_STATUS = $statusPath - [System.IO.File]::WriteAllText($statusPath, '{"status":"restart_required","step":"Restart from elevated pass","error":"Reboot boundary","progress":20}', [System.Text.UTF8Encoding]::new($false)) - $restart = Get-HostSetupOutcome -ExitCode 0 - if ($restart.Status -ne "restart_required" -or $restart.Step -ne "Restart from elevated pass" -or $restart.Detail -ne "Reboot boundary") { - throw "A shared restart_required result was not authoritative over elevated exit code 0." - } - - [System.IO.File]::WriteAllText($statusPath, '{"status":"failed","step":"Elevated failure","error":"Exact elevated error","progress":0}', [System.Text.UTF8Encoding]::new($false)) - $failed = Get-HostSetupOutcome -ExitCode 0 - if ($failed.Status -ne "failed" -or $failed.Detail -ne "Exact elevated error") { - throw "A shared elevated failure was not preserved." - } - - Remove-Item -LiteralPath $statusPath -Force - $zero = Get-HostSetupOutcome -ExitCode 0 - $ten = Get-HostSetupOutcome -ExitCode 10 - if ($zero.Status -ne "continue" -or $ten.Status -ne "restart_required") { - throw "Exit-code fallback behavior is invalid." - } - Write-Host "Windows WSL shared-status transaction regression passed." -} finally { - Remove-Item Env:HELM_WSL_SETUP_STATUS -ErrorAction SilentlyContinue - if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Recurse -Force } -}