diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 238ccaa..ced690c 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -91,3 +91,77 @@ jobs:
set -euo pipefail
test "$(PATH=/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin extrabrain --version)" = "$(node -p 'require("./package.json").version')"
test "$(stat -f '%Su:%Sg:%Lp' /usr/local/bin/extrabrain)" = root:wheel:755
+
+ validate-windows:
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - runner: windows-2025
+ arch: x64
+ - runner: windows-11-arm
+ arch: arm64
+ runs-on: ${{ matrix.runner }}
+ steps:
+ - uses: actions/checkout@v7.0.1
+ - uses: actions/setup-node@v7.0.0
+ with:
+ node-version: 24.20.0
+ cache: npm
+ - run: npm ci
+ - run: npm run check
+ - run: npm run build:sea
+ - name: Smoke test Windows executable
+ shell: pwsh
+ run: |
+ $PSNativeCommandUseErrorActionPreference = $false
+ $version = node -p 'require("./package.json").version'
+ $actualVersion = & dist/extrabrain.exe --version
+ if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $version) { throw "Version mismatch: $actualVersion" }
+ $output = & dist/extrabrain.exe --json capabilities
+ $exitCode = $LASTEXITCODE
+ $result = $output | ConvertFrom-Json
+ if ($exitCode -ne 3 -or $result.code -ne 3) { throw "Capabilities contract failed: $output" }
+ exit 0
+ - name: Install Windows command for the current user
+ shell: pwsh
+ env:
+ ARCH: ${{ matrix.arch }}
+ run: |
+ $version = node -p 'require("./package.json").version'
+ $root = Join-Path $env:RUNNER_TEMP 'installer-releases'
+ $release = Join-Path $root "download\v$version"
+ New-Item -ItemType Directory -Force -Path $release | Out-Null
+ $asset = "extrabrain-v$version-win32-$env:ARCH.zip"
+ Compress-Archive -LiteralPath dist/extrabrain.exe -DestinationPath (Join-Path $release $asset)
+ $hash = (Get-FileHash -Algorithm SHA256 (Join-Path $release $asset)).Hash.ToLowerInvariant()
+ Set-Content -NoNewline -Path (Join-Path $release 'SHA256SUMS') -Value "$hash $asset`n"
+ $server = Join-Path $env:RUNNER_TEMP 'release-server.cjs'
+ Set-Content -Path $server -Value @'
+ const { createReadStream, existsSync } = require('node:fs')
+ const { join } = require('node:path')
+ const root = process.argv[2]
+ require('node:http').createServer((request, response) => {
+ const path = join(root, decodeURIComponent(new URL(request.url, 'http://x').pathname))
+ if (!path.startsWith(root) || !existsSync(path)) return response.writeHead(404).end()
+ createReadStream(path).pipe(response)
+ }).listen(38123, '127.0.0.1')
+ '@
+ Start-Process -FilePath node -ArgumentList @($server, $root) -WindowStyle Hidden
+ Start-Sleep -Seconds 2
+ $env:EXTRABRAIN_RELEASE_BASE = 'http://127.0.0.1:38123'
+ $env:EXTRABRAIN_VERSION = "v$version"
+ powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -File install.ps1
+ if ($LASTEXITCODE -ne 0) { throw 'Installer failed' }
+ - name: Verify user installation in a new shell
+ shell: pwsh
+ run: |
+ $PSNativeCommandUseErrorActionPreference = $false
+ $version = node -p 'require("./package.json").version'
+ $installDir = Join-Path $env:LOCALAPPDATA 'ExtraBrain\bin'
+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
+ if (@($userPath -split ';') -notcontains $installDir) { throw "User PATH does not contain $installDir" }
+ $machinePath = [Environment]::GetEnvironmentVariable('Path', 'Machine')
+ $actual = powershell.exe -NoProfile -NonInteractive -Command "`$env:Path = '$machinePath;$userPath'; extrabrain --version"
+ if ($LASTEXITCODE -ne 0 -or $actual -ne $version) { throw "Installed version mismatch: $actual" }
+ exit 0
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index ba01298..43bf981 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -62,6 +62,10 @@ jobs:
target: darwin-arm64
- runner: macos-15-intel
target: darwin-x64
+ - runner: windows-2025
+ target: win32-x64
+ - runner: windows-11-arm
+ target: win32-arm64
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v7.0.1
@@ -72,6 +76,7 @@ jobs:
node-version: 24.20.0
cache: npm
- name: Set release version
+ shell: bash
env:
RELEASE_VERSION: ${{ needs.prepare.outputs.version }}
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
@@ -85,6 +90,7 @@ jobs:
- name: Build executable
run: npm run build:sea
- name: Verify and archive macOS executable
+ if: startsWith(matrix.target, 'darwin-')
shell: bash
env:
RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
@@ -99,6 +105,22 @@ jobs:
test "$code" = 3
echo "$result" | jq -e '.code == 3 and .message == "ExtraBrain is not running"'
tar -czf "extrabrain-$RELEASE_TAG-$TARGET.tar.gz" -C dist extrabrain
+ - name: Verify and archive Windows executable
+ if: startsWith(matrix.target, 'win32-')
+ shell: pwsh
+ env:
+ RELEASE_TAG: ${{ needs.prepare.outputs.tag }}
+ TARGET: ${{ matrix.target }}
+ run: |
+ $PSNativeCommandUseErrorActionPreference = $false
+ $actualVersion = & dist/extrabrain.exe --version
+ if ($LASTEXITCODE -ne 0 -or $actualVersion -ne $env:RELEASE_TAG.Substring(1)) { throw "Version mismatch: $actualVersion" }
+ $output = & dist/extrabrain.exe --json capabilities
+ $exitCode = $LASTEXITCODE
+ $result = $output | ConvertFrom-Json
+ if ($exitCode -ne 3 -or $result.code -ne 3 -or $result.message -ne 'ExtraBrain is not running') { throw "Capabilities contract failed: $output" }
+ Compress-Archive -LiteralPath dist/extrabrain.exe -DestinationPath "extrabrain-$env:RELEASE_TAG-$env:TARGET.zip"
+ exit 0
- uses: actions/upload-artifact@v7.0.1
with:
name: ${{ matrix.target }}
@@ -147,4 +169,4 @@ jobs:
set -euo pipefail
cd release-assets
sha256sum extrabrain-* > SHA256SUMS
- gh release create "$RELEASE_TAG" extrabrain-* SHA256SUMS --title "$RELEASE_TAG" --notes "Unsigned macOS CLI release. This executable is not Developer ID signed or notarized." --verify-tag
+ gh release create "$RELEASE_TAG" extrabrain-* SHA256SUMS --title "$RELEASE_TAG" --notes "Unsigned CLI release for macOS and Windows. The macOS executables are not Developer ID signed or notarized, and the Windows executables are not Authenticode signed." --verify-tag
diff --git a/README.md b/README.md
index 504ab1b..f26227b 100644
--- a/README.md
+++ b/README.md
@@ -1,19 +1,21 @@
# ExtraBrain CLI
-The ExtraBrain CLI is a small add-on for the [ExtraBrain](https://extrabrain.app) Mac app. It lets you work with your ExtraBrain library by typing short commands in the Terminal instead of clicking through the app. It is also handy for AI assistants and scripts that need to read from or add to your library.
+The ExtraBrain CLI is a small add-on for the [ExtraBrain](https://extrabrain.app) app on Mac and Windows. It lets you work with your ExtraBrain library by typing short commands in Terminal (Mac) or PowerShell (Windows) instead of clicking through the app. It is also handy for AI assistants and scripts that need to read from or add to your library.
To learn more about ExtraBrain itself, or to download the app, visit **[extrabrain.app](https://extrabrain.app)**.
-> **Good to know:** The CLI is a companion to the app, not a replacement. **ExtraBrain must be open and running** on your Mac whenever you use it. Everything stays on your computer; nothing is sent over the internet.
+> **Good to know:** The CLI is a companion to the app, not a replacement. **ExtraBrain must be open and running** on your computer whenever you use it. Everything stays on your computer; nothing is sent over the internet.
## What you need
-- A Mac (Apple Silicon or Intel).
+- A Mac (Apple Silicon or Intel), or a Windows 10 or 11 PC (Intel/AMD or ARM).
- The ExtraBrain app, installed and open. Get it at [extrabrain.app](https://extrabrain.app).
- About two minutes. You do not need to install anything else (no Node, no Python), and no administrator password.
## Install
+### Mac
+
1. Open the **Terminal** app. (Press `⌘ Space`, type "Terminal", and press Return.)
2. Copy the line below, paste it into Terminal, and press Return:
@@ -28,32 +30,72 @@ curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master
extrabrain --version
```
-If you see a version number such as `0.1.7`, you are done.
+If you see a version number such as `0.1.8`, you are done.
**If macOS blocks the program the first time:** this release is not yet signed by Apple, so macOS may ask for extra approval. Open **System Settings → Privacy & Security**, scroll down, and choose **Open Anyway** next to the message about `extrabrain`.
**If Terminal says "command not found":** close every Terminal window and open a new one. The installer adds the command to your shell setup, and that only takes effect in new windows.
+### Windows
+
+1. Open **PowerShell**. (Press the Windows key, type "PowerShell", and press Enter.)
+2. Copy the line below, paste it into PowerShell, and press Enter:
+
+```powershell
+irm https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.ps1 | iex
+```
+
+3. When it finishes, **close PowerShell and open a new window**.
+4. Check that it worked:
+
+```powershell
+extrabrain --version
+```
+
+If you see a version number such as `0.1.8`, you are done.
+
+The installer puts `extrabrain.exe` in `%LOCALAPPDATA%\ExtraBrain\bin` and adds that folder to your user `PATH`. It works for both Intel/AMD and ARM PCs and picks the right version automatically.
+
+**If Windows shows "Windows protected your PC" or Microsoft Defender warns about the program:** this release is not yet signed by Microsoft, so Windows may ask for extra approval. Choose **More info → Run anyway**.
+
+**If PowerShell says "'extrabrain' is not recognized":** close every PowerShell and Command Prompt window and open a new one. The installer adds the command to your `PATH`, and that only takes effect in new windows.
+
### Update
```sh
extrabrain update
```
-Check the new version afterwards with `extrabrain --version`. Updating never happens automatically.
+Check the new version afterwards with `extrabrain --version`. On Windows the update finishes just after the command exits, so check the version with a new command. Updating never happens automatically.
### Uninstall
+On a Mac:
+
```sh
rm ~/.local/bin/extrabrain
```
+On Windows, in PowerShell:
+
+```powershell
+Remove-Item "$env:LOCALAPPDATA\ExtraBrain\bin\extrabrain.exe"
+```
+
+To also remove the folder from your `PATH` on Windows:
+
+```powershell
+$dir = "$env:LOCALAPPDATA\ExtraBrain\bin"
+$path = ([Environment]::GetEnvironmentVariable('Path', 'User') -split ';' | Where-Object { $_ -and $_ -ne $dir }) -join ';'
+[Environment]::SetEnvironmentVariable('Path', $path, 'User')
+```
+
This removes only the command. Your ExtraBrain app, documents, and settings are not touched.
## Try it
1. Open the ExtraBrain app.
-2. In Terminal, run:
+2. In Terminal (Mac) or PowerShell (Windows), run:
```sh
extrabrain capabilities
@@ -63,7 +105,7 @@ If it lists what your app supports, everything is connected. If it says the app
## What the CLI can do
-Think of these as its skills. Each one is a group of commands. Add `--json` right after `extrabrain` (for example `extrabrain --json documents list`) when a script or AI assistant needs machine-readable results.
+Think of these as its skills. Each one is a group of commands, and they work the same on Mac and Windows. Add `--json` right after `extrabrain` (for example `extrabrain --json documents list`) when a script or AI assistant needs machine-readable results.
### 1. Documents: add files to your library
@@ -164,23 +206,38 @@ Exports go to a **new** location and never overwrite existing files. They contai
| --- | --- |
| "App not running" (exit code `3`) | Open the ExtraBrain app, then try again. |
| "Unsupported API version or capability" (exit code `8`) | Your app is too old, or that feature is turned off. Update ExtraBrain from [extrabrain.app](https://extrabrain.app). |
-| `command not found` | Close Terminal and open a new window. |
-| Wrong port | The CLI talks to the app on port `37373`. If your app uses another port, set `EXTRABRAIN_PORT` to that number. |
+| `command not found` (Mac) | Close Terminal and open a new window. |
+| `'extrabrain' is not recognized` (Windows) | Close PowerShell and open a new window. |
+| "Windows protected your PC" | Choose **More info → Run anyway**. The program is not yet signed by Microsoft. |
+| Wrong port | The CLI talks to the app on port `37373`. If your app uses another port, set `EXTRABRAIN_PORT` to that number (`export EXTRABRAIN_PORT=37374` on a Mac, `$env:EXTRABRAIN_PORT = 37374` in PowerShell). |
## Advanced
Installer options
+On a Mac:
+
- **Install location:** by default the installer puts `extrabrain` in `~/.local/bin` and adds that folder to your shell setup (zsh and bash). Set `EXTRABRAIN_NO_MODIFY_PATH=1` to skip changing shell files, or `EXTRABRAIN_INSTALL_DIR=` to choose another folder.
- **All users on the Mac:** run the installer with `EXTRABRAIN_INSTALL_DIR=/usr/local/bin`. It asks for an administrator password only when it needs one. Uninstall with `sudo rm /usr/local/bin/extrabrain`.
- **A specific version:** set `EXTRABRAIN_VERSION=v0.1.1` (any release tag).
-- **Safety:** downloads are checked against the release's `SHA256SUMS` before anything is replaced.
```sh
curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.sh | env EXTRABRAIN_INSTALL_DIR=/usr/local/bin bash
```
+On Windows:
+
+- **Install location:** by default the installer puts `extrabrain.exe` in `%LOCALAPPDATA%\ExtraBrain\bin` and adds that folder to your user `PATH`. Set `$env:EXTRABRAIN_INSTALL_DIR` to choose another folder; the installer then leaves `PATH` unchanged.
+- **A specific version:** set `$env:EXTRABRAIN_VERSION` to any release tag that includes Windows builds, as in the example below (replace `vX.Y.Z`). The setting lasts only for that PowerShell window, so open a new window afterwards.
+- **Requirements:** Windows PowerShell 5.1 (built into Windows 10 and 11) or PowerShell 7.
+
+```powershell
+$env:EXTRABRAIN_VERSION = 'vX.Y.Z'; irm https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master/install.ps1 | iex
+```
+
+On both, downloads are checked against the release's `SHA256SUMS` before anything is replaced.
+
@@ -189,6 +246,8 @@ curl -fsSL https://raw.githubusercontent.com/ExtraBrainApp/ExtraBrain-cli/master
Use `--json` to get `{ "code": number, "data": object | null, "message": string }`. Exit codes: `0` success, `1` failure, `2` usage, `3` app not running, `4` authentication or revocation, `5` conflict, `6` partial batch, `7` protected storage unavailable, `8` unsupported API or capability.
- The CLI talks to the app over a local-only connection (`127.0.0.1`) and needs no password or token. It never starts the app or reads its database.
+- `extrabrain pair` keeps its credential in the macOS Keychain on a Mac, or in a file protected with Windows DPAPI under `%LOCALAPPDATA%\ExtraBrain` on Windows. There is no plaintext fallback.
+- Interrupted imports are remembered in `~/.local/state/extrabrain/imports` on a Mac and `%LOCALAPPDATA%\ExtraBrain\state\imports` on Windows, so `documents resume` can continue them.
- Put file paths that begin with `-` after `--`. Symlinks and non-regular files are rejected. The CLI reads files from its own filesystem, so a sandboxed or remote agent cannot import a desktop-only path.
- Deleting requires one document ID and its current revision. On a revision conflict, read the document again and confirm with the user instead of retrying.
- List commands accept `--limit` and `--cursor`; time filters (`--since`, `--until`) are Unix seconds. Follow `data.nextCursor` to page through results.
@@ -199,9 +258,14 @@ Use `--json` to get `{ "code": number, "data": object | null, "message": string
Building from source
-Use Node 24.20.0 and npm 11.19.0. `npm ci`, `npm run typecheck`, and `npm test` validate the CLI; `npm run build:sea` creates a self-contained executable in `dist/`.
+Use Node 24.20.0 and npm 11.19.0. `npm ci`, `npm run typecheck`, and `npm test` validate the CLI; `npm run build:sea` creates a self-contained executable for your computer in `dist/`.
+
+To build for another platform, pass one or more targets: `npm run build:sea -- --target win32-x64 --target win32-arm64` (also `darwin-arm64` and `darwin-x64`). Each lands in `dist//`. The build downloads the matching official Node.js binary and checks it against the Node.js release's `SHA256SUMS`.
+
+- macOS executables must be built on a Mac (they are signed ad hoc with `codesign`).
+- Windows executables can be built anywhere. On Windows the build uses `signtool` from the Windows SDK when it finds it (or `EXTRABRAIN_SIGNTOOL_PATH`); elsewhere it needs `osslsigncode`.
-To release, run the `Standalone CLI release` workflow on `master` and choose `patch`, `minor`, or `major`.
+To release, run the `Standalone CLI release` workflow on `master` and choose `patch`, `minor`, or `major`. It builds and tests each executable natively on GitHub-hosted macOS (Apple Silicon and Intel) and Windows (x64 and ARM64) runners.
diff --git a/install.ps1 b/install.ps1
index 4d7b4f7..3e136ed 100644
--- a/install.ps1
+++ b/install.ps1
@@ -17,6 +17,7 @@ $releaseBase = if ($env:EXTRABRAIN_RELEASE_BASE) { $env:EXTRABRAIN_RELEASE_BASE
$installDir = if ($env:EXTRABRAIN_INSTALL_DIR) { $env:EXTRABRAIN_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA 'ExtraBrain\bin' }
$arch = switch ([Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString()) {
'X64' { 'x64' }
+ 'Arm64' { 'arm64' }
default { throw 'Unsupported architecture' }
}
if ($env:EXTRABRAIN_VERSION) {
@@ -33,11 +34,11 @@ $temporary = Join-Path ([IO.Path]::GetTempPath()) ("extrabrain-install-" + [guid
try {
$manifest = Join-Path $temporary 'SHA256SUMS'
$archive = Join-Path $temporary $asset
- Invoke-WebRequest "$releaseBase/download/$version/SHA256SUMS" -OutFile $manifest
- Invoke-WebRequest "$releaseBase/download/$version/$asset" -OutFile $archive
- $matches = @(Get-Content $manifest | Where-Object { $_ -match ('^[a-fA-F0-9]{64} ' + [regex]::Escape($asset) + '$') })
- if ($matches.Count -ne 1) { throw 'Release checksum is missing or invalid' }
- $expected = $matches[0].Substring(0, 64).ToLowerInvariant()
+ Invoke-WebRequest "$releaseBase/download/$version/SHA256SUMS" -OutFile $manifest -UseBasicParsing
+ Invoke-WebRequest "$releaseBase/download/$version/$asset" -OutFile $archive -UseBasicParsing
+ $checksumLines = @(Get-Content $manifest | Where-Object { $_ -match ('^[a-fA-F0-9]{64} ' + [regex]::Escape($asset) + '$') })
+ if ($checksumLines.Count -ne 1) { throw 'Release checksum is missing or invalid' }
+ $expected = $checksumLines[0].Substring(0, 64).ToLowerInvariant()
$stream = [IO.File]::OpenRead($archive)
$hasher = [Security.Cryptography.SHA256]::Create()
try {
diff --git a/scripts/build-sea.mjs b/scripts/build-sea.mjs
index 132863e..c8b617d 100644
--- a/scripts/build-sea.mjs
+++ b/scripts/build-sea.mjs
@@ -1,11 +1,26 @@
import { spawnSync } from 'node:child_process'
-import { copyFile, mkdir, writeFile } from 'node:fs/promises'
+import { createHash } from 'node:crypto'
+import { existsSync } from 'node:fs'
+import { chmod, copyFile, mkdir, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
+import { parseArgs } from 'node:util'
+
+// Cross-target builds embed a portable blob (no snapshot or code cache) into the
+// official Node binary for the target, downloaded and checked against SHASUMS256.txt.
+const TARGETS = ['darwin-arm64', 'darwin-x64', 'win32-arm64', 'win32-x64']
+const host = `${process.platform}-${process.arch}`
+const { values } = parseArgs({ options: { target: { type: 'string', multiple: true } } })
+const requested = values.target ?? []
+for (const target of requested) {
+ if (!TARGETS.includes(target)) throw new Error(`--target must be one of: ${TARGETS.join(', ')}`)
+}
const outputDir = 'dist'
-const executable = join(outputDir, process.platform === 'win32' ? 'extrabrain.exe' : 'extrabrain')
const seaConfig = join(outputDir, 'sea-config.json')
const blob = join(outputDir, 'sea.blob')
+const nodeVersion = `v${process.versions.node}`
+const nodeDist = `https://nodejs.org/dist/${nodeVersion}`
+const cacheDir = join('node_modules', '.cache', 'extrabrain-sea', nodeVersion)
const run = (command, args) => {
const result = spawnSync(command, args, { stdio: 'inherit' })
@@ -14,6 +29,91 @@ const run = (command, args) => {
}
}
+const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex')
+
+const fetchBytes = async (url) => {
+ const response = await fetch(url)
+ if (!response.ok) throw new Error(`Download failed: ${url} (${response.status})`)
+ return Buffer.from(await response.arrayBuffer())
+}
+
+let checksums
+const verifiedDownload = async (name) => {
+ checksums ??= (await fetchBytes(`${nodeDist}/SHASUMS256.txt`)).toString('utf8')
+ const expected = checksums.split('\n').find((line) => line.endsWith(` ${name}`))?.slice(0, 64)
+ if (!expected) throw new Error(`${name} is missing from ${nodeDist}/SHASUMS256.txt`)
+ const path = join(cacheDir, name.replaceAll('/', '-'))
+ if (existsSync(path) && sha256(await readFile(path)) === expected) return path
+ const bytes = await fetchBytes(`${nodeDist}/${name}`)
+ if (sha256(bytes) !== expected) throw new Error(`Checksum mismatch for ${name}`)
+ await mkdir(cacheDir, { recursive: true })
+ await writeFile(path, bytes)
+ return path
+}
+
+const baseBinary = async (platform, arch) => {
+ if (`${platform}-${arch}` === host) return process.execPath
+ if (platform === 'win32') return verifiedDownload(`win-${arch}/node.exe`)
+ const name = `node-${nodeVersion}-darwin-${arch}`
+ const archive = await verifiedDownload(`${name}.tar.gz`)
+ run('tar', ['-xzf', archive, '-C', cacheDir, `${name}/bin/node`])
+ return join(cacheDir, name, 'bin', 'node')
+}
+
+const findSigntool = async () => {
+ if (process.env.EXTRABRAIN_SIGNTOOL_PATH) return process.env.EXTRABRAIN_SIGNTOOL_PATH
+ const kits = join(process.env['ProgramFiles(x86)'] ?? 'C:\\Program Files (x86)', 'Windows Kits', '10', 'bin')
+ const versions = existsSync(kits) ? await readdir(kits) : []
+ versions.sort((a, b) => b.localeCompare(a, undefined, { numeric: true }))
+ // The x64 tool also runs on Windows on ARM through emulation.
+ for (const arch of [...new Set([process.arch === 'arm64' ? 'arm64' : 'x64', 'x64'])]) {
+ for (const version of versions) {
+ const tool = join(kits, version, arch, 'signtool.exe')
+ if (existsSync(tool)) return tool
+ }
+ }
+ return undefined
+}
+
+// Copies the target's Node binary to the executable path without its signature.
+const prepareExecutable = async (platform, base, executable) => {
+ await rm(executable, { force: true })
+ if (platform === 'darwin') {
+ if (process.platform !== 'darwin') throw new Error('macOS executables must be built on macOS')
+ await copyFile(base, executable)
+ run('codesign', ['--remove-signature', executable])
+ } else if (platform === 'win32' && process.platform === 'win32') {
+ await copyFile(base, executable)
+ const signtool = await findSigntool()
+ // Removing the signature is optional; without it postject warns about the stale signature.
+ if (signtool) run(signtool, ['remove', '/s', executable])
+ else console.warn('signtool.exe was not found; keeping the Node.js signature')
+ } else if (platform === 'win32') {
+ run(process.env.EXTRABRAIN_OSSLSIGNCODE_PATH ?? 'osslsigncode', ['remove-signature', '-in', base, '-out', executable])
+ } else {
+ await copyFile(base, executable)
+ }
+}
+
+const build = async (target) => {
+ const [platform, arch] = target.split('-')
+ const directory = requested.length ? join(outputDir, target) : outputDir
+ const executable = join(directory, platform === 'win32' ? 'extrabrain.exe' : 'extrabrain')
+ await mkdir(directory, { recursive: true })
+ await prepareExecutable(platform, await baseBinary(platform, arch), executable)
+ run(process.execPath, [
+ join('node_modules', 'postject', 'dist', 'cli.js'),
+ executable,
+ 'NODE_SEA_BLOB',
+ blob,
+ '--sentinel-fuse',
+ 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2',
+ ...(platform === 'darwin' ? ['--macho-segment-name', 'NODE_SEA'] : [])
+ ])
+ if (platform !== 'win32') await chmod(executable, 0o755)
+ if (platform === 'darwin') run('codesign', ['--force', '--sign', '-', executable])
+}
+
await mkdir(outputDir, { recursive: true })
await writeFile(
seaConfig,
@@ -26,19 +126,4 @@ await writeFile(
})
)
run(process.execPath, ['--experimental-sea-config', seaConfig])
-await copyFile(process.execPath, executable)
-if (process.platform === 'darwin') run('codesign', ['--remove-signature', executable])
-if (process.platform === 'win32') {
- run(process.env.EXTRABRAIN_SIGNTOOL_PATH ?? 'signtool', ['remove', '/s', executable])
-}
-run(process.execPath, [
- join('node_modules', 'postject', 'dist', 'cli.js'),
- executable,
- 'NODE_SEA_BLOB',
- blob,
- '--sentinel-fuse',
- 'NODE_SEA_FUSE_fce680ab2cc467b6e072b8b5df1996b2',
- ...(process.platform === 'darwin' ? ['--macho-segment-name', 'NODE_SEA'] : [])
-])
-if (process.platform !== 'win32') run('chmod', ['755', executable])
-if (process.platform === 'darwin') run('codesign', ['--force', '--sign', '-', executable])
+for (const target of requested.length ? requested : [host]) await build(target)
diff --git a/src/credentialStore.ts b/src/credentialStore.ts
index 27950ac..096e6cc 100644
--- a/src/credentialStore.ts
+++ b/src/credentialStore.ts
@@ -5,6 +5,7 @@ const SERVICE = 'ExtraBrain standalone CLI document automation'
const ACCOUNT = 'local-api'
const WINDOWS_SCRIPT = `
$ErrorActionPreference = 'Stop'
+Add-Type -AssemblyName System.Security
$path = Join-Path $env:LOCALAPPDATA 'ExtraBrain\\cli-credential.dat'
try {
if ($env:EXTRABRAIN_CLI_CREDENTIAL_ACTION -eq 'read') {
diff --git a/src/resumeStore.service.ts b/src/resumeStore.service.ts
index b194a07..4c40cc1 100644
--- a/src/resumeStore.service.ts
+++ b/src/resumeStore.service.ts
@@ -29,7 +29,10 @@ export interface ResumeManifest {
}
}
-const defaultDirectory = (): string => join(homedir(), '.local', 'state', 'extrabrain', 'imports')
+const defaultDirectory = (): string =>
+ process.platform === 'win32' && process.env.LOCALAPPDATA
+ ? join(process.env.LOCALAPPDATA, 'ExtraBrain', 'state', 'imports')
+ : join(homedir(), '.local', 'state', 'extrabrain', 'imports')
export class ResumeStore {
private saveQueue: Promise = Promise.resolve()
diff --git a/src/update.ts b/src/update.ts
index 295b6f6..e5251ef 100644
--- a/src/update.ts
+++ b/src/update.ts
@@ -5,6 +5,12 @@ import installShell from '../install.sh?raw'
import installPowerShell from '../install.ps1?raw'
import { CliApiError } from './apiClient.service'
+// A packaged executable updates itself in place unless an install directory is requested.
+const installDirectory = (): Record =>
+ isSea() && !process.env.EXTRABRAIN_INSTALL_DIR
+ ? { EXTRABRAIN_INSTALL_DIR: dirname(process.execPath) }
+ : {}
+
export const updateCli = (): { scheduled: boolean } => {
if (process.platform === 'win32') {
const encoded = Buffer.from(installPowerShell, 'utf16le').toString('base64')
@@ -14,7 +20,7 @@ export const updateCli = (): { scheduled: boolean } => {
{
detached: true,
stdio: 'ignore',
- env: { ...process.env, EXTRABRAIN_WAIT_PID: String(process.pid) }
+ env: { ...process.env, ...installDirectory(), EXTRABRAIN_WAIT_PID: String(process.pid) }
}
)
child.unref()
@@ -23,12 +29,7 @@ export const updateCli = (): { scheduled: boolean } => {
const result = spawnSync('sh', ['-s'], {
encoding: 'utf8',
input: installShell,
- env: {
- ...process.env,
- ...(isSea() && !process.env.EXTRABRAIN_INSTALL_DIR
- ? { EXTRABRAIN_INSTALL_DIR: dirname(process.execPath) }
- : {})
- },
+ env: { ...process.env, ...installDirectory() },
timeout: 120_000
})
if (result.error || result.status !== 0) {
diff --git a/test/installWindows.test.ts b/test/installWindows.test.ts
index 476a0af..8d6221e 100644
--- a/test/installWindows.test.ts
+++ b/test/installWindows.test.ts
@@ -21,7 +21,7 @@ describe.skipIf(process.platform !== 'win32')('public Windows installer', () =>
directories.push(directory)
const installDir = join(directory, 'bin')
const source = join(directory, 'extrabrain.exe')
- const asset = 'extrabrain-v0.1.0-win32-x64.zip'
+ const asset = `extrabrain-v0.1.0-win32-${process.arch === 'arm64' ? 'arm64' : 'x64'}.zip`
const archive = join(directory, asset)
let checksum = ''
const publish = async (content: string, valid = true) => {