Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# Normalize line endings to LF on checkout for every OS (#43).
#
# kit is POSIX-first and every committed file uses LF. Pinning eol=lf keeps a
# Windows checkout byte-identical to macOS/Linux, which matters for golden
# snapshot tests (e.g. contracts/public-surface.json) that compare a freshly
# serialized LF string against the committed file: with git's default
# autocrlf=true on Windows the file would arrive as CRLF and the byte-for-byte
# compare would fail.
* text=auto eol=lf

# The shipped public-surface snapshot is compared byte-for-byte — pin it
# explicitly so the contract never depends on a contributor's git config.
contracts/public-surface.json text eol=lf
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).

## [Unreleased]

## [2.1.1] - 2026-06-27

kit 2.1 (Reach) — native Windows. The build + ~all tests already ran on windows-latest; this closes the remaining cross-platform gaps so kit runs natively on Windows (PowerShell/cmd), not only via WSL2.

### Fixed

- **Native Windows: 17 cross-platform test/runtime gaps closed** (verified on a windows-latest CI runner). Path handling made separator-agnostic (`path.isAbsolute`, `path.relative` containment, `path.posix.join` for shell-profile content, manual `/`+`\\` split for repo-name derivation); bare-tool resolution uses `where` on win32 (the `mise which` fast path already worked); plugin adapters import via `pathToFileURL` (a bare `C:\\...` path is not a valid ESM URL); `~/.kit/bin/kit` now also emits a managed `kit.cmd` shim on Windows; secret-file hardening asserts the platform-appropriate guarantee (icacls on NTFS, `0600` on POSIX); bumblebee cache-reuse + integrity no longer short-circuit on the platform gate. The `public-surface` golden snapshot is canonicalized (forward-slash + LF, pinned via `.gitattributes eol=lf`) so one committed snapshot matches macOS, Linux, and Windows.
- `docs/PLATFORM_SUPPORT.md` updated to reflect that native Windows now builds + passes the suite (residual gaps documented honestly, e.g. the bumblebee scanner binary ships POSIX-only so it honest-skips on native Windows).

## [2.1.0] - 2026-06-27

kit 2.1 (Reach), part 1 — make kit's governance fire everywhere a fleet actually runs.
Expand Down
80 changes: 49 additions & 31 deletions docs/PLATFORM_SUPPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ APIs (it uses `execFile` with argument arrays, `os.homedir()`, and
platform-routed commands), but a handful of operational dependencies at the
edges assume a POSIX shell and Unix tooling.

| Platform | Status | Notes |
|----------|--------|-------|
| **macOS** (Apple Silicon + Intel) | ✅ Supported | Full feature set. FileVault detected via `fdesetup`. |
| **Linux** (x86_64 + arm64) | ✅ Supported | Full feature set. LUKS detected via `lsblk`. |
| **Windows via WSL2** | ✅ Supported (recommended) | Run kit inside your WSL2 distro. Treat it as Linux. |
| **Windows via Git Bash / MSYS2** | ✅ Supported | Provides the POSIX shell + tools kit relies on. |
| **Native Windows** (PowerShell / cmd) | ⛔ Not supported yet | Hard blockers below. Use WSL2 instead. |
| Platform | Status | Notes |
| ------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| **macOS** (Apple Silicon + Intel) | ✅ Supported | Full feature set. FileVault detected via `fdesetup`. |
| **Linux** (x86_64 + arm64) | ✅ Supported | Full feature set. LUKS detected via `lsblk`. |
| **Windows via WSL2** | ✅ Supported (recommended) | Run kit inside your WSL2 distro. Treat it as Linux. |
| **Windows via Git Bash / MSYS2** | ✅ Supported | Provides the POSIX shell + tools kit relies on. |
| **Native Windows** (PowerShell / cmd) | ✅ Supported (build + test green) | Builds and the full test suite pass on `windows-latest` CI. A few features degrade gracefully — see [Residual gaps](#residual-gaps-on-native-windows). |

## Running on Windows

Expand Down Expand Up @@ -59,31 +59,49 @@ a `docker` on `PATH` (for `kit pkg docker:` / service adapters); registry triage
needs neither a daemon nor a CLI. There is intentionally no engine-specific
detection — pick whichever engine you prefer.

## Why native Windows is not supported yet

These are the concrete blockers, not a blanket "we didn't try". Each is
tracked for a future native-Windows effort:

1. **Git hooks are POSIX shell scripts.** kit installs pre-/post-commit hooks
written as `#!/bin/sh` with `date`, `stat`, and `rm` (`src/hooks.ts`).
cmd/PowerShell ignore the shebang and the syntax.
2. **Tool resolution uses `which`.** `src/utils/resolveTool.ts` shells out to
`which`, which does not exist on native Windows (`where` is the equivalent).
The `mise which` fast path works, but bare tools won't resolve.
3. **Archive extraction assumes `tar`.** The supply-chain scanner download
path (`src/bumblebee.ts`, `src/triage-sandbox.ts`) calls `tar`.
4. **Secret-file permissions rely on POSIX mode bits.** kit writes
`~/.kit/memory.db`, `elevation.key`, and materialized `.env.local` with
`0600`. `chmod` is a no-op on NTFS, so on a multi-user native-Windows box
those files would not get owner-only protection. WSL2 enforces them
normally.
5. **The build script is POSIX** (`rm -rf` + `chmod +x`). Contributors building
from source need a POSIX shell.

What already works cross-platform: home/config dir resolution (`os.homedir()`,
## Native Windows: what was fixed

The original native-Windows blockers have been resolved cross-platform:

1. **Tool resolution.** `src/utils/resolveTool.ts` uses `where` on Windows and
`which` on POSIX (the `mise which` fast path runs first on both).
2. **Secret-file permissions.** `src/utils/secure-perms.ts` restricts secret
files/dirs with `icacls` (strip inherited ACLs, grant only the current user)
on Windows and `chmod 0o600/0o700` on POSIX, so `~/.kit/memory.db`,
`mcp-tokens.json`, `elevation.key`, and materialized env files are owner-only
on NTFS too.
3. **Self-healing hook wrapper.** `kit hooks add` / `kit memory install` emit a
POSIX `~/.kit/bin/kit` wrapper AND a `~/.kit/bin/kit.cmd` companion shim so a
bare `kit` resolves from cmd/PowerShell as well as from a hook's `sh`.
4. **The build is cross-platform.** `npm run build` shells out only to node
helpers (`scripts/clean-dist.mjs` for `rm -rf dist`, `scripts/chmod-cli.mjs`
for the no-op-on-Windows `chmod +x`) plus `tsc` — no POSIX shell required.
5. **Path + line-ending handling.** Containment checks, plugin dynamic imports
(`pathToFileURL`), and the public-surface golden snapshot are
separator/line-ending independent; a repo-wide `.gitattributes eol=lf` keeps
checkouts byte-identical so snapshot tests match on Windows.

What already worked cross-platform: home/config dir resolution (`os.homedir()`,
`%APPDATA%`), git operations (`execFile`, no shell), browser open
(`start`/`open`/`xdg-open` routing), and the BitLocker branch of the
disk-encryption check.

If you want to help bring up native Windows, the remediation checklist lives in
the tracking issue linked from this repo's issues.
## Residual gaps on native Windows

These are honest, narrow limitations — kit runs, but a few features degrade:

1. **Supply-chain scanner binary.** bumblebee ships linux/darwin (amd64/arm64)
tarballs only; there is no native-Windows build. A previously cached binary
is reused and integrity-verified against its sidecar, but a _fresh_ install on
native Windows reports `unsupported`. Use WSL2/Docker for `kit scan` there.
2. **Git hooks need a POSIX `sh`.** The generated hooks are `#!/bin/sh` using
`date`/`stat`/`rm`. Git for Windows bundles `sh` (bash) and runs them, so
hooks work for anyone with Git for Windows installed; a hypothetical
git-without-sh environment would not execute them. The `kit.cmd` shim only
covers invoking kit, not the hook body's coreutils.
3. **`mise`-managed tools.** mise's native-Windows tool support is narrower than
on POSIX; the shims-on-PATH activation helpers target a POSIX shell profile.

For the richest experience on Windows, WSL2 (or the signed Docker image) remains
the recommendation. Native Windows is now a supported, tested target for the
core workflow.
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "sandstream-kit",
"version": "2.1.0",
"version": "2.1.1",
"description": "developer kit. zero LLM, local-first, multi-vault. one command from git clone to working dev environment.",
"license": "MIT",
"funding": "https://buymeacoffee.com/sandstream",
Expand Down
9 changes: 6 additions & 3 deletions scripts/gen-public-surface.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,22 @@
// change: review the diff, regenerate, commit, and add a BREAKING note to the
// changelog/PR if the change removes or renames a stable contract.
import { writeFileSync, existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { fileURLToPath, pathToFileURL } from "node:url";
import { dirname, join } from "node:path";

const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = join(__dirname, "..");

if (!existsSync(join(repoRoot, "dist", "public-surface.js"))) {
const distEntry = join(repoRoot, "dist", "public-surface.js");
if (!existsSync(distEntry)) {
console.error("dist/public-surface.js not found. Run `npm run build` first.");
process.exit(1);
}

// import() needs a file:// URL for an absolute path on Windows (a bare "C:\\…"
// throws ERR_UNSUPPORTED_ESM_URL_SCHEME); harmless on POSIX. #43.
const { collectPublicSurface, serializePublicSurface } = await import(
join(repoRoot, "dist", "public-surface.js")
pathToFileURL(distEntry).href
);

const out = join(repoRoot, "contracts", "public-surface.json");
Expand Down
23 changes: 15 additions & 8 deletions src/bumblebee.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,14 +189,10 @@ export async function ensureBumblebee(opts: EnsureOptions = {}): Promise<EnsureR
return { install: { binPath: envBin, catalogDir } };
}

const target = resolveTarget();
if (!target) {
return {
kind: "unsupported",
reason: `unsupported platform (${process.platform}/${process.arch}); bumblebee ships linux/darwin on amd64/arm64`,
};
}

// Cache reuse + verification is platform-independent: the cached binary path
// and its sidecar don't depend on the download target, so we check the cache
// BEFORE the platform gate. The `resolveTarget` gate only matters when we
// actually have to DOWNLOAD (bumblebee ships linux/darwin tarballs only). #43.
const root = cacheRoot();
const binPath = join(root, "bumblebee");
const catalogDir = join(root, "threat_intel");
Expand Down Expand Up @@ -241,6 +237,17 @@ export async function ensureBumblebee(opts: EnsureOptions = {}): Promise<EnsureR
};
}

// Downloading requires a supported release target — bumblebee ships
// linux/darwin (amd64/arm64) tarballs only. Native Windows has no binary, so a
// fresh (uncached) install is genuinely unsupported there.
const target = resolveTarget();
if (!target) {
return {
kind: "unsupported",
reason: `unsupported platform (${process.platform}/${process.arch}); bumblebee ships linux/darwin on amd64/arm64`,
};
}

try {
const notice = opts.notice ?? ((m: string) => process.stderr.write(m + "\n"));
notice(`kit: downloading supply-chain scanner bumblebee v${BUMBLEBEE_VERSION} (one-time)…`);
Expand Down
10 changes: 6 additions & 4 deletions src/check-disk-encryption.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
*/
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { resolve } from "node:path";
import { resolve, relative, isAbsolute } from "node:path";
import type { SecurityCheckResult } from "./check-security.js";
import { getMemoryDir } from "./memory/db.js";
import { getCurrentProjectRoot } from "./memory/project.js";
Expand Down Expand Up @@ -48,9 +48,11 @@ export function interpretLsblk(out: string): boolean | null {

/** Is the memory dir inside the repo working tree (committable foot-gun)? */
export function memoryDirInsideRepo(memDir: string, repoRoot: string): boolean {
const m = resolve(memDir);
const r = resolve(repoRoot);
return m === r || m.startsWith(r + "/");
// Containment via path.relative (mirrors plugin-loader): if memDir is the repo
// root or below it, the relative path is "" or doesn't escape with "..". This is
// cross-platform — `r + "/"` would miss Windows "\\" separators (#43).
const rel = relative(resolve(repoRoot), resolve(memDir));
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
}

// ─── Checks ─────────────────────────────────────────────────────────────────
Expand Down
33 changes: 19 additions & 14 deletions src/clone.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,20 +18,24 @@ before(async () => {
await writeFile(join(testRepoDir, "README.md"), "# Test Repo\n", "utf-8");
await writeFile(join(testRepoDir, ".kit.toml"), '[tools]\nnode = "22"\n', "utf-8");

// Initialize git
const { execSync } = await import("node:child_process");
// Initialize git. Use execFileSync with an argv array (NOT a shell string):
// on Windows execSync runs through cmd.exe, which does not treat single quotes
// as quoting, so "git commit -m 'Initial commit'" tokenizes into a broken
// message plus a stray pathspec and the commit fails. An argv array bypasses
// the shell entirely, so the same calls behave identically on POSIX and win32.
const { execFileSync } = await import("node:child_process");
try {
execSync("git init", { cwd: testRepoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
execFileSync("git", ["init"], { cwd: testRepoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: testRepoDir,
stdio: "pipe",
});
execSync("git config user.name 'Test'", {
execFileSync("git", ["config", "user.name", "Test"], {
cwd: testRepoDir,
stdio: "pipe",
});
execSync("git add .", { cwd: testRepoDir, stdio: "pipe" });
execSync("git commit -m 'Initial commit'", {
execFileSync("git", ["add", "."], { cwd: testRepoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "Initial commit"], {
cwd: testRepoDir,
stdio: "pipe",
});
Expand Down Expand Up @@ -96,20 +100,21 @@ describe("cloneRepository", () => {
await mkdir(noTomlRepoDir, { recursive: true });
await writeFile(join(noTomlRepoDir, "README.md"), "# Test\n", "utf-8");

// Initialize git repo without .kit.toml
const { execSync } = await import("node:child_process");
// Initialize git repo without .kit.toml. execFileSync (argv array, no shell)
// so the commit succeeds on Windows cmd.exe as well as POSIX sh.
const { execFileSync } = await import("node:child_process");
try {
execSync("git init", { cwd: noTomlRepoDir, stdio: "pipe" });
execSync("git config user.email 'test@test.com'", {
execFileSync("git", ["init"], { cwd: noTomlRepoDir, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@test.com"], {
cwd: noTomlRepoDir,
stdio: "pipe",
});
execSync("git config user.name 'Test'", {
execFileSync("git", ["config", "user.name", "Test"], {
cwd: noTomlRepoDir,
stdio: "pipe",
});
execSync("git add .", { cwd: noTomlRepoDir, stdio: "pipe" });
execSync("git commit -m 'Initial'", {
execFileSync("git", ["add", "."], { cwd: noTomlRepoDir, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "Initial"], {
cwd: noTomlRepoDir,
stdio: "pipe",
});
Expand Down
14 changes: 11 additions & 3 deletions src/clone.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { access, constants } from "node:fs/promises";
import { resolve, basename } from "node:path";
import { resolve } from "node:path";
import { exec } from "./utils/exec.js";

export interface CloneOptions {
Expand Down Expand Up @@ -33,10 +33,18 @@ export async function cloneRepository(opts: CloneOptions): Promise<CloneResult>

// Derive target directory from repo URL if not provided
// e.g., "https://github.com/user/my-repo.git" → "my-repo"
// Split on BOTH separators ourselves rather than path.basename: a repo URL
// always uses "/", but path.basename is platform-routed (win32 basename also
// splits on "\\"), so deriving the name by hand keeps it identical on every
// OS and independent of the local path flavour. #43.
let targetDir = opts.targetDir;
if (!targetDir) {
const repoName = basename(repoUrl).replace(/\.git$/, "");
targetDir = repoName;
const lastSegment =
repoUrl
.replace(/[/\\]+$/, "")
.split(/[/\\]/)
.pop() ?? repoUrl;
targetDir = lastSegment.replace(/\.git$/, "");
}

const clonedPath = resolve(cwd, targetDir);
Expand Down
17 changes: 12 additions & 5 deletions src/hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,18 @@ describe("installHooks", () => {
await installHooks(config, testGitDir);

const hookPath = join(testGitDir, "hooks", "pre-commit");
const { stat } = await import("node:fs/promises");
const stats = await stat(hookPath);

// Check if file is executable (mode should include 0o100)
assert.ok((stats.mode & 0o111) !== 0);
// NTFS has no POSIX execute bit, so chmod(0o755) is a no-op on native Windows
// and stat().mode never carries 0o111. Git for Windows runs the hook via its
// bundled `sh` regardless of the exec bit, so on win32 we assert the hook was
// written instead of the (meaningless) mode bits. #43.
if (process.platform === "win32") {
assert.ok(existsSync(hookPath));
} else {
const { stat } = await import("node:fs/promises");
const stats = await stat(hookPath);
// Check if file is executable (mode should include 0o100)
assert.ok((stats.mode & 0o111) !== 0);
}
});

it("updates existing hooks", async () => {
Expand Down
Loading
Loading