Skip to content

Instantly share code, notes, and snippets.

@efenocchi
Created September 10, 2026 05:44
Show Gist options
  • Select an option

  • Save efenocchi/7fe059906196fb1bc2093a16fd987e15 to your computer and use it in GitHub Desktop.

Select an option

Save efenocchi/7fe059906196fb1bc2093a16fd987e15 to your computer and use it in GitHub Desktop.
hivemind installer, branch work/install-node-path, Windows Node bootstrap test
# Hivemind installer — https://deeplake.ai/hivemind.ps1
#
# Windows counterpart of https://deeplake.ai/hivemind.sh.
#
# Installs the @deeplake/hivemind CLI globally, then wires Hivemind into every
# AI assistant it detects on your machine (Claude Code, OpenClaw, Codex, Cursor,
# Hermes, pi) and opens your browser once for SSO.
#
# Usage:
# irm https://deeplake.ai/hivemind.ps1 | iex
#
# # Install for a single assistant (forwarded to the CLI). `iex` cannot take
# # arguments, so build a script block instead:
# & ([scriptblock]::Create((irm https://deeplake.ai/hivemind.ps1))) claude install
#
# # Headless / CI — set the token first; it is inherited by this session:
# $env:HIVEMIND_TOKEN = '<your-token>'; irm https://deeplake.ai/hivemind.ps1 | iex
#
# # Scope captures to a workspace:
# $env:HIVEMIND_WORKSPACE_ID = 'payments-service'; irm https://deeplake.ai/hivemind.ps1 | iex
#
# # Unattended: install Node too, if it is missing. With no console the
# # installer will not download a runtime unless you ask for it here.
# & ([scriptblock]::Create((irm https://deeplake.ai/hivemind.ps1))) install --install-node
#
# Execution policy:
# `irm | iex` evaluates this script as text in the current session, so the
# policy that governs .ps1 *files* never applies to it — that is why it is the
# documented route. If you saved this file instead, an unsigned script on the
# default RemoteSigned policy fails with UnauthorizedAccess. Run it as:
# powershell -ExecutionPolicy Bypass -File .\hivemind.ps1
#
# Telemetry:
# This installer reports which stage it reached — and which one it died on —
# so failures that happen before the CLI exists are visible to us at all. It
# sends the Node/npm version, OS, architecture, PowerShell edition and a
# numeric exit code. It never sends command output, paths, your username,
# hostname, or anything from your environment.
#
# Opt out: $env:HIVEMIND_NO_TELEMETRY = '1'; irm https://deeplake.ai/hivemind.ps1 | iex
param([Parameter(ValueFromRemainingArguments = $true)] [string[]] $CliArgs)
# `iex` evaluates this text inside the caller's own session, so anything set
# here outlives the install and changes how their later commands behave. Saved
# and restored rather than left behind. (Under `-File` it is a fresh process and
# the restore is a no-op.)
$PrevErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Stop'
# Empty rather than $null, so the .Count below is safe even under a caller's
# Set-StrictMode. Setting strict mode off here would be another leak.
if (-not $CliArgs) { $CliArgs = @() }
# Non-empty only when this text was run as a real .ps1 file. `exit` inside
# `irm | iex` does not end a script — it ends the caller's PowerShell session,
# usually closing their terminal. So every failure path routes through
# Stop-Install, which exits only when there is a process of our own to exit.
$RunningAsFile = [bool] $PSCommandPath
$PKG = '@deeplake/hivemind'
$SCRIPT_VERSION = 'ps-3'
$HELP_URL = 'https://deeplake.ai/hivemind/help'
# The Node the installer downloads when the machine has none, pinned by version
# AND by hash. Fetching nodejs.org's SHASUMS256.txt at install time would prove
# only that the download was not truncated: the same server serves both, so
# anyone able to swap one can swap the other. These constants are the trust
# anchor, and they are only as good as how they were produced.
#
# BUMP PROCEDURE — a deliberate release step, kept in step with hivemind.sh:
# 1. curl -fsSLO https://nodejs.org/dist/vX.Y.Z/SHASUMS256.txt
# 2. curl -fsSLO https://nodejs.org/dist/vX.Y.Z/SHASUMS256.txt.sig
# 3. gpgv --keyring ./nodejs-keyring.kbx SHASUMS256.txt.sig SHASUMS256.txt
# 4. copy the hash below FROM THE VERIFIED FILE, never from a mirror
# 5. bump NODE_BOOTSTRAP_VERSION in hivemind.ps1 AND hivemind.sh together
$NODE_BOOTSTRAP_VERSION = '22.20.0'
$NODE_SHA256_win_x64 = 'bb819d6eb8f5bfda294bbc83a7e4ec6539da67c4233d54b0d655b9248b15e29d'
# Node version the published package declares in "engines". Below this npm only
# prints an EBADENGINE warning and installs anyway, so the run is reported and
# then allowed to continue — the histogram of what people actually run on is
# the point, and a hard stop here would block installs that work today.
$MIN_NODE_MAJOR = 22
$BEACON_URL = if ($env:HIVEMIND_BEACON_URL) { $env:HIVEMIND_BEACON_URL }
else { 'https://api.deeplake.ai/telemetry/install-beacon' }
# Windows PowerShell 5.1 — the default shell on every Windows box — negotiates
# TLS 1.0 unless told otherwise, and api.deeplake.ai does not accept it. Without
# this every beacon fails at the handshake on exactly the edition most users are
# running, which would read as "Windows sends no telemetry" rather than "the
# telemetry could not connect".
try {
[Net.ServicePointManager]::SecurityProtocol =
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
} catch { }
# ---------------------------------------------------------------------------
# Output
#
# Write-Host -ForegroundColor rather than ANSI escapes: Windows PowerShell 5.1
# on Windows 10 builds without VT processing prints raw escape sequences as
# literal garbage, and it is the edition most likely to be running this.
# ---------------------------------------------------------------------------
function Say { param([string] $Text = '', [string] $Color)
if ($Color) { Write-Host $Text -ForegroundColor $Color } else { Write-Host $Text }
}
function Err { param([string] $Text) Write-Host "error: $Text" -ForegroundColor Red }
# ---------------------------------------------------------------------------
# Telemetry
#
# Every call is bounded at 2 seconds and swallowed: an installer that is slower,
# or that fails, because our analytics are down would cost more than the numbers
# are worth.
# ---------------------------------------------------------------------------
$Stopwatch = [Diagnostics.Stopwatch]::StartNew()
# Distinguishes two genuine attempts on one machine from one attempt retried by
# the transport: the server namespaces this per install_id and per step, so a
# re-run counts and a transport retry does not.
$RUN_TOKEN = -join ((1..4) | ForEach-Object { '{0:x2}' -f (Get-Random -Minimum 0 -Maximum 256) })
# npm's own machine-readable failure code, filled in by Install-Package. Empty
# until npm actually fails. Without it a Windows npm_failed cannot be told apart
# from EPERM, ENOTFOUND or E404 — the POSIX script has sent it since it was
# instrumented, and the field has been allowlisted server-side the whole time.
$script:NpmCode = ''
# Set by --install-node. With no console the installer will not download a
# runtime unless this says so.
$script:InstallNode = $false
# Set when this run downloaded its own Node, so the launcher is written only for
# the runs that need one.
$script:NodeWasBootstrapped = $false
# When we bootstrapped, the package MUST land beside the runtime we downloaded.
# Setting $script:NpmPrefix does not configure npm -- npm resolves its global
# prefix from its own config, so a `prefix=` in the user's npmrc or an
# npm_config_prefix in the environment wins over the runtime's default. The
# package would install elsewhere while Write-HivemindLauncher looks beside the
# runtime, find nothing, and return without writing a launcher: an install that
# reports success and leaves no working command. A --prefix on the command line
# outranks both config sources.
$script:NpmPrefixArgs = @()
# Mirrors the check in install-id.ts and in the endpoint's installIDPattern.
# Uppercase fails both, so anything minted here is lowercased before it is
# tested — a UUID that fails this is treated as corrupt and rotated, which
# silently breaks the join between the install and the signup.
$UUID_PATTERN = '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$'
function Test-Uuid { param([string] $Value)
return ($null -ne $Value) -and ($Value -match $UUID_PATTERN)
}
function Get-HomeDir {
# node's os.homedir() reads USERPROFILE on Windows, and the CLI resolves
# ~/.deeplake through it. Matching that exactly is what keeps both halves
# pointing at one file.
if ($env:USERPROFILE) { return $env:USERPROFILE }
$p = [Environment]::GetFolderPath('UserProfile')
if ($p) { return $p }
return $HOME
}
# The CLI reads ~/.deeplake/install-id and adopts any valid UUID it finds there
# (src/commands/install-id.ts). Writing it here first is what makes one key span
# download -> install -> login, so the shell-phase events and signup_intent land
# on the same person instead of two halves that cannot be joined.
function Resolve-InstallId {
$home_dir = Get-HomeDir
if (-not $home_dir) { return '' }
$dir = Join-Path $home_dir '.deeplake'
$idFile = Join-Path $dir 'install-id'
if (Test-Path -LiteralPath $idFile -PathType Leaf) {
$existing = ''
try { $existing = ([IO.File]::ReadAllText($idFile)).Trim() } catch { $existing = '' }
if (Test-Uuid $existing) { return $existing }
}
$newId = ([guid]::NewGuid()).ToString().ToLowerInvariant()
if (-not (Test-Uuid $newId)) { return '' }
# Written as raw ASCII bytes, not Set-Content or Out-File: on Windows
# PowerShell 5.1 those append a line terminator and can prepend a BOM, and
# both make the file fail install-id.ts's regex. The CLI would then treat it
# as corrupt and rotate it, breaking the join this file exists to create.
try {
if (-not (Test-Path -LiteralPath $dir -PathType Container)) {
New-Item -ItemType Directory -Path $dir -Force | Out-Null
}
[IO.File]::WriteAllBytes($idFile, [Text.Encoding]::ASCII.GetBytes($newId))
} catch { }
# An ID we could not persist is worse than no ID: the CLI would mint its own
# on the next run, so this one identifies a single beacon burst and nothing
# else — a PostHog person that can never be joined to a signup and inflates
# the denominator forever. install-id.ts returns an empty string for the
# same reason. Read it back rather than trusting the write.
$written = ''
try { $written = ([IO.File]::ReadAllText($idFile)).Trim() } catch { $written = '' }
if ($written -ceq $newId) { return $newId }
return ''
}
# $InstallId is empty when the user opted out, so the opt-out is enforced in one
# place instead of being re-checked at every call site.
function Test-TelemetryEnabled { return -not [string]::IsNullOrEmpty($script:InstallId) }
# [long], not [int]: the cast is evaluated while Send-Beacon builds its body,
# before the try that swallows transport failures, so an overflow here does not
# lose a beacon — it ends the install. [int] overflows at 24.9 days, which the
# cli_exited beacon can reach because the CLI it times is an interactive session
# the user can leave open. The server accepts twelve digits.
function Get-ElapsedMs { return [long] $Stopwatch.Elapsed.TotalMilliseconds }
# Send-Beacon <step> [exitCode]
#
# The body is escaped field by field with EscapeDataString rather than handed to
# Invoke-WebRequest as a hashtable: a version string or a --ref code with an
# unexpected character would otherwise corrupt the body and silently drop the
# event we are trying to record.
function Send-Beacon {
param([string] $Step, [string] $Code = '')
if (-not (Test-TelemetryEnabled)) { return }
$fields = [ordered]@{
step = $Step
script_version = $SCRIPT_VERSION
node = $script:EnvNode
npm = $script:EnvNpm
os = $script:EnvOs
arch = $script:EnvArch
shell = $script:EnvShell
ci = $script:EnvCi
tty = $script:EnvTty
prefix_writable = $script:EnvPrefixWritable
ref = $script:RefCode
code = $Code
npm_code = $script:NpmCode
ms = (Get-ElapsedMs)
idem = $RUN_TOKEN
}
$pairs = foreach ($k in $fields.Keys) {
'{0}={1}' -f $k, [Uri]::EscapeDataString([string] $fields[$k])
}
$body = $pairs -join '&'
try {
Invoke-WebRequest -Uri $BEACON_URL -Method Post -TimeoutSec 2 -UseBasicParsing `
-Headers @{
'X-Hivemind-Install-Id' = $script:InstallId
'X-Deeplake-Client' = "hivemind-installer/$SCRIPT_VERSION"
} `
-ContentType 'application/x-www-form-urlencoded' `
-Body $body | Out-Null
} catch { }
# -UseBasicParsing: without it, 5.1 hands the response to the Internet
# Explorer engine, which is absent or unconfigured on Server Core and on any
# box where IE first-run was never completed, and the call throws there.
}
# Everything here is a bounded enumeration or a number. Nothing derived from a
# path, a username, a hostname, or command output goes into these.
# `node -v` is trusted to print a version, and normally does. But a PATH shim, a
# wrapper script or a broken install can print anything at all — a path, an
# error carrying the username — and it would go straight into the beacon body,
# which is exactly what the privacy contract says never happens. So the value is
# only accepted if it has the shape of a version, and a non-zero exit is treated
# as no answer rather than as data.
function Read-VersionOutput {
param([string] $Exe, [string[]] $VersionArgs = @('-v'))
$raw = $null
$code = $null
# NOT `... | Select-Object -First 1`: -First stops the pipeline as soon as it
# has its one object, and a short-circuited pipeline leaves $LASTEXITCODE
# unset. The exit-code check below then compares against $null, is true for
# every probe including the healthy ones, and every version is reported as
# 'none'. Collect first, take the first line after.
try {
$raw = & $Exe @VersionArgs 2>$null
$code = $LASTEXITCODE
} catch { return 'none' }
# A native command that fails does not throw, on either edition, whatever
# $ErrorActionPreference says — so the code has to be read explicitly or a
# failed probe's stdout reads as a successful answer.
if ($code -ne 0) { return 'none' }
$out = @($raw) | Select-Object -First 1
if ($null -eq $out) { return 'none' }
$out = ([string] $out).Trim()
if ($out -match '^v?[0-9]+(\.[0-9]+){0,3}([-+][0-9A-Za-z.-]{1,24})?$') { return $out }
return 'unknown'
}
function Get-InstallEnv {
$node = Get-Command node -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($node) { $script:EnvNode = Read-VersionOutput $node.Source }
if (-not $script:EnvNode) { $script:EnvNode = 'none' }
$script:NpmCommand = Resolve-NpmCommand
if ($script:NpmCommand) { $script:EnvNpm = Read-VersionOutput $script:NpmCommand }
if (-not $script:EnvNpm) { $script:EnvNpm = 'none' }
# uname does not exist here. `Windows` is a value no `uname -s` ever
# produces, so it slots into the same PostHog breakdown as Linux and Darwin
# without colliding with either.
$script:EnvOs = 'Windows'
# Mapped onto the `uname -m` vocabulary the POSIX script sends, so one arch
# breakdown covers all three platforms instead of x64/X64/x86_64 splitting
# the same machines across three rows.
# RuntimeInformation.OSArchitecture only exists on .NET Framework 4.7.1+, and
# Windows PowerShell 5.1 can be hosted on an older 4.x. Uncaught, the missing
# type ends the run inside this function — before the `started` beacon, with
# no message — on precisely the old machines worth measuring.
# PROCESSOR_ARCHITECTURE has been set by Windows since NT and is the fallback.
$rawArch = ''
try {
$rawArch = [string] [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture
} catch {
$rawArch = switch ("$env:PROCESSOR_ARCHITEW6432 $env:PROCESSOR_ARCHITECTURE") {
{ $_ -match 'ARM64' } { 'Arm64' }
{ $_ -match 'AMD64' } { 'X64' }
{ $_ -match 'ARM' } { 'Arm' }
{ $_ -match 'x86' } { 'X86' }
default { '' }
}
}
$script:EnvArch = ConvertTo-UnameArch $rawArch
# The POSIX field is the basename of $SHELL. The equivalent distinction here
# is the edition: `powershell` is Windows PowerShell 5.1, which ships with
# Windows and behaves differently enough (TLS defaults, Out-File encodings,
# no VT) that a failure histogram split by it is worth having.
$script:EnvShell = if ($PSVersionTable.PSEdition -eq 'Core') { 'pwsh' } else { 'powershell' }
$script:EnvCi = if ($env:CI) { '1' } else { '0' }
# There is no /dev/tty to open. Measured on pwsh 7.4.6: [Environment]::
# UserInteractive returns True both inside a pty and under setsid with
# redirected stdio, and $Host.UI.RawUI.WindowSize throws in neither — so
# neither of those is the test. IsInputRedirected is the one that actually
# discriminates (False in a pty, True when redirected), and it is what
# answers the question the field exists for: could this run have shown the
# SSO prompt, or is a missing signup_intent the legitimate outcome?
#
# Note the shape of `irm | iex` differs from `curl | sh` here: iex evaluates
# the script text inside the user's own session, so stdin is still their
# console. There is no pipe to fight, and no /dev/tty redirect to replace.
try {
$script:EnvTty = if ([Console]::IsInputRedirected) { '0' } else { '1' }
} catch { $script:EnvTty = '0' }
# Predicts an EPERM before it happens — the Windows spelling of the EACCES
# the POSIX script is watching for. Probed with a real file create because
# there is no `test -w`: an ACL that denies writes is invisible to Test-Path.
$script:EnvPrefixWritable = '0'
if ($script:NpmCommand) {
$prefix = ''
try {
$prefix = (& $script:NpmCommand config get prefix 2>$null | Select-Object -First 1)
if ($LASTEXITCODE -ne 0) { $prefix = '' }
} catch { $prefix = '' }
$script:NpmPrefix = $prefix
if ($prefix -and (Test-Path -LiteralPath $prefix -PathType Container)) {
$probe = Join-Path $prefix (".hivemind-write-probe-" + $RUN_TOKEN)
try {
[IO.File]::WriteAllBytes($probe, [byte[]]@())
Remove-Item -LiteralPath $probe -Force -ErrorAction SilentlyContinue
$script:EnvPrefixWritable = '1'
} catch { $script:EnvPrefixWritable = '0' }
}
}
}
# npm on Windows is npm.cmd, npm.ps1 and a bare npm shell script sitting in one
# directory. -CommandType Application selects the .cmd and skips the .ps1 on
# purpose: a .ps1 is subject to the execution policy, so under Restricted — the
# default on Windows Server — resolving npm to it makes the install fail with
# UnauthorizedAccess on a machine where npm is installed and working.
function Resolve-NpmCommand {
$c = Get-Command npm -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($c) { return $c.Source }
$c = Get-Command npm -ErrorAction SilentlyContinue | Select-Object -First 1
if ($c) { return $c.Source }
return ''
}
# npm prints its machine-readable failure code on a line of its own — "npm error
# code EPERM" on npm 10+, "npm ERR! code EPERM" on 9 and earlier. That line
# carries no path and no username, unlike every line around it, which is why it
# is the one piece of npm's output this script is allowed to forward. Both
# spellings are matched because a Windows box running an old bundled npm is
# exactly the population this measurement exists for.
function Read-NpmErrorCode {
param([string] $Output)
if (-not $Output) { return '' }
# Anchored to a whole line: npm repeats its summary at the end of a failed
# run, and an unanchored match would also pick up the word "code" out of a
# package name or a registry URL.
$m = [regex]::Matches($Output, '(?m)^npm (?:error|ERR!) code ([A-Z0-9]{2,20})\s*$')
if ($m.Count -eq 0) { return '' }
$code = $m[0].Groups[1].Value
# The server's allowlist shape is ^E[A-Z0-9]{2,20}$; anything else would be
# dropped there anyway, so it is dropped here where it is visible.
if ($code -notmatch '^E[A-Z0-9]{1,19}$') { return '' }
return $code
}
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# Architecture vocabulary.
#
# There are exactly TWO vocabularies in this script and they are defined here,
# together, because keeping them apart is what broke the Windows bootstrap: the
# beacon normalised x64 to the `uname -m` spelling `x86_64` while Install-Node
# still tested for `x64`, so every Windows x64 machine — the overwhelming
# majority — silently refused to bootstrap Node, with or without --install-node.
# The accept-list and the normaliser must not be able to drift apart again, so
# neither is written out at its use site: ConvertTo-UnameArch is the only thing
# that produces a value, ConvertTo-NodeArch is the only thing that consumes one,
# and installer-tests/arch.test.ps1 fails if any output of the first is unknown
# to the second.
#
# 1. The `uname -m` vocabulary, which the beacon sends so one PostHog arch
# breakdown covers Linux, macOS and Windows instead of splitting the same
# machines across x64 / X64 / x86_64.
# 2. Node's own download vocabulary, which is what nodejs.org names its files.
function ConvertTo-UnameArch {
param([string] $RawArch)
switch ($RawArch) {
'X64' { 'x86_64' }
'X86' { 'i686' }
'Arm64' { 'arm64' }
'Arm' { 'armv7l' }
default { 'unknown' }
}
}
# The Node download arch for a uname-vocabulary value, or '' when we publish no
# verified build for it. '' is the honest answer for i686 and armv7l: nodejs.org
# ships no win-x86 or win-arm build we have pinned a hash for, so those fall
# through to the instructions rather than to a 404 mid-download.
function ConvertTo-NodeArch {
param([string] $UnameArch)
switch ($UnameArch) {
'x86_64' { 'x64' }
'arm64' { 'arm64' }
default { '' }
}
}
# A recursive delete that refuses anything not shaped like our own directory.
#
# Every path this script removes is assembled from $env:LOCALAPPDATA, which the
# script does not choose, and -Recurse -Force without -LiteralPath treats that
# path as a WILDCARD: a legitimate `C:\Users\a[1]\AppData\Local` would retarget
# the delete at a sibling. The single-file case had the same bug and it is worse
# here, so the literal-path handling and the shape check live in one place that
# every caller goes through.
function Remove-HivemindTree {
param([string] $Path)
if (-not $Path) { return }
# Ours looks like ...\hivemind\node\<something>, optionally with the
# .incoming.<pid> staging suffix. Anything else is refused.
if ($Path -notmatch '[\\/]hivemind[\\/]node[\\/][^\\/]+$') { return }
# A final segment of . or .. satisfies the pattern above and then resolves
# UPWARD: ...\hivemind\node\.. is ...\hivemind, so a -Recurse -Force here
# would take the parent tree. Not reachable today, because Get-NodePrefix
# builds the last segment from a hardcoded version plus .incoming.<pid>, but
# this function exists to be paranoid about paths and a guard that accepts ..
# is not paranoid. Rejected explicitly rather than relied upon to be
# unreachable.
$leaf = ($Path -split '[\\/]')[-1]
if ($leaf -eq '.' -or $leaf -eq '..') { return }
Remove-Item -Recurse -Force -LiteralPath $Path -ErrorAction SilentlyContinue
}
# Where a private Node lives. Under LOCALAPPDATA, versioned, owned entirely by
# us: removing that one directory puts the machine back exactly as it was.
# Nothing here needs Administrator and nothing touches a system path.
function Get-NodePrefix {
$base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path (Get-HomeDir) 'AppData\Local' }
return (Join-Path $base "hivemind\node\$NODE_BOOTSTRAP_VERSION")
}
# The launcher lives in its own directory, deliberately NOT the runtime prefix:
# an npm reinstall rewrites shims inside the prefix, and a launcher sitting there
# would be overwritten by the very thing it exists to wrap.
function Get-LauncherDir {
$base = if ($env:LOCALAPPDATA) { $env:LOCALAPPDATA } else { Join-Path (Get-HomeDir) 'AppData\Local' }
return (Join-Path $base 'hivemind\bin')
}
function Say-NodeInstructions {
Say
Say "Install Node.js $MIN_NODE_MAJOR+ and re-run this installer:"
Say ' winget: winget install OpenJS.NodeJS.LTS' 'Blue'
Say ' scoop: scoop install nodejs-lts' 'Blue'
Say ' or: https://nodejs.org' 'Blue'
Say
Say 'Then open a NEW terminal - an installer that just edited PATH cannot'
Say 'change the PATH of a window that was already open.'
Say
Say 'Or let this installer do it, without touching anything system-wide:'
Say ' & ([scriptblock]::Create((irm https://deeplake.ai/hivemind.ps1))) install --install-node' 'Blue'
Say
Say "More help: $HELP_URL#troubleshooting-node" 'Blue'
}
# Downloads Node into the private prefix and puts it on PATH for this process.
# Returns $true only when a runnable node.exe ended up in place.
function Install-Node {
# The accept test goes through ConvertTo-NodeArch rather than comparing
# spellings here. Comparing here is exactly what broke: this line used to
# test for 'x64', which Get-InstallEnv never produces.
$nodeArch = ConvertTo-NodeArch $script:EnvArch
if (-not $nodeArch) {
# No verified hash for this architecture. Falling through to the
# instructions beats downloading something we cannot check.
return $false
}
# Only win-x64 has a pinned hash today, so anything else is refused above the
# download rather than 404ing halfway through it.
if ($nodeArch -ne 'x64') { return $false }
$prefix = Get-NodePrefix
$zipName = "node-v$NODE_BOOTSTRAP_VERSION-win-$nodeArch.zip"
$staging = "$prefix.incoming.$PID"
Send-Beacon 'node_install_started'
Say "Downloading Node $NODE_BOOTSTRAP_VERSION (about 30 MB) ..." 'Blue'
try {
# Staged beside the prefix and moved into place only once verified, so an
# interrupted download cannot leave a partial runtime a later run would
# mistake for a good one.
# [IO.Directory]::CreateDirectory, not New-Item. New-Item has NO
# -LiteralPath parameter at all, so passing one throws a parameter-binding
# error that Install-Node's catch turns into "bootstrap failed" -- on every
# Windows machine, before anything is downloaded. The .NET API takes a
# literal path by nature, which is what was wanted here, and creates
# intermediate directories the way -Force does.
$null = [IO.Directory]::CreateDirectory($staging)
$zipPath = Join-Path $staging $zipName
Invoke-WebRequest -Uri "https://nodejs.org/dist/v$NODE_BOOTSTRAP_VERSION/$zipName" `
-OutFile $zipPath -UseBasicParsing -ErrorAction Stop
$actual = (Get-FileHash -LiteralPath $zipPath -Algorithm SHA256).Hash.ToLower()
if ($actual -ne $NODE_SHA256_win_x64) {
# Wrong bytes. We cannot say this is Node, so we do not run it.
Remove-HivemindTree $staging
return $false
}
Expand-Archive -LiteralPath $zipPath -DestinationPath $staging -Force -ErrorAction Stop
$extracted = Join-Path $staging "node-v$NODE_BOOTSTRAP_VERSION-win-$nodeArch"
if (-not (Test-Path -LiteralPath (Join-Path $extracted 'node.exe'))) {
Remove-HivemindTree $staging
return $false
}
$null = [IO.Directory]::CreateDirectory((Split-Path -Parent $prefix))
if (Test-Path -LiteralPath $prefix) { Remove-HivemindTree $prefix }
Move-Item -LiteralPath $extracted -Destination $prefix -ErrorAction Stop
Remove-HivemindTree $staging
} catch {
Remove-HivemindTree $staging
return $false
}
# For the rest of this process. The launcher written after a successful
# install is what makes it work in the user's NEXT terminal.
$env:PATH = "$prefix;$env:PATH"
$script:NpmPrefix = $prefix
$script:NpmCommand = (Join-Path $prefix 'npm.cmd')
# Pin npm's global prefix to the runtime we just downloaded.
$script:NpmPrefixArgs = @("--prefix=$prefix")
$script:NodeWasBootstrapped = $true
return $true
}
# A .cmd, never a .ps1: a .ps1 is subject to the execution policy and would
# reintroduce on the launcher the exact failure this release removes from npm's
# generated shim. The target is quoted, arguments are forwarded with %*, and the
# child's status is preserved with `exit /b`.
function Write-HivemindLauncher {
$prefix = Get-NodePrefix
$target = Join-Path $prefix 'hivemind.cmd'
if (-not (Test-Path -LiteralPath $target)) { return }
$dir = Get-LauncherDir
try {
$null = [IO.Directory]::CreateDirectory($dir)
} catch { return }
$launcher = Join-Path $dir 'hivemind.cmd'
if ((Test-Path -LiteralPath $launcher) -and
-not ((Get-Content -Raw -LiteralPath $launcher -ErrorAction SilentlyContinue) -match 'hivemind private-runtime launcher')) {
Say "note: $launcher already exists and was left alone." 'Yellow'
return
}
$body = @"
@echo off
rem hivemind private-runtime launcher - written by the hivemind installer.
rem Remove this file and $prefix to undo it.
set "PATH=$prefix;%PATH%"
"$target" %*
exit /b %ERRORLEVEL%
"@
try {
# NOT ASCII: the body embeds absolute paths derived from LOCALAPPDATA, and
# a profile like C:\Users\Jose with an accent becomes C:\Users\Jos? under
# ASCII -- a launcher that cannot find its own runtime, which is a silent
# bootstrap failure for every user whose name is not ASCII.
#
# Oem, not UTF8: cmd.exe reads a .cmd in the console's OEM codepage, and a
# UTF-8 file (with or without BOM) is misread there. Oem is the encoding
# the interpreter of this file actually uses.
# -LiteralPath, like every other path operation here. With -Path a
# bracket in LOCALAPPDATA makes this a wildcard: measured, writing to
# `a[1]\hivemind.cmd` landed in the sibling `a1\hivemind.cmd` and the
# intended file was never created. The catch below then returns silently,
# leaving a successful bootstrap with no persistent command.
Set-Content -LiteralPath $launcher -Value $body -Encoding Oem -ErrorAction Stop
} catch { return }
# The user PATH, not the machine PATH: no elevation, and it survives the
# window closing. The process PATH is updated too so the handover below finds
# it without opening a new terminal.
try {
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
if (-not $userPath) { $userPath = '' }
if (($userPath -split ';') -notcontains $dir) {
$joined = if ($userPath.TrimEnd(';')) { $userPath.TrimEnd(';') + ';' + $dir } else { $dir }
[Environment]::SetEnvironmentVariable('Path', $joined, 'User')
}
if (($env:PATH -split ';') -notcontains $dir) { $env:PATH = "$env:PATH;$dir" }
Say "hivemind is on your PATH via $launcher" 'Green'
Say 'Open a NEW terminal for that to take effect in other windows.'
} catch {
Say
Say 'One more step. hivemind was installed to:' 'Yellow'
Say " $launcher" 'Blue'
Say 'which is not on your PATH yet. Add that directory to PATH.'
}
}
function Assert-Node {
$node = Get-Command node -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if (-not $node) {
Send-Beacon 'node_missing'
# A console separates the person this installer exists for - someone who
# pasted a command and is watching - from a CI step. We ask the first and
# never surprise the second with a download it did not request;
# --install-node is how the second one asks.
if ($script:EnvTty -eq '1' -or $script:InstallNode) {
$wantNode = $script:InstallNode
if (-not $wantNode) {
Send-Beacon 'node_install_prompted'
Say
Say "Hivemind needs Node.js $MIN_NODE_MAJOR+, which this machine does not have."
Say 'I can install a private copy just for hivemind - no Administrator,'
Say 'nothing system-wide, removable by deleting one directory:'
Say " $(Get-NodePrefix)" 'Blue'
Say
# Read-Host works here where it cannot on Unix: `irm | iex` and the
# call operator both run in the caller's own console, so there is
# no pipe standing in for stdin.
$answer = Read-Host 'Install it now? [Y/n]'
$wantNode = ($answer -eq '' -or $answer -match '^[Yy]')
}
if ($wantNode) {
if (Install-Node) {
Send-Beacon 'node_install_ok'
Say "Node $NODE_BOOTSTRAP_VERSION installed." 'Green'
} else {
Send-Beacon 'node_install_failed'
Err 'Could not install Node.js automatically.'
Say-NodeInstructions
Stop-Install 1
return
}
} else {
Send-Beacon 'node_install_declined'
Say-NodeInstructions
Stop-Install 1
return
}
} else {
Err 'Node.js is required but was not found.'
Say-NodeInstructions
Stop-Install 1
return
}
}
if (-not $script:NpmCommand) {
Send-Beacon 'node_missing'
Err 'npm is required but was not found.'
Say
Say 'npm ships with the official Node.js installer for Windows. If you have'
Say 'Node but not npm, you are probably on a runtime-only build — reinstall'
Say 'Node from https://nodejs.org'
Stop-Install 1
return
}
# `node -v` prints e.g. v20.20.2; take the major.
$major = 0
if ($script:EnvNode -match '^v?(\d+)\.') { $major = [int] $Matches[1] }
if ($major -eq 0) { return } # unparseable — do not guess, let npm decide
if ($major -lt $MIN_NODE_MAJOR) {
Send-Beacon 'node_too_old'
Say "note: $PKG declares Node $MIN_NODE_MAJOR+, you are on $($script:EnvNode)." 'Yellow'
Say ' Not a blocker on its own - the installer carries on below and npm'
Say ' normally installs it anyway. Upgrade Node if the CLI then'
Say " misbehaves: $HELP_URL#troubleshooting-node"
}
}
function Install-Package {
Say "Installing $PKG ..." 'Blue'
# --workspaces=false is passed for the same reason it is on Unix: npm
# refuses a global install outright when the user's npm config turns
# workspaces on ("Workspaces not supported for global packages"), and npm
# config is cross-platform, so a %USERPROFILE%\.npmrc carrying
# `workspaces=true` fails here identically. A command-line flag outranks
# both the config file and npm_config_* in the environment.
#
# npm's output is captured as well as shown so the workspaces refusal can be
# recognised below: it carries no `npm error code` line, so nothing else in
# this script can tell it apart from a permissions failure.
$npmCode = 0
$npmOutput = ''
# Initialized before the pipeline and read in `finally`, so a terminating
# error inside Tee-Object or Out-Host still leaves whatever npm managed to
# print available to the detection below instead of nothing.
$npmLines = @()
# Cleared first: if the pipeline below throws before npm runs, the catch
# would otherwise read whatever the previous native command left behind.
$global:LASTEXITCODE = 0
# 2>&1 turns npm's stderr into error records, and on Windows PowerShell 5.1
# the first one raises NativeCommandError while $ErrorActionPreference is
# 'Stop'. npm writes warnings to stderr on perfectly good installs, so under
# 'Stop' the capture ends at the first warning: $npmLines holds a prefix,
# Read-NpmErrorCode below never sees the `npm error code` line, and a healthy
# install can take the failure branch. Lowered for the pipeline only and
# restored immediately — npm's own exit status is read explicitly two lines
# down, so nothing here depends on a stderr write being fatal.
$prevEap = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
try {
& $script:NpmCommand install -g --workspaces=false @script:NpmPrefixArgs $PKG 2>&1 |
Tee-Object -Variable npmLines | Out-Host
# $global:, not a bare read. A native command's status is written to the
# global scope, but an unqualified read resolves to the nearest
# enclosing scope first — and `irm | iex` evaluates this text inside the
# caller's session, so a caller function holding its own $LASTEXITCODE
# shadows ours. Measured: a caller-local 42 is what a bare read returns
# after npm exits 1.
$npmCode = $global:LASTEXITCODE
} catch {
$npmCode = if ($global:LASTEXITCODE) { $global:LASTEXITCODE } else { 1 }
} finally {
$ErrorActionPreference = $prevEap
$npmOutput = ($npmLines | Out-String)
}
if ($npmCode -eq 0) {
# The step that was missing. Without it a Windows run reports `started`
# and `cli_launched` and nothing else, so a successful install and an
# install that died inside npm produce the same two events and the
# funnel cannot tell them apart. Sent before the CLI is resolved, so it
# means what it says: npm finished, whatever PATH does next.
Send-Beacon 'npm_ok'
# Before the CLI is resolved, so the bare name the user types later
# cannot resolve to a file the execution policy blocks.
Remove-NpmPowerShellShim
return
}
# Set before any failure beacon fires, so npm_workspaces_config and
# npm_failed both carry it.
$script:NpmCode = Read-NpmErrorCode $npmOutput
# A config that names a workspace (`workspace=<name>`) is the case the flag
# above cannot override — npm rejects the two together — so it still lands
# here, and so does a machine-wide npmrc. There is no elevation story that
# changes an npm setting, so this reports the real fix and stops.
# The second pattern drops its opening words deliberately: npm 10.9.8 prints
# "Can not use --no-workspaces ...", npm 11.11.0 prints "Cannot use ...", and
# matching either spelling alone misses half the installs.
if ($npmOutput -match 'Workspaces not supported for global packages' -or
$npmOutput -match 'no-workspaces and --workspace at the same time') {
# The two refusals are counted apart because their remediation differs:
# a config that NAMES a workspace is the one --workspaces=false cannot
# override, so the user has to delete that line themselves. Its count is
# also what would reopen the filtered-npmrc option, validated and
# deliberately not built; it has fired zero times since telemetry began
# working on 2026-09-03.
$named = $npmOutput -match 'no-workspaces and --workspace at the same time'
$step = if ($named) { 'npm_workspaces_named' } else { 'npm_workspaces_config' }
Send-Beacon $step "$npmCode"
Err "npm is configured to use workspaces, and npm cannot install a global package that way."
Say "This is a setting in your npm config, not a problem with $PKG."
Say
# A FILE EDIT, never an `npm config` command: with this setting in place
# npm config refuses to run ("This command does not support workspaces"),
# so `npm config delete workspace` fails with ENOWORKSPACES and leaves the
# user where they started.
if ($named) {
Say 'Your npm config names a workspace. Delete that line and re-run this'
Say 'installer - npm config cannot remove it while it is set:'
} else {
Say 'Find the workspaces setting in your npm config, delete that line,'
Say 'then re-run. npm config cannot remove it while it is set:'
}
Say ' %USERPROFILE%\.npmrc your own config, the usual one' 'Blue'
Say ' .\.npmrc in the directory you ran this from' 'Blue'
Say ' etc\npmrc next to your Node install, machine-wide' 'Blue'
Say 'If it comes from the environment instead, clear npm_config_workspaces'
Say 'and npm_config_workspace and re-run.'
Say
# One link, not a lecture: every npmrc location, the fact that
# `npm config` cannot remove the setting while it is set, and the
# npm_config_* environment case all live on the help page, which can be
# corrected without redeploying a script people have already piped into
# their shell.
Say 'Where it lives and how to clear it in every case:'
Say " $HELP_URL#troubleshooting-npm-workspaces" 'Blue'
Stop-Install 1
return
}
# There is no sudo here, and no non-interactive equivalent of it: elevation
# on Windows is UAC, which means a consent dialog on the interactive desktop
# or a whole second process launched with -Verb RunAs. Neither can be
# retried inline the way `sudo npm install -g` is on Unix, and a script
# piped from the internet that silently asks for Administrator is not a
# thing this installer should do.
#
# So `npm_sudo_retry` is deliberately never emitted on Windows: there is no
# retry to report, and sending the step anyway would put runs into that
# histogram that never attempted an elevated install. A Windows npm_failed
# with prefix_writable=0 is the same signal without the lie.
Send-Beacon 'npm_failed' "$npmCode"
Err "Failed to install $PKG."
Say
if ($script:EnvPrefixWritable -eq '0') {
# EPERM/EACCES on Windows is almost always a prefix under Program Files
# left behind by an old Node installer, or a corporate policy on it.
# NOT `setx PATH "$env:PATH;..."`, which this script printed until now and
# which breaks the machine two ways:
# - setx truncates the value it writes at 1024 characters, so on any box
# whose PATH is longer (common on a developer machine) it silently
# writes a TRUNCATED PATH and every terminal opened afterwards is
# missing entries. The user arrived because one install failed and
# would leave with a broken shell.
# - $env:PATH is the process PATH, which is Machine + User concatenated.
# Writing that into the USER scope copies every machine entry into the
# user's own, where they appear twice in the resolved PATH and stop
# tracking later machine-level changes.
# The API below has no length limit and touches only the user scope. It is
# what Write-HivemindLauncher already uses; the advice printed here simply
# disagreed with the code in the same file.
Say 'npm cannot write to its global prefix. Point it at a directory you own:'
Say ' npm config set prefix "$env:LOCALAPPDATA\npm-global"' 'Blue'
Say ' $p = [Environment]::GetEnvironmentVariable(''Path'', ''User'')' 'Blue'
Say ' [Environment]::SetEnvironmentVariable(''Path'', "$p;$env:LOCALAPPDATA\npm-global", ''User'')' 'Blue'
Say 'then open a new terminal and re-run this installer.'
Say
Say 'Or re-run this installer from an Administrator PowerShell.'
} else {
Say 'Re-run this installer from an Administrator PowerShell, or install a'
Say 'Node version manager that needs no elevation:'
Say ' https://github.com/coreybutler/nvm-windows' 'Blue'
}
Say
# One link, unconditional. An earlier version routed EPERM to the
# execution-policy section, which was wrong: this file already classifies
# EPERM as a PREFIX PERMISSION failure — "the Windows spelling of the EACCES
# the POSIX script is watching for" (Get-InstallEnv, above) — and the branch
# directly above prints prefix remediation for it. Sending that user to a page
# about script signing could not have helped them, and the message would have
# contradicted itself: `npm config set prefix` followed by a link about
# signing. The execution policy is a different failure at a different moment,
# surfacing as PSSecurityException when a .ps1 launcher is invoked, and it is
# linked from the branches that actually hit it.
Say "More help: $HELP_URL#troubleshooting-npm-permissions" 'Blue'
Stop-Install 1
}
# On Unix a global npm install lands in a bin directory that is already on PATH,
# so "installed but not on PATH" is the edge case. On Windows it is the normal
# one: $env:PATH was copied into this process when the window opened, and
# nothing npm or the Node installer writes to the registry afterwards reaches
# it. So before reporting path_missing, rebuild PATH from the registry and add
# npm's own prefix — that turns the common case back into a working install
# instead of an instruction to close the window.
# -CommandType Application for the same reason npm is resolved that way: npm
# installs hivemind.cmd AND hivemind.ps1 side by side, PowerShell will happily
# pick the .ps1, and a .ps1 is subject to the execution policy — so under
# Restricted the launch fails on a machine where the working .cmd is right
# there. It also skips any alias or function named `hivemind`, whose .Source is
# not an executable path at all.
function Resolve-HivemindCommand {
$found = Get-Command hivemind -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($found) { return $found.Source }
$added = @()
foreach ($scope in @('Machine', 'User')) {
try {
$p = [Environment]::GetEnvironmentVariable('Path', $scope)
if ($p) { $added += $p }
} catch { }
}
if ($script:NpmPrefix) { $added += $script:NpmPrefix }
if ($added.Count -gt 0) {
$env:PATH = (@($env:PATH) + $added) -join [IO.Path]::PathSeparator
}
$found = Get-Command hivemind -CommandType Application -ErrorAction SilentlyContinue |
Select-Object -First 1
if ($found) { return $found.Source }
return ''
}
# npm writes three shims side by side for every global bin — cmd-shim's
# lib/index.js does `writeFile(to + '.ps1', ...)`, `writeFile(to + '.cmd', ...)`
# and `writeFile(to, ...)`. At a PowerShell prompt the bare name `hivemind`
# resolves to the .ps1, and a .ps1 is subject to the execution policy while a
# .cmd is not. So under Restricted or AllSigned every later `hivemind ...` the
# user types dies with PSSecurityException, on a machine where the working .cmd
# is sitting right next to it. This installer already routes around that for
# itself (Get-Command -CommandType Application, below); the user does not.
#
# Deleting npm's generated .ps1 needs no privileges and works on a
# Group-Policy-locked machine. The alternatives were rejected: overwriting it
# with a passthrough leaves a .ps1, which the policy still blocks; Authenticode
# signing needs a certificate and a release pipeline that does not exist, and
# AllSigned still prompts on first run.
#
# npm rewrites the file on every install and every update — cmd-shim removes and
# rewrites all three each time — so this runs after every npm install we do, and
# `hivemind update` does the same on its side.
function Remove-NpmPowerShellShim {
# Only npm's own global bin directory, only the file we can identify, and
# only when the working .cmd is there to replace it. Never a glob, never a
# guessed path, and never a file we did not positively identify as the shim
# npm generated for our package.
if (-not $script:NpmPrefix) { return }
$ps1 = Join-Path $script:NpmPrefix 'hivemind.ps1'
$cmd = Join-Path $script:NpmPrefix 'hivemind.cmd'
# -LiteralPath on EVERY path operation below. Without it PowerShell treats the
# path as a wildcard pattern, and an npm prefix is a user-chosen directory:
# a legitimate `C:\tools\npm[1]` resolves to the neighbouring `npm1`, so this
# function would read one file and delete a different one. That is the whole
# hazard of this function, and it is not hypothetical -- `[` and `]` are legal
# in a Windows directory name.
if (-not (Test-Path -LiteralPath $ps1)) { return }
if (-not (Test-Path -LiteralPath $cmd)) {
# No .cmd means deleting the .ps1 would remove the only way to run it.
return
}
$content = Get-Content -Raw -LiteralPath $ps1 -ErrorAction SilentlyContinue
if (-not $content) { return }
# POSITIVE identification of npm's generated wrapper for OUR package, not two
# substrings. The previous test accepted any file containing `$basedir` and
# the word `hivemind`, which a user's own script can satisfy -- and this
# function DELETES what it matches, so a loose test costs somebody their file
# and whatever configuration it held.
#
# cmd-shim emits both of these verbatim: the $basedir preamble, and an
# invocation naming the package's own path under node_modules. A hand-written
# helper that merely mentions hivemind matches neither.
if ($content -notmatch [regex]::Escape('$basedir=Split-Path $MyInvocation.MyCommand.Definition -Parent')) { return }
if ($content -notmatch [regex]::Escape('node_modules/@deeplake/hivemind') -and
$content -notmatch [regex]::Escape('node_modules\@deeplake\hivemind')) { return }
try {
Remove-Item -Force -LiteralPath $ps1 -ErrorAction Stop
Send-Beacon 'ps1_shim_removed'
} catch {
# A shim we cannot delete is not a reason to fail an install that worked.
# The user keeps the .cmd and the help page explains the rest.
Say "note: could not remove $ps1" 'Yellow'
Say "If ``hivemind`` fails with a security error, see $HELP_URL#troubleshooting-windows-policy"
}
}
function Invoke-Hivemind {
param([string[]] $Arguments)
$cmd = Resolve-HivemindCommand
if (-not $cmd) {
Send-Beacon 'path_missing'
Err 'hivemind was installed but is not on your PATH.'
Say
Say 'On Windows this is usually just a stale session: PATH is read when a'
Say 'terminal starts, so a directory npm added a moment ago is not visible'
Say 'to the window you are standing in. This installer already retried with'
Say 'the registry PATH and with npm''s own prefix, and hivemind was in'
Say 'neither.'
Say
Say 'Open a NEW PowerShell window and run:'
Say ' hivemind install' 'Blue'
Say
Say 'If that still fails, check where npm put it and add that to PATH:'
Say ' npm prefix -g' 'Blue'
Say
Say "More help: $HELP_URL#troubleshooting-path" 'Blue'
Stop-Install 1
return
}
Send-Beacon 'cli_launched'
Say "Running: hivemind $($Arguments -join ' ')" 'Blue'
# No /dev/tty equivalent is needed. `irm | iex` runs this text inside the
# user's own session, so the CLI inherits the real console and the SSO
# prompt works — the problem `curl | sh` has to work around does not exist.
#
# The CLI's own exit status is deliberately left to propagate: the script's
# last act is to hand over to it, and swallowing a real failure here would
# report success for an install that did not finish.
# The status goes into a script-scoped variable, NOT a return value. A
# PowerShell function emits every uncaptured success-stream object, so
# `return $LASTEXITCODE` here would hand back the CLI's entire stdout with
# the code appended: the caller's `-ne 0` then filters that array, finds it
# non-empty, and takes the failure branch on a perfectly good install —
# swallowing the CLI's output and never printing the success line.
# Cleared first, the same way Install-Package clears it before its own
# pipeline. A launch that sets no code does not reset $LASTEXITCODE — it
# keeps whatever the last native command left, and the npm install two steps
# above left a 0 there. Without this the check below reads that stale zero
# and reports a CLI that never ran as a clean exit.
$global:LASTEXITCODE = $null
# A path resolved a moment ago can be gone, or refused, by the time it is
# invoked, and under $ErrorActionPreference = 'Stop' that ends this function
# outright — so the beacon below would never fire, losing the report on
# exactly the failure worth reporting. This is the PowerShell spelling of
# the guard the POSIX script puts around the same call.
try {
if ($Arguments.Count -gt 0) { & $cmd @Arguments } else { & $cmd }
$script:ExitCode = $global:LASTEXITCODE
} catch {
Err $_.Exception.Message
# A non-zero $LASTEXITCODE means the CLI ran and chose that status: with
# $PSNativeCommandUseErrorActionPreference on, a native command exiting
# non-zero THROWS, so the ordinary "CLI exited 7" arrives here rather
# than on the line above. Flattening that to 1 would report every CLI
# failure as the same failure and lose the code the beacon exists to
# carry. 1 is only for a launch that produced no status of its own.
$native = $global:LASTEXITCODE
if (($native -is [int]) -and ($native -ne 0)) {
$script:ExitCode = $native
} else {
$script:ExitCode = 1
}
}
# Without cli_exited, "cli_launched and then no signup_intent" collapses
# three different stories into one: the CLI crashed, it ran headless and
# skipped auth by design, or the user declined the sign-in prompt. The tty
# field separates the second; this separates the first.
#
# $LASTEXITCODE is still $null here when the CLI was launched but set no
# code. The empty string that would produce is dropped by the server's
# numeric shape, taking the beacon's only payload with it, so it is reported
# as a failure rather than as nothing.
if ($null -eq $script:ExitCode) { $script:ExitCode = 1 }
Send-Beacon 'cli_exited' "$($script:ExitCode)"
}
# Ends the install with a status. Never call `exit` directly: see $RunningAsFile.
function Stop-Install {
param([int] $Code = 0)
$script:ExitCode = $Code
$script:Stopped = $true
}
function Invoke-Main {
param([string[]] $Arguments)
# Opting out means opting out of the file too: someone who sets this and
# then finds a freshly minted UUID on their disk has been told one thing and
# given another. The CLI still writes its own on first login, which is its
# decision to explain, not this script's.
if ($env:HIVEMIND_NO_TELEMETRY) {
$script:InstallId = ''
} else {
$script:InstallId = Resolve-InstallId
}
Get-InstallEnv
# The affiliate code is forwarded to the CLI as-is; it is read here only so
# the shell-phase events carry the same campaign attribution the device
# flow parks, instead of two breakdowns that have to be unioned.
$script:RefCode = ''
$prev = ''
foreach ($a in $Arguments) {
if ($prev -eq '--ref') { $script:RefCode = $a }
if ($a -like '--ref=*') { $script:RefCode = $a.Substring(6) }
$prev = $a
}
# A campaign code is a short slug. Anything else here is a mistake or a path
# someone pasted, and it would be stored verbatim next to a person record --
# so drop it rather than forward it. The CLI still receives the original
# argument untouched; only the telemetry copy is filtered.
#
# This filter existed before and was dropped by the argument-parser rewrite,
# which meant every Send-Beacon carried whatever --ref was given, verbatim.
# It is restored here, above the first beacon.
if ($script:RefCode -and
(($script:RefCode -notmatch '^[A-Za-z0-9._-]+$') -or ($script:RefCode.Length -gt 64))) {
$script:RefCode = ''
}
# The installer's own flags are REMOVED from what the CLI is handed. The CLI
# reads argv[0] as its command name, so a stray --install-node would arrive as
# an unknown command and exit 1 AFTER npm had already installed successfully:
# the funnel would read a working install as a CLI crash. Stripping also
# restores the zero-argument case below.
$kept = New-Object System.Collections.Generic.List[string]
$pastDashDash = $false
foreach ($a in $Arguments) {
if ($pastDashDash) { $kept.Add($a); continue }
switch -CaseSensitive ($a) {
'--' { $pastDashDash = $true; $kept.Add($a); continue }
'--install-node' { $script:InstallNode = $true; continue }
default { $kept.Add($a) }
}
}
$Arguments = $kept.ToArray()
Send-Beacon 'started'
# Each step signals through $script:Stopped instead of exiting, so a failure
# ends the install without ending the caller's session.
Assert-Node
if ($script:Stopped) { return }
Install-Package
if ($script:Stopped) { return }
# Only meaningful when this run brought its own Node: the package landed in
# the private prefix, which is on PATH for this process and nowhere else.
if ($script:NodeWasBootstrapped) { Write-HivemindLauncher }
# Default to a full install across all detected assistants. Any arguments
# passed through the script-block form are forwarded verbatim to the CLI.
if ($Arguments.Count -eq 0) { $Arguments = @('install') }
Invoke-Hivemind -Arguments $Arguments
if ($script:Stopped) { return }
# The CLI's own status is deliberately propagated: the script's last act is
# to hand over to it, and reporting success for an install that did not
# finish would be worse than a wrong exit code.
if ($script:ExitCode -ne 0) { return }
Say
Say 'Hivemind is ready.' 'Green'
Say 'Restart your assistants and they all share the same brain.'
}
$script:Stopped = $false
$script:ExitCode = 0
# try/finally, because everything below has to run on EVERY exit path and an
# uncaught terminating error inside Invoke-Main skips a bare sequence of
# statements. That is not hypothetical: without this, a throw leaves the caller's
# session on $ErrorActionPreference = 'Stop', against this file's own stated
# intent at the top — measured, and it changes how every later command they type
# behaves. The token clear has the same requirement.
try {
Invoke-Main -Arguments $CliArgs
} finally {
$ErrorActionPreference = $PrevErrorActionPreference
}
# The harm `exit` does is specific to ONE case: a person typing `irm ... | iex`
# at their own prompt, where this scope is their live session and exiting closes
# the window. Everywhere else a real status is wanted — a saved .ps1 run with
# -File, and `pwsh -Command "irm ... | iex"` in CI, where swallowing the code
# would let a failed install report success.
#
# $EnvTty is the same open-a-console check the beacon sends, and it separates
# the two: an interactive prompt has stdin attached, a CI step does not.
if ($RunningAsFile -or $script:EnvTty -ne '1') {
exit $script:ExitCode
}
$global:LASTEXITCODE = $script:ExitCode
#!/bin/sh
set -e
# Hivemind installer — https://deeplake.ai/hivemind.sh
#
# Installs the @deeplake/hivemind CLI globally, then wires Hivemind into every
# AI assistant it detects on your machine (Claude Code, OpenClaw, Codex, Cursor,
# Hermes, pi) and opens your browser once for SSO.
#
# Usage:
# curl -fsSL https://deeplake.ai/hivemind.sh | sh
#
# # Install for a single assistant (forwarded to the CLI):
# curl -fsSL https://deeplake.ai/hivemind.sh | sh -s -- claude install
#
# # Headless / CI — pass the token to the script side of the pipe:
# curl -fsSL https://deeplake.ai/hivemind.sh | HIVEMIND_TOKEN=<your-token> sh
#
# # Scope captures to a workspace:
# curl -fsSL https://deeplake.ai/hivemind.sh | HIVEMIND_WORKSPACE_ID=payments-service sh
#
# # Unattended: install Node too, if it is missing. Without a terminal the
# # installer will not download a runtime unless you ask for it here.
# curl -fsSL https://deeplake.ai/hivemind.sh | sh -s -- install --install-node
#
# Telemetry:
# This installer reports which stage it reached — and which one it died on —
# so failures that happen before the CLI exists are visible to us at all. It
# sends the Node/npm version, OS, architecture, shell name and a numeric exit
# code. It never sends command output, paths, your username, hostname, or
# anything from your environment.
#
# Opt out: curl -fsSL https://deeplake.ai/hivemind.sh | HIVEMIND_NO_TELEMETRY=1 sh
PKG="@deeplake/hivemind"
SCRIPT_VERSION="4"
HELP_URL="https://deeplake.ai/hivemind/help"
# The Node the installer downloads when the machine has none. Pinned by version
# AND by hash: fetching nodejs.org's SHASUMS256.txt at install time would prove
# only that the download was not truncated, since the same server serves both and
# anyone able to swap one can swap the other. These constants are the trust
# anchor, and they are only as good as how they were produced.
#
# BUMP PROCEDURE — a deliberate release step, not a chore to automate away:
# 1. curl -fsSLO https://nodejs.org/dist/vX.Y.Z/SHASUMS256.txt
# 2. curl -fsSLO https://nodejs.org/dist/vX.Y.Z/SHASUMS256.txt.sig
# 3. gpgv --keyring ./nodejs-keyring.kbx SHASUMS256.txt.sig SHASUMS256.txt
# (build the keyring from the release keys listed in the Node README)
# 4. copy the five lines below FROM THE VERIFIED FILE, never from a mirror
# 5. bump NODE_BOOTSTRAP_VERSION in hivemind.sh AND hivemind.ps1 together
# Step 3 is the whole point: it is what makes these hashes worth more than the
# fetch they replace.
NODE_BOOTSTRAP_VERSION="22.20.0"
NODE_SHA256_darwin_arm64="cc04a76a09f79290194c0646f48fec40354d88969bec467789a5d55dd097f949"
NODE_SHA256_darwin_x64="00df9c5df3e4ec6848c26b70fb47bf96492f342f4bed6b17f12d99b3a45eeecc"
NODE_SHA256_linux_arm64="4181609e03dcb9880e7e5bf956061ecc0503c77a480c6631d868cb1f65a2c7dd"
NODE_SHA256_linux_x64="eeaccb0378b79406f2208e8b37a62479c70595e20be6b659125eb77dd1ab2a29"
# Node version the published package declares in "engines". Below this npm only
# prints an EBADENGINE warning and installs anyway, so the run is reported and
# then allowed to continue — the histogram of what people actually run on is
# the point, and a hard stop here would block installs that work today.
MIN_NODE_MAJOR="22"
BEACON_URL="${HIVEMIND_BEACON_URL:-https://api.deeplake.ai/telemetry/install-beacon}"
# Colors (disabled when stdout is not a terminal)
if [ -t 1 ]; then
GREEN='\033[0;32m'; YELLOW='\033[0;33m'; RED='\033[0;31m'; BLUE='\033[0;34m'; NC='\033[0m'
else
GREEN=''; YELLOW=''; RED=''; BLUE=''; NC=''
fi
say() { printf '%b\n' "$*"; }
err() { printf '%b\n' "${RED}error:${NC} $*" >&2; }
# ---------------------------------------------------------------------------
# Telemetry
#
# Every call is bounded at 2 seconds and swallowed with `|| true`: an installer
# that is slower, or that fails, because our analytics are down would cost more
# than the numbers are worth. curl is required for the beacon and for nothing
# else — you got this script through curl, so it is here.
# ---------------------------------------------------------------------------
START_EPOCH=$(date +%s 2>/dev/null || echo 0)
# npm's own machine-readable failure code, filled in by install_pkg. Empty until
# npm actually fails.
NPM_CODE=""
# Set when this run downloaded its own Node. It decides whether a launcher is
# written afterwards: an install that used the machine's existing Node needs no
# launcher, and writing one would be a file we left behind for no reason.
NODE_WAS_BOOTSTRAPPED=0
# When we bootstrapped, the package MUST land beside the runtime we downloaded.
# Putting the private node first on PATH is not enough to guarantee that: npm
# resolves its global prefix from config, so a `prefix=` line in the user's
# ~/.npmrc or an exported npm_config_prefix wins over the runtime's own default.
# Measured, with a private node on PATH:
# default prefix of the private npm: /tmp/node-v22.20.0-linux-x64
# with npm_config_prefix set: /tmp/elsewhere
# with prefix= in ~/.npmrc: /tmp/from-npmrc
# The package would install to the user's configured prefix while the launcher
# looks beside the runtime, find nothing, and return silently -- an install that
# reports success and leaves no working command. --prefix on the command line
# outranks both config sources, which is why it is passed explicitly.
NPM_PREFIX_ARGS=
# Filled in by the argument scan in main(), read by require_node.
INSTALL_NODE=0
# Distinguishes two genuine attempts on one machine from one attempt retried by
# the transport: the server namespaces this per install_id and per step, so a
# re-run counts and a curl retry does not.
# A pipeline reports only the last command's status, so `od | tr || fallback`
# never reaches the fallback: tr succeeds on the empty input a failed od left it.
# Check the value instead of the exit code.
RUN_TOKEN=$(od -An -tx1 -N4 /dev/urandom 2>/dev/null | tr -d ' \n')
[ -n "$RUN_TOKEN" ] || RUN_TOKEN="$$"
# Each source is checked for a well-formed result rather than for a zero exit:
# these are pipelines, and a pipeline reports only its last command, so a failed
# producer would hand back an empty string that reads as success. An empty or
# malformed ID is rejected by the backend and would lose the run entirely.
is_uuid() {
case "$1" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
return 0 ;;
esac
return 1
}
uuid4() {
if [ -r /proc/sys/kernel/random/uuid ]; then
candidate=$(cat /proc/sys/kernel/random/uuid 2>/dev/null || true)
if is_uuid "$candidate"; then
printf '%s' "$candidate"
return 0
fi
fi
if command -v uuidgen >/dev/null 2>&1; then
# macOS uuidgen prints uppercase; the CLI and the backend both require
# lowercase and treat anything else as corrupt.
candidate=$(uuidgen 2>/dev/null | tr 'ABCDEF' 'abcdef')
if is_uuid "$candidate"; then
printf '%s' "$candidate"
return 0
fi
fi
candidate=$(od -An -tx1 -N16 /dev/urandom 2>/dev/null | tr -d ' \n' | awk '{
printf "%s-%s-4%s-a%s-%s", substr($0,1,8), substr($0,9,4),
substr($0,14,3), substr($0,18,3), substr($0,21,12)
}')
if is_uuid "$candidate"; then
printf '%s' "$candidate"
fi
}
# The CLI reads ~/.deeplake/install-id and adopts any valid UUID it finds there
# (src/commands/install-id.ts). Writing it here first is what makes one key span
# download → install → login, so the shell-phase events and signup_intent land
# on the same person instead of two halves that cannot be joined.
resolve_install_id() {
id_file="$HOME/.deeplake/install-id"
if [ -r "$id_file" ]; then
existing=$(tr -d '\n\r \t' < "$id_file" 2>/dev/null || true)
case "$existing" in
[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f])
printf '%s' "$existing"
return 0
;;
esac
fi
new_id=$(uuid4)
# Same modes the CLI applies: 0700 on the directory, 0600 on the file.
(umask 077; mkdir -p "$HOME/.deeplake" >/dev/null 2>&1 || true
printf '%s' "$new_id" > "$id_file" 2>/dev/null || true)
# An ID we could not persist is worse than no ID: the CLI would mint its own
# on the next run, so this one identifies a single beacon burst and nothing
# else — a PostHog person that can never be joined to a signup and inflates
# the denominator forever. install-id.ts returns an empty string for the
# same reason. Read it back rather than trusting the write.
written=$(tr -d '\n\r \t' < "$id_file" 2>/dev/null || true)
if [ "$written" = "$new_id" ]; then
printf '%s' "$new_id"
fi
}
# INSTALL_ID is empty when the user opted out, so the opt-out is enforced in one
# place instead of being re-checked at every call site.
telemetry_enabled() {
[ -n "$INSTALL_ID" ] && command -v curl >/dev/null 2>&1
}
elapsed_ms() {
now=$(date +%s 2>/dev/null || echo 0)
if [ "$START_EPOCH" -gt 0 ] 2>/dev/null; then
echo $(( (now - START_EPOCH) * 1000 ))
else
echo 0
fi
}
# beacon <step> [exit_code]
#
# --data-urlencode does the escaping, so a version string or a --ref code with
# an unexpected character cannot corrupt the body and silently drop the event
# we are trying to record.
beacon() {
telemetry_enabled || return 0
b_step="$1"
b_code="${2:-}"
curl -sS -o /dev/null -m 2 -X POST "$BEACON_URL" \
-H "X-Hivemind-Install-Id: $INSTALL_ID" \
-H "X-Deeplake-Client: hivemind-installer/$SCRIPT_VERSION" \
--data-urlencode "step=$b_step" \
--data-urlencode "script_version=$SCRIPT_VERSION" \
--data-urlencode "node=$ENV_NODE" \
--data-urlencode "npm=$ENV_NPM" \
--data-urlencode "os=$ENV_OS" \
--data-urlencode "arch=$ENV_ARCH" \
--data-urlencode "shell=$ENV_SHELL" \
--data-urlencode "ci=$ENV_CI" \
--data-urlencode "tty=$ENV_TTY" \
--data-urlencode "prefix_writable=$ENV_PREFIX_WRITABLE" \
--data-urlencode "ref=$REF_CODE" \
--data-urlencode "code=$b_code" \
--data-urlencode "npm_code=$NPM_CODE" \
--data-urlencode "ms=$(elapsed_ms)" \
--data-urlencode "idem=$RUN_TOKEN" \
>/dev/null 2>&1 || true
}
# Everything here is a bounded enumeration or a number. Nothing derived from a
# path, a username, a hostname, or command output goes into these.
collect_env() {
ENV_NODE=$(command -v node >/dev/null 2>&1 && node -v 2>/dev/null || echo none)
ENV_NPM=$(command -v npm >/dev/null 2>&1 && npm -v 2>/dev/null || echo none)
ENV_OS=$(uname -s 2>/dev/null || echo unknown)
ENV_ARCH=$(uname -m 2>/dev/null || echo unknown)
# basename only: $SHELL is a path, and on a Nix or home-prefixed install it
# carries the username.
ENV_SHELL=$(basename "${SHELL:-unknown}" 2>/dev/null || echo unknown)
[ -n "$CI" ] && ENV_CI=1 || ENV_CI=0
# A run with no terminal cannot show the SSO prompt, so the CLI skips auth
# and never reaches signup_intent. Without this flag that legitimate outcome
# is indistinguishable from a user who dropped out.
#
# `[ -e /dev/tty ]` is NOT that test: the device node exists on every normal
# macOS and Linux system, including inside a container where the process has
# no controlling terminal and opening it fails with ENXIO. Opening it is the
# only honest check — and getting this wrong sends an unattended run down the
# interactive sudo branch, straight into the password prompt that `sudo -n`
# exists to avoid.
if (exec 3<>/dev/tty) 2>/dev/null; then
ENV_TTY=1
else
ENV_TTY=0
fi
ENV_PREFIX_WRITABLE=0
if command -v npm >/dev/null 2>&1; then
npm_prefix=$(npm config get prefix 2>/dev/null || echo "")
# An `a && b && c=1` chain returns non-zero when the prefix is NOT
# writable, and as the last command in this function that ends the whole
# script under `set -e` — silently, before a single beacon is sent, in
# exactly the EACCES case this instrumentation exists to measure. The
# else branch is what keeps the status zero.
if [ -n "$npm_prefix" ] && [ -w "$npm_prefix" ]; then
ENV_PREFIX_WRITABLE=1
else
ENV_PREFIX_WRITABLE=0
fi
fi
}
# ---------------------------------------------------------------------------
# Where a private Node lives. Under the XDG data dir, versioned, owned entirely
# by us: removing it is one directory and the machine is exactly as it was.
# Nothing here touches a system path or needs elevation.
node_prefix() {
printf '%s/hivemind/node/%s' "${XDG_DATA_HOME:-$HOME/.local/share}" "$NODE_BOOTSTRAP_VERSION"
}
# The tarball nodejs.org publishes for this machine, or "" for a platform we have
# no pinned hash for. An unknown architecture falls through to the instructions
# rather than failing opaquely on a 404 halfway through a download.
node_tarball_name() {
n_os=""
case "$(uname -s 2>/dev/null)" in
Darwin) n_os="darwin" ;;
Linux) n_os="linux" ;;
esac
n_arch=""
case "$(uname -m 2>/dev/null)" in
x86_64|amd64) n_arch="x64" ;;
arm64|aarch64) n_arch="arm64" ;;
esac
[ -n "$n_os" ] && [ -n "$n_arch" ] || return 0
printf 'node-v%s-%s-%s.tar.gz' "$NODE_BOOTSTRAP_VERSION" "$n_os" "$n_arch"
}
# The pinned hash for a tarball name, or "" when we have none.
node_expected_sha() {
case "$1" in
*-darwin-arm64.tar.gz) printf '%s' "$NODE_SHA256_darwin_arm64" ;;
*-darwin-x64.tar.gz) printf '%s' "$NODE_SHA256_darwin_x64" ;;
*-linux-arm64.tar.gz) printf '%s' "$NODE_SHA256_linux_arm64" ;;
*-linux-x64.tar.gz) printf '%s' "$NODE_SHA256_linux_x64" ;;
esac
}
# Emits its argument as a single-quoted POSIX shell literal, safe to paste into
# generated shell text. Single quotes suppress every expansion, so the only
# character needing care is a single quote itself: close the quoted run, emit an
# escaped quote, reopen. This is the standard POSIX idiom and it has no
# dependencies, which matters in a script that runs before Node exists.
sh_quote() {
printf '%s' "'$(printf '%s' "$1" | sed "s/'/'\\\\''/g")'"
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" 2>/dev/null | cut -d" " -f1
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" 2>/dev/null | cut -d" " -f1
fi
}
# Every failure path in install_node ends here, so the half-finished directory is
# never left for a later run to mistake for a good runtime.
discard_node_staging() {
[ -n "${NODE_STAGING:-}" ] || return 0
case "$NODE_STAGING" in
# Symmetric with the prefix guard in install_node. A dot segment cannot
# satisfy the .incoming. pattern below, so this is belt and braces rather
# than a live hole -- but the two guards should not disagree about what a
# safe path looks like, because that disagreement is how the last three
# defects in this work got in.
*/hivemind/node/. | */hivemind/node/..) ;;
*/hivemind/node/*.incoming.*) rm -rf "$NODE_STAGING" 2>/dev/null || true ;;
esac
NODE_STAGING=""
}
say_node_instructions() {
say ""
say "Install Node.js ${MIN_NODE_MAJOR}+ and re-run this installer:"
say " macOS: ${BLUE}brew install node${NC}"
say " Linux: https://nodejs.org (or your distro's package manager)"
say ""
say "Or let this installer do it, without touching anything system-wide:"
say " ${BLUE}curl -fsSL https://deeplake.ai/hivemind.sh | sh -s -- install --install-node${NC}"
say ""
say "More help: ${BLUE}${HELP_URL}#troubleshooting-node${NC}"
}
# Downloads Node into the private prefix and puts it on PATH for this run.
# Returns non-zero and leaves nothing behind on any failure.
install_node() {
tarball=$(node_tarball_name)
expected=$(node_expected_sha "$tarball")
if [ -z "$tarball" ] || [ -z "$expected" ]; then
# No pinned hash for this platform. Downloading something we cannot
# verify would be worse than printing the instructions.
return 1
fi
command -v tar >/dev/null 2>&1 || return 1
prefix=$(node_prefix)
beacon node_install_started
say "${BLUE}Downloading Node ${NODE_BOOTSTRAP_VERSION} (about 55 MB) ...${NC}"
# Staged beside the prefix and moved into place only once verified, so an
# interrupted download cannot leave a partial runtime behind.
NODE_STAGING="${prefix}.incoming.$$"
( umask 077; mkdir -p "$NODE_STAGING" ) 2>/dev/null || { NODE_STAGING=""; return 1; }
if ! curl -fsSL --retry 2 -o "$NODE_STAGING/$tarball" \
"https://nodejs.org/dist/v${NODE_BOOTSTRAP_VERSION}/$tarball"; then
discard_node_staging
return 1
fi
actual=$(sha256_of "$NODE_STAGING/$tarball")
if [ -z "$actual" ] || [ "$actual" != "$expected" ]; then
# No checksum tool, or the wrong bytes. Both mean the same thing: we
# cannot say this is Node, so we do not run it.
discard_node_staging
return 1
fi
if ! tar -xzf "$NODE_STAGING/$tarball" -C "$NODE_STAGING" 2>/dev/null; then
discard_node_staging
return 1
fi
extracted="$NODE_STAGING/${tarball%.tar.gz}"
if [ ! -x "$extracted/bin/node" ]; then
discard_node_staging
return 1
fi
( umask 077; mkdir -p "$(dirname "$prefix")" ) 2>/dev/null || true
# The same shape guard discard_node_staging applies, and for the same reason:
# this is a RECURSIVE delete of a path assembled from $XDG_DATA_HOME or
# $HOME, neither of which this script chose. The guard costs one line and
# means a surprising environment cannot turn a reinstall into a recursive
# delete of somewhere else. discard_node_staging had this and its sibling
# here did not, which is the same code-right-in-one-place inconsistency that
# produced two other defects in this work.
case "$prefix" in
# A final segment of . or .. satisfies the shape below and then resolves
# UPWARD -- .../hivemind/node/.. IS .../hivemind -- so the recursive
# delete would take the parent tree. Not reachable today (the segment is
# a hardcoded version), rejected anyway: this guard's whole job is to be
# paranoid, and one that accepts .. is not.
*/hivemind/node/. | */hivemind/node/..)
discard_node_staging
return 1
;;
*/hivemind/node/?*)
if [ -d "$prefix" ]; then
rm -rf "$prefix" 2>/dev/null || true
fi
;;
*)
# Refuse rather than guess. A prefix that does not look like ours is
# not ours to delete.
discard_node_staging
return 1
;;
esac
if ! mv "$extracted" "$prefix" 2>/dev/null; then
discard_node_staging
return 1
fi
discard_node_staging
# For the rest of this run. The launcher written after a successful install
# is what makes it work in the user's NEXT terminal.
PATH="$prefix/bin:$PATH"
export PATH
NODE_WAS_BOOTSTRAPPED=1
return 0
}
# Why a launcher and not a symlink: the published `hivemind` bin begins with
# `#!/usr/bin/env node`, so a symlink into a directory on PATH finds the program
# and then fails to find its interpreter — `env node` searches the user's PATH,
# which is exactly the PATH with no Node on it. Measured, that combination gives
# "/usr/bin/env: 'node': No such file or directory". This puts the private
# runtime on PATH first, so both halves resolve.
write_launcher() {
launcher_dir="$HOME/.local/bin"
launcher="$launcher_dir/hivemind"
prefix=$(node_prefix)
[ -x "$prefix/bin/hivemind" ] || return 0
mkdir -p "$launcher_dir" 2>/dev/null || return 0
if [ -e "$launcher" ] && ! grep -q "hivemind private-runtime launcher" "$launcher" 2>/dev/null; then
say "${YELLOW}note:${NC} ${launcher} already exists and was left alone."
return 0
fi
# The prefix is derived from $HOME or $XDG_DATA_HOME, which are the user's
# to choose and can contain ANY byte a directory name can hold. Interpolating
# it into generated shell text is code injection, not a quoting nit: a home
# directory containing $(...) executed on every launch. Reproduced with
# $(printf INJECTED), which ran twice.
#
# sh_quote emits a single-quoted shell literal, which has exactly one escape
# to handle, so nothing inside it is ever expanded or executed.
q_prefix=$(sh_quote "$prefix")
# A quoted heredoc delimiter: the shell performs NO substitution inside this
# body, so the only values that reach the file are the ones printf puts there.
# An unquoted delimiter is what allowed the injection.
{
printf '%s\n' '#!/bin/sh'
printf '%s\n' '# hivemind private-runtime launcher — written by the hivemind installer.'
printf '%s\n' '# Remove this file and the runtime directory below to undo it.'
printf 'PATH=%s/bin:$PATH\n' "$q_prefix"
printf '%s\n' 'export PATH'
printf 'exec %s/bin/hivemind "$@"\n' "$q_prefix"
} > "$launcher" 2>/dev/null || return 0
chmod 755 "$launcher" 2>/dev/null || true
case ":$PATH:" in
*":$launcher_dir:"*)
say "${GREEN}hivemind${NC} is on your PATH via ${launcher}"
;;
*)
# The launcher exists either way, so this is one line of work rather
# than a reinstall.
say ""
say "${YELLOW}One more step.${NC} hivemind was installed to:"
say " ${BLUE}${launcher}${NC}"
say "which is not on your PATH, so the command will not be found yet. Add it:"
say " ${BLUE}export PATH=\"\$HOME/.local/bin:\$PATH\"${NC}"
say "(put that line in your shell profile to make it permanent)"
;;
esac
}
require_node() {
if ! command -v node >/dev/null 2>&1; then
beacon node_missing
# A terminal separates the person this installer exists for — someone who
# pasted a command and is watching — from a Dockerfile or a CI step. We
# ask the first and never surprise the second with a 55 MB download it
# did not request. --install-node is how the second one asks.
if [ "$ENV_TTY" = "1" ] || [ "$INSTALL_NODE" = "1" ]; then
want_node="$INSTALL_NODE"
if [ "$want_node" != "1" ]; then
beacon node_install_prompted
say ""
say "Hivemind needs Node.js ${MIN_NODE_MAJOR}+, which this machine does not have."
say "I can install a private copy just for hivemind — no sudo, nothing"
say "system-wide, removable by deleting one directory:"
say " ${BLUE}$(node_prefix)${NC}"
say ""
printf 'Install it now? [Y/n] '
# /dev/tty, not stdin: under `curl | sh` stdin is the remaining
# text of this script, so a bare `read` would consume it and then
# hand the shell whatever was left to execute.
node_answer=""
read node_answer < /dev/tty || node_answer=""
case "$node_answer" in
""|y|Y|yes|Yes|YES) want_node=1 ;;
*) want_node=0 ;;
esac
fi
if [ "$want_node" = "1" ]; then
if install_node; then
# Pin npm's global prefix to the runtime we just downloaded.
NPM_PREFIX_ARGS="--prefix=$(node_prefix)"
beacon node_install_ok
say "${GREEN}Node ${NODE_BOOTSTRAP_VERSION} installed.${NC}"
else
beacon node_install_failed
err "Could not install Node.js automatically."
say_node_instructions
exit 1
fi
else
beacon node_install_declined
say_node_instructions
exit 1
fi
else
err "Node.js is required but was not found."
say_node_instructions
exit 1
fi
fi
if ! command -v npm >/dev/null 2>&1; then
beacon node_missing
err "npm is required but was not found."
say ""
# Debian and Ubuntu ship npm as its own package, so "it comes with
# Node.js" sends exactly the users who hit this down the wrong path.
say "On Debian/Ubuntu npm is a separate package:"
say " ${BLUE}sudo apt install npm${NC}"
say "Elsewhere it ships with Node.js — reinstall Node from https://nodejs.org"
exit 1
fi
# `node -v` prints e.g. v20.20.2; take the major.
node_major=$(node -v 2>/dev/null | sed 's/^v//' | cut -d. -f1)
case "$node_major" in
''|*[!0-9]*) return 0 ;; # unparseable — do not guess, let npm decide
esac
if [ "$node_major" -lt "$MIN_NODE_MAJOR" ]; then
beacon node_too_old
say "${YELLOW}note:${NC} ${PKG} declares Node ${MIN_NODE_MAJOR}+, you are on $(node -v)."
say " Not a blocker on its own — the installer carries on below and"
say " npm normally installs it anyway. Upgrade Node if the CLI then"
say " misbehaves: ${BLUE}${HELP_URL}#troubleshooting-node${NC}"
fi
}
# npm prints its failure reason as `npm error code EACCES` on a line of its own.
# That line carries no path, unlike the `npm error path ...` and `npm error
# Error: ...` lines around it, so it is the one piece of npm's output that can be
# reported without leaking a username or a registry URL. Only the bare code is
# kept, and only if it looks like one.
npm_error_code() {
code=$(sed -n 's/^npm \(error\|ERR!\) code \([A-Z0-9]\{2,20\}\)$/\2/p' "$1" 2>/dev/null | head -1)
case "$code" in
E*) printf '%s' "$code" ;;
esac
}
# npm refuses a global install outright when its own config turns workspaces on
# — `workspaces=true` or `workspace=<name>` in ~/.npmrc, or an npm_config_*
# variable in the environment. It reports that with a bare English sentence and
# no `npm error code` line, so npm_error_code finds nothing and the failure
# lands in the permissions advice below, which sends the user to a Node version
# manager that cannot fix an npm setting. This is the one npm failure sudo
# makes no difference to, so it gets its own branch.
# The second message is matched without its opening words on purpose: npm 10.9.8
# prints "Can not use --no-workspaces ...", npm 11.11.0 prints "Cannot use ...",
# and a match on either spelling alone silently misses half the installs.
npm_workspaces_error() {
grep -q "Workspaces not supported for global packages" "$1" 2>/dev/null ||
grep -q "no-workspaces and --workspace at the same time" "$1" 2>/dev/null
}
# The two refusals need different remediation, so they are counted apart.
# `workspaces=true` is what --workspaces=false already neutralises, and anything
# still reaching us with that message has it set somewhere the flag cannot reach.
# `workspace=<name>` is the case npm rejects ALONGSIDE the flag, and the only
# thing that clears it is deleting the setting.
npm_workspaces_named_error() {
grep -q "no-workspaces and --workspace at the same time" "$1" 2>/dev/null
}
# Every remediation here is a file edit rather than an `npm config` command:
# with `workspaces=true` set, `npm config` itself refuses to run ("This command
# does not support workspaces"), and with `workspace=<name>` set the flag that
# would clear that refusal is rejected as a conflict. Editing the file is the
# one instruction that works for both.
# One line and a link, not a lecture. The full remediation — every npmrc
# location, the fact that `npm config` cannot remove the setting while it is set,
# and the npm_config_* environment case — lives on the help page, which can be
# corrected without redeploying a script people have already piped into a shell.
say_workspaces_fix() {
err "npm is configured to use workspaces, and npm cannot install a global package that way."
say "This is a setting in your npm config, not a problem with ${PKG}."
say ""
# The remediation is a FILE EDIT, never an `npm config` command. With this
# setting in place npm config itself refuses to run -- "This command does not
# support workspaces" -- so `npm config delete workspace` fails with
# ENOWORKSPACES and leaves the user exactly where they started. An earlier
# revision of this branch printed that command; the comment above
# say_workspaces_fix on origin/main had already recorded why it cannot work,
# and collapsing this message deleted the warning along with the guidance.
if [ "$1" = "named" ]; then
say "Your npm config names a workspace. Delete that line and re-run this"
say "installer -- ${BLUE}npm config${NC} cannot remove it while it is set:"
else
say "Find the ${BLUE}workspaces${NC} setting in your npm config, delete that line,"
say "then re-run. ${BLUE}npm config${NC} cannot remove it while it is set:"
fi
say " ${BLUE}~/.npmrc${NC} your own config, the usual one"
say " ${BLUE}./.npmrc${NC} in the directory you ran this from"
say " ${BLUE}etc/npmrc${NC} next to your Node install, machine-wide"
say "If it comes from the environment instead, unset ${BLUE}npm_config_workspaces${NC}"
say "and ${BLUE}npm_config_workspace${NC} and re-run."
say ""
say "Where it lives and how to clear it in every case:"
say " ${BLUE}${HELP_URL}#troubleshooting-npm-workspaces${NC}"
}
# Where one `npm install -g` attempt's output is left for the failure branches
# to read. Empty when no log file could be created at all.
NPM_ATTEMPT_LOG=""
# mktemp is missing on some minimal images, and without a log the workspaces
# refusal cannot be told apart from a permissions failure — which is the wrong
# advice this change exists to stop printing. So fall back to a path this
# script already owns rather than to no log. /tmp is deliberately not that
# fallback: a predictable name in a world-writable directory is a file anyone
# on the machine can pre-create as a symlink.
# npm_log_path <role> — a file this script can safely write, or "" if none.
npm_log_path() {
path=$(mktemp 2>/dev/null || echo "")
if [ -n "$path" ]; then
printf '%s' "$path"
return 0
fi
(umask 077; mkdir -p "$HOME/.deeplake") 2>/dev/null || return 0
# $1 names the role, because this is called twice per attempt and two calls
# in the same second would otherwise ask for the same name — which noclobber
# then refuses, losing the capture on exactly the machines this fallback
# exists for. A counter cannot do it: the callers are command substitutions,
# so each runs in a subshell and any increment dies with it.
path="$HOME/.deeplake/npm-install-$$-$(date +%s 2>/dev/null || echo 0)-${1:-out}.log"
# noclobber, so this creates the file or fails: it will not write through an
# existing file, or through a symlink someone left in its place. Set inside
# a subshell so the rest of the script keeps its own redirection rules.
( set -C; : > "$path" ) 2>/dev/null || return 0
printf '%s' "$path"
}
# npm_install_attempt <prefix> [prefix args...] — runs one global install with
# the given command prefix (`command` for the plain run, `sudo` for the retry),
# shows npm's output live, leaves it in NPM_ATTEMPT_LOG, and returns npm's own
# status.
#
# Both attempts go through here so both are readable afterwards. The sudo retry
# used to run bare, which left the elevated failure unexplained: a workspaces
# setting in root's npmrc reads as an ordinary permissions failure and gets the
# nvm advice that cannot fix it.
#
# Under `curl | sh` stdin is the remaining text of this script. npm, or any
# lifecycle script it runs, that reads stdin would consume it and the shell
# would then execute whatever was left. </dev/null closes that hole. sudo is
# unaffected: its password prompt reads /dev/tty, not stdin.
#
# --workspaces=false is what makes the install survive an npm config with
# workspaces turned on. It is a command-line flag rather than an exported
# npm_config_workspaces because sudo resets the environment, so a variable
# would be dropped on exactly the retry that needs it most.
npm_install_attempt() {
NPM_ATTEMPT_LOG=$(npm_log_path out)
attempt_code=0
# The status file is created the same guarded way as the log, not as
# "$NPM_ATTEMPT_LOG.rc": a name derived from one this script just printed is
# predictable, and `echo $? > name` follows a symlink left in its place,
# which would let a local user pick a file for this script — or for root, on
# the sudo retry — to overwrite.
attempt_rc=""
if [ -n "$NPM_ATTEMPT_LOG" ]; then
attempt_rc=$(npm_log_path rc)
fi
if [ -n "$attempt_rc" ]; then
# npm's output is teed rather than swallowed: the user still sees the
# install progress live, which can take minutes when the package
# compiles native bindings, and a blank screen for that long reads as a
# hang.
#
# A pipeline reports only its LAST command, so `npm ... | tee` would
# hand back tee's status and every npm failure would look like a
# success. POSIX sh has no PIPESTATUS; the status goes through a file.
#
# `set +e` inside the group, or `set -e` ends it the moment npm fails
# and the status line never runs — leaving the real exit code lost. The
# group is the left side of a pipe, so it is a subshell and this does
# not leak to the rest of the script.
{ set +e; "$@" npm install -g --workspaces=false ${NPM_PREFIX_ARGS:+"$NPM_PREFIX_ARGS"} "$PKG" </dev/null 2>&1; echo $? > "$attempt_rc"; } | tee "$NPM_ATTEMPT_LOG"
attempt_code=$(cat "$attempt_rc" 2>/dev/null || echo 1)
rm -f "$attempt_rc" 2>/dev/null || true
else
# No usable pair of files: run without capture. Nothing can be read back
# afterwards, so leave no half-written log claiming otherwise.
discard_npm_log
# `|| attempt_code=$?` is what keeps `set -e` from ending the script
# here on a bare failing command, which is the whole failure this
# instrumentation exists to make visible.
"$@" npm install -g --workspaces=false ${NPM_PREFIX_ARGS:+"$NPM_PREFIX_ARGS"} "$PKG" </dev/null || attempt_code=$?
fi
case "$attempt_code" in
''|*[!0-9]*) attempt_code=1 ;;
esac
return "$attempt_code"
}
# GNU and busybox `rm -f ""` exit zero, but that is not guaranteed elsewhere —
# and a non-zero status here ends the whole script under `set -e`, on the
# success path, on the machines that could not create a log in the first place.
discard_npm_log() {
[ -n "$NPM_ATTEMPT_LOG" ] || return 0
# Cleanup is best-effort: a log this script cannot delete is not a reason to
# end an install that worked.
rm -f "$NPM_ATTEMPT_LOG" 2>/dev/null || true
NPM_ATTEMPT_LOG=""
}
# Set once any attempt has been refused over workspaces. It decides which advice
# the final failure prints, and nothing else: an attempt refused this way is
# still allowed to fall through to the sudo retry, because root has its own
# config and the setting that stopped the user's run may simply not be in it.
NPM_WORKSPACES_REFUSED=0
# "named" once any attempt was refused over a config that NAMES a workspace,
# which is the case --workspaces=false cannot override. Decides the beacon step
# and which of the two remediations is printed.
NPM_WORKSPACES_KIND=""
note_workspaces_refusal() {
[ -n "$NPM_ATTEMPT_LOG" ] || return 0
npm_workspaces_error "$NPM_ATTEMPT_LOG" || return 0
NPM_WORKSPACES_REFUSED=1
if npm_workspaces_named_error "$NPM_ATTEMPT_LOG"; then
NPM_WORKSPACES_KIND="named"
fi
}
# The beacon step for whichever refusal we saw. Two steps rather than one because
# the remediation differs, and because the named case is the one whose count
# decides whether the filtered-npmrc option — validated and deliberately not
# built — ever needs reopening. It has fired zero times since 2026-09-03.
workspaces_step() {
if [ "$NPM_WORKSPACES_KIND" = "named" ]; then
printf 'npm_workspaces_named'
else
printf 'npm_workspaces_config'
fi
}
install_pkg() {
say "${BLUE}Installing ${PKG} ...${NC}"
# `$?` after a bare `fi` is the status of the whole if-statement, which is
# zero when the condition failed and there is no else — so reading it there
# reports every npm failure as "code 0". `|| npm_code=$?` is where the real
# exit status survives.
npm_code=0
npm_install_attempt command || npm_code=$?
if [ "$npm_code" -eq 0 ]; then
discard_npm_log
beacon npm_ok
return 0
fi
if [ -n "$NPM_ATTEMPT_LOG" ]; then
NPM_CODE=$(npm_error_code "$NPM_ATTEMPT_LOG")
fi
# --workspaces=false already neutralises the common case (`workspaces=true`).
# What still reaches here is a config that names a workspace
# (`workspace=a`), which npm rejects alongside the flag.
note_workspaces_refusal
discard_npm_log
# Global installs often fail with EACCES when the npm prefix is root-owned.
if command -v sudo >/dev/null 2>&1; then
beacon npm_sudo_retry "$npm_code"
say "${YELLOW}Global install failed — retrying with sudo ...${NC}"
# Without `|| { ... }` this is where `set -e` used to end the script:
# the second failure exited with no beacon and no message at all, which
# is precisely the silent disappearance this instrumentation is for.
# With no terminal — `curl | sh` from a cron job, a Dockerfile, a
# remote shell — a password prompt has nowhere to appear and the
# install hangs there indefinitely, which is indistinguishable from a
# user who walked away. -n makes it fail immediately instead, so the
# beacon below actually fires.
sudo_code=0
if [ "$ENV_TTY" = "1" ]; then
npm_install_attempt sudo || sudo_code=$?
else
npm_install_attempt sudo -n || sudo_code=$?
fi
if [ "$sudo_code" -ne 0 ]; then
# root has its own npmrc, so the elevated run can hit the workspaces
# refusal even when the user's run did not.
note_workspaces_refusal
discard_npm_log
if [ "$NPM_WORKSPACES_REFUSED" -eq 1 ]; then
beacon "$(workspaces_step)" "$sudo_code"
say_workspaces_fix "$NPM_WORKSPACES_KIND"
exit 1
fi
beacon npm_failed "$sudo_code"
err "Failed to install ${PKG} even with sudo."
say "Try a Node version manager instead, which needs no elevation:"
say " ${BLUE}https://github.com/nvm-sh/nvm${NC}"
say ""
say "More help: ${BLUE}${HELP_URL}#troubleshooting-npm-permissions${NC}"
exit 1
fi
discard_npm_log
else
if [ "$NPM_WORKSPACES_REFUSED" -eq 1 ]; then
beacon "$(workspaces_step)" "$npm_code"
say_workspaces_fix "$NPM_WORKSPACES_KIND"
exit 1
fi
beacon npm_failed "$npm_code"
err "Failed to install ${PKG}."
say "You may need elevated permissions:"
say " ${BLUE}sudo npm install -g ${PKG}${NC}"
say ""
say "More help: ${BLUE}${HELP_URL}#troubleshooting-npm-permissions${NC}"
exit 1
fi
}
run_hivemind() {
if ! command -v hivemind >/dev/null 2>&1; then
beacon path_missing
err "hivemind was installed but is not on your PATH."
say "Open a new terminal (or add your npm global bin dir to PATH), then run:"
say " ${BLUE}hivemind $*${NC}"
say ""
say "More help: ${BLUE}${HELP_URL}#troubleshooting-path${NC}"
exit 1
fi
beacon cli_launched
say "${BLUE}Running: hivemind $*${NC}"
# Give the CLI a real terminal so the SSO prompt works under `curl | sh`.
# ENV_TTY is the open-it-for-real check, not `[ -e /dev/tty ]`: in a headless
# container the node exists but the open fails, and under `set -e` that ended
# the install before the CLI ever ran.
#
# The CLI's own exit status is deliberately left to propagate: the script's
# last act is to hand over to it, and swallowing a real failure here would
# report success for an install that did not finish.
# The CLI's exit status is captured rather than left to `set -e`, so a crash
# produces a beacon instead of the script vanishing. It is still propagated
# at the end: the script's last act is to hand over, and reporting success
# for an install that did not finish would be a lie.
#
# Without cli_exited, "cli_launched and then no signup_intent" collapses
# three different stories into one: the CLI crashed, it ran headless and
# skipped auth by design, or the user declined the sign-in prompt. The tty
# field separates the second; this separates the first.
cli_code=0
if [ "$ENV_TTY" = "1" ]; then
hivemind "$@" < /dev/tty || cli_code=$?
else
hivemind "$@" || cli_code=$?
fi
beacon cli_exited "$cli_code"
if [ "$cli_code" -ne 0 ]; then
exit "$cli_code"
fi
}
main() {
# Opting out means opting out of the file too: someone who sets this and
# then finds a freshly minted UUID on their disk has been told one thing and
# given another. The CLI still writes its own on first login, which is its
# decision to explain, not this script's.
if [ -n "$HIVEMIND_NO_TELEMETRY" ]; then
INSTALL_ID=""
else
INSTALL_ID=$(resolve_install_id)
fi
collect_env
# The affiliate code is forwarded to the CLI as-is; it is read here only so
# the shell-phase events carry the same campaign attribution the device
# flow parks, instead of two breakdowns that have to be unioned.
REF_CODE=""
prev_arg=""
for arg in "$@"; do
case "$prev_arg" in --ref) REF_CODE="$arg" ;; esac
case "$arg" in --ref=*) REF_CODE="${arg#--ref=}" ;; esac
prev_arg="$arg"
done
# A campaign code is a short slug. Anything else here is a mistake or a path
# someone pasted, and it would be stored verbatim next to a person record —
# so drop it rather than forward it. The CLI still receives the original
# argument untouched; only the telemetry copy is filtered.
case "$REF_CODE" in
"") ;;
*[!A-Za-z0-9._-]*) REF_CODE="" ;;
????????????????????????????????????????????????????????????????*) REF_CODE="" ;;
esac
# The installer's own flags are REMOVED from what the CLI is handed, and this
# is the only place that can do it: POSIX sh has no way for a function to
# rewrite its caller's "$@", so the rebuild is inline.
#
# `for arg in "$@"` expands the list once, up front, so shifting and
# re-appending inside the loop is safe. Every kept argument is re-appended
# with `set -- "$@" "$arg"` — never reconstructed into a string and never
# passed through eval — so boundaries, order, empty strings, embedded
# whitespace and literal glob characters all survive exactly.
#
# Why the flag must not simply be forwarded: the script hands its remaining
# arguments to the CLI, and the CLI reads argv[0] as the command name. A
# stray `--install-node` would arrive as an unknown command and exit 1 AFTER
# npm had already installed successfully — the funnel would read a working
# install as a CLI crash. Stripping also restores the zero-argument case
# below, so `sh -s -- --install-node` still ends up running `hivemind install`.
INSTALL_NODE=0
past_ddash=0
for arg in "$@"; do
shift
if [ "$past_ddash" -eq 1 ]; then
set -- "$@" "$arg"
continue
fi
case "$arg" in
# Everything after a literal `--` belongs to the CLI verbatim.
--) past_ddash=1; set -- "$@" "$arg"; continue ;;
--install-node) INSTALL_NODE=1; continue ;;
esac
set -- "$@" "$arg"
done
beacon started
require_node
install_pkg
# Only meaningful when this run brought its own Node: the package landed in
# the private prefix, which is on PATH for this process and nowhere else.
# Without this the install would look like it worked and then `hivemind`
# would not exist in the user's next terminal.
if [ "$NODE_WAS_BOOTSTRAPPED" = "1" ]; then
write_launcher
fi
# Default to a full install across all detected assistants. Any args after
# `sh -s --` (e.g. `claude install`, `install --ref <code>`) are forwarded
# verbatim to the CLI.
if [ "$#" -eq 0 ]; then
set -- install
fi
run_hivemind "$@"
say ""
say "${GREEN}Hivemind is ready.${NC} Restart your assistants and they all share the same brain."
}
main "$@"
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment