feat(35-01): buildthis CLI package — hardware detection + bootstrap

Standalone npm package at packages/buildthis/. Probes running Nexus
instance, opens browser if found, guides install with hardware-aware
provider recommendations if not. 14 tests.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Nexus Dev 2026-04-03 23:01:10 +00:00
parent 9c6ff93a89
commit e149e01458
10 changed files with 535 additions and 0 deletions

View file

@ -0,0 +1,13 @@
/** @type {import('esbuild').BuildOptions} */
export default {
entryPoints: ["src/index.ts"],
bundle: true,
platform: "node",
target: "node20",
format: "esm",
outfile: "dist/index.js",
banner: { js: "#!/usr/bin/env node" },
external: ["systeminformation", "open", "@clack/prompts", "commander", "picocolors"],
treeShaking: true,
sourcemap: true,
};

View file

@ -0,0 +1,39 @@
{
"name": "buildthis",
"version": "0.1.0",
"description": "Bootstrap your AI workspace with a single command",
"type": "module",
"bin": {
"buildthis": "./dist/index.js"
},
"files": [
"dist"
],
"publishConfig": {
"access": "public"
},
"engines": {
"node": ">=20"
},
"scripts": {
"dev": "tsx src/index.ts",
"build": "node --input-type=module -e \"import esbuild from 'esbuild'; import config from './esbuild.config.mjs'; await esbuild.build(config);\" && chmod +x dist/index.js",
"clean": "rm -rf dist",
"typecheck": "tsc --noEmit",
"test": "vitest run"
},
"dependencies": {
"@clack/prompts": "^0.10.0",
"commander": "^13.1.0",
"open": "^11.0.0",
"picocolors": "^1.1.1",
"systeminformation": "5"
},
"devDependencies": {
"@types/node": "^22.12.0",
"esbuild": "latest",
"tsx": "^4.19.2",
"typescript": "^5.7.3",
"vitest": "^2.0.0"
}
}

View file

@ -0,0 +1,92 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock fetch globally before imports
const mockFetch = vi.fn();
vi.stubGlobal("fetch", mockFetch);
// Mock open, clack, and hardware modules
vi.mock("open", () => ({ default: vi.fn().mockResolvedValue(undefined) }));
vi.mock("@clack/prompts", () => ({
spinner: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
select: vi.fn(),
isCancel: vi.fn().mockReturnValue(false),
cancel: vi.fn(),
outro: vi.fn(),
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), step: vi.fn() },
intro: vi.fn(),
}));
vi.mock("../hardware.js", () => ({
detectHardware: vi.fn().mockResolvedValue({ tier: "gpu", totalGb: 32, gpuName: "RTX 4090", gpuVramGb: 24 }),
}));
vi.mock("../banner.js", () => ({
printBuildthisBanner: vi.fn(),
}));
const { probeRunningInstance, getProviderOptions } = await import("../bootstrap.js");
describe("probeRunningInstance", () => {
beforeEach(() => {
vi.resetAllMocks();
vi.stubGlobal("fetch", mockFetch);
});
it("returns true when health endpoint responds with 200", async () => {
mockFetch.mockResolvedValue({ ok: true, status: 200 });
const result = await probeRunningInstance(3100);
expect(result).toBe(true);
expect(mockFetch).toHaveBeenCalledWith(
"http://127.0.0.1:3100/api/health",
expect.objectContaining({ signal: expect.any(Object) })
);
});
it("returns false when fetch throws (connection refused)", async () => {
mockFetch.mockRejectedValue(new Error("ECONNREFUSED"));
const result = await probeRunningInstance(3100);
expect(result).toBe(false);
});
it("returns false when health endpoint responds with non-200 status", async () => {
mockFetch.mockResolvedValue({ ok: false, status: 503 });
const result = await probeRunningInstance(3100);
expect(result).toBe(false);
});
it("returns false when AbortError is thrown (timeout)", async () => {
const abortError = new DOMException("The operation was aborted", "AbortError");
mockFetch.mockRejectedValue(abortError);
const result = await probeRunningInstance(3100);
expect(result).toBe(false);
});
});
describe("getProviderOptions", () => {
it("includes local AI option when tier is apple_silicon", () => {
const options = getProviderOptions("apple_silicon");
const values = options.map((o) => o.value);
expect(values).toContain("local");
});
it("includes local AI option when tier is gpu", () => {
const options = getProviderOptions("gpu");
const values = options.map((o) => o.value);
expect(values).toContain("local");
});
it("excludes local AI option when tier is cpu_only", () => {
const options = getProviderOptions("cpu_only");
const values = options.map((o) => o.value);
expect(values).not.toContain("local");
});
it("always includes puter, google, apikey, and skip options", () => {
for (const tier of ["apple_silicon", "gpu", "cpu_only"] as const) {
const options = getProviderOptions(tier);
const values = options.map((o) => o.value);
expect(values).toContain("puter");
expect(values).toContain("google");
expect(values).toContain("apikey");
expect(values).toContain("skip");
}
});
});

View file

@ -0,0 +1,136 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import os from "node:os";
// We mock systeminformation before importing hardware module
vi.mock("systeminformation", () => ({
default: {
graphics: vi.fn(),
},
}));
vi.mock("node:os", () => ({
default: {
cpus: vi.fn(),
totalmem: vi.fn(),
},
}));
// Import after mocks
const { detectHardware } = await import("../hardware.js");
const mockOs = vi.mocked(os);
describe("detectHardware", () => {
beforeEach(() => {
vi.resetAllMocks();
// Default: 16GB RAM
mockOs.totalmem.mockReturnValue(16 * 1024 * 1024 * 1024);
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("detects apple_silicon on darwin with Apple CPU", async () => {
vi.stubEnv("FORCE_PLATFORM", "darwin");
mockOs.cpus.mockReturnValue([
{ model: "Apple M4 Pro", speed: 3200, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } },
] as any);
// Override platform check — use internal platform stub
const result = await detectHardware("darwin");
expect(result.tier).toBe("apple_silicon");
expect(result.totalGb).toBeGreaterThan(0);
});
it("detects gpu on linux with NVIDIA GPU with sufficient VRAM", async () => {
const si = await import("systeminformation");
vi.mocked(si.default.graphics).mockResolvedValue({
controllers: [
{
model: "NVIDIA GeForce RTX 4090",
vram: 24576, // 24GB in MB
vramDynamic: false,
},
],
displays: [],
} as any);
mockOs.cpus.mockReturnValue([
{ model: "Intel Core i9-13900K", speed: 3200, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } },
] as any);
const result = await detectHardware("linux");
expect(result.tier).toBe("gpu");
expect(result.gpuName).toBe("NVIDIA GeForce RTX 4090");
expect(result.gpuVramGb).toBe(24);
expect(result.totalGb).toBeGreaterThan(0);
});
it("returns cpu_only when si.graphics times out (3 second timeout)", async () => {
const si = await import("systeminformation");
vi.mocked(si.default.graphics).mockImplementation(
() =>
new Promise((resolve) => {
// Never resolves — simulates a very long hang
setTimeout(resolve, 60_000);
})
);
mockOs.cpus.mockReturnValue([
{ model: "Intel Core i5", speed: 2400, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } },
] as any);
// Use fake timers so we don't actually wait 3 seconds
vi.useFakeTimers();
const resultPromise = detectHardware("linux");
vi.advanceTimersByTime(3001);
const result = await resultPromise;
vi.useRealTimers();
expect(result.tier).toBe("cpu_only");
});
it("returns cpu_only when GPU VRAM is below 4 GB", async () => {
const si = await import("systeminformation");
vi.mocked(si.default.graphics).mockResolvedValue({
controllers: [
{
model: "Intel UHD Graphics 630",
vram: 2048, // 2GB — below threshold
vramDynamic: false,
},
],
displays: [],
} as any);
mockOs.cpus.mockReturnValue([
{ model: "Intel Core i7", speed: 2800, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } },
] as any);
const result = await detectHardware("linux");
expect(result.tier).toBe("cpu_only");
});
it("returns cpu_only when si.graphics throws an error", async () => {
const si = await import("systeminformation");
vi.mocked(si.default.graphics).mockRejectedValue(new Error("graphics query failed"));
mockOs.cpus.mockReturnValue([
{ model: "AMD Ryzen 9", speed: 3600, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } },
] as any);
const result = await detectHardware("linux");
expect(result.tier).toBe("cpu_only");
});
it("returns cpu_only when controllers array is empty", async () => {
const si = await import("systeminformation");
vi.mocked(si.default.graphics).mockResolvedValue({
controllers: [],
displays: [],
} as any);
mockOs.cpus.mockReturnValue([
{ model: "AMD Ryzen 5", speed: 3200, times: { user: 0, nice: 0, sys: 0, idle: 0, irq: 0 } },
] as any);
const result = await detectHardware("linux");
expect(result.tier).toBe("cpu_only");
});
});

View file

@ -0,0 +1,24 @@
import pc from "picocolors";
const BUILDTHIS_ART = [
"██████╗ ██╗ ██╗██╗██╗ ██████╗ ████████╗██╗ ██╗██╗███████╗",
"██╔══██╗██║ ██║██║██║ ██╔══██╗╚══██╔══╝██║ ██║██║██╔════╝",
"██████╔╝██║ ██║██║██║ ██║ ██║ ██║ ███████║██║███████╗",
"██╔══██╗██║ ██║██║██║ ██║ ██║ ██║ ██╔══██║██║╚════██║",
"██████╔╝╚██████╔╝██║███████╗██████╔╝ ██║ ██║ ██║██║███████║",
"╚═════╝ ╚═════╝ ╚═╝╚══════╝╚═════╝ ╚═╝ ╚═╝ ╚═╝╚═╝╚══════╝",
] as const;
const TAGLINE = "Bootstrap your AI workspace";
export function printBuildthisBanner(): void {
const lines = [
"",
...BUILDTHIS_ART.map((line) => pc.cyan(line)),
pc.blue(" ──────────────────────────────────────────────────────────────────"),
pc.bold(pc.white(` ${TAGLINE}`)),
"",
];
console.log(lines.join("\n"));
}

View file

@ -0,0 +1,145 @@
import * as p from "@clack/prompts";
import pc from "picocolors";
import { detectHardware, type HardwareTier } from "./hardware.js";
import { printBuildthisBanner } from "./banner.js";
/**
* Probe whether a Nexus instance is running on the given port.
* Returns true if the health endpoint responds with HTTP 200 within 2 seconds.
*/
export async function probeRunningInstance(port: number): Promise<boolean> {
try {
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
signal: AbortSignal.timeout(2000),
});
return res.ok;
} catch {
return false;
}
}
/**
* Build the provider selection options based on hardware tier.
* Excludes local AI for cpu_only machines (too slow for practical use).
*/
export function getProviderOptions(tier: HardwareTier): Array<{ value: string; label: string; hint: string }> {
const options: Array<{ value: string; label: string; hint: string }> = [
{
value: "puter",
label: "Puter — free, zero-config",
hint: "No API key needed",
},
{
value: "google",
label: "Google — Gemini free tier",
hint: "Sign in with Google",
},
{
value: "apikey",
label: "API key — subscription provider",
hint: "OpenAI, Anthropic, Groq",
},
];
if (tier !== "cpu_only") {
options.push({
value: "local",
label: "Local AI (Ollama)",
hint: "Private, offline",
});
}
options.push({
value: "skip",
label: "Skip for now",
hint: "Set up a provider later",
});
return options;
}
/**
* Main bootstrap function.
*
* Two paths:
* 1. Nexus is already running on port 3100 open the browser
* 2. Fresh install detect hardware, show provider options, print next steps
*/
export async function bootstrap(): Promise<void> {
printBuildthisBanner();
const port = 3100;
// Non-TTY guard: prevent hanging in CI/piped environments
if (!process.stdin.isTTY || !process.stdout.isTTY) {
console.log(pc.bold("Run this command in an interactive terminal."));
console.log("");
console.log(" To open Nexus in your browser:");
console.log(pc.cyan(` http://127.0.0.1:${port}`));
console.log("");
console.log(" To install Nexus on this machine:");
console.log(pc.cyan(" npx paperclipai@latest onboard"));
process.exit(0);
return;
}
// Path 1: Running instance detected
const isRunning = await probeRunningInstance(port);
if (isRunning) {
const s = p.spinner();
s.start("Opening Nexus...");
const { default: open } = await import("open");
await open(`http://127.0.0.1:${port}`);
s.stop(pc.green("Nexus opened in your browser"));
return;
}
// Path 2: Fresh install — detect hardware and guide setup
const s = p.spinner();
s.start("Detecting hardware...");
const hw = await detectHardware();
s.stop("Hardware detected");
// Display hardware info
if (hw.tier === "apple_silicon") {
p.log.info(
`${pc.bold("Apple Silicon detected")} — unified memory (${hw.totalGb} GB), runs entirely on your machine`
);
} else if (hw.tier === "gpu") {
p.log.info(
`${pc.bold("GPU detected")}: ${hw.gpuName} (${hw.gpuVramGb} GB VRAM) — ${hw.totalGb} GB RAM`
);
} else {
p.log.info(
`${pc.bold("CPU-only machine")} (${hw.totalGb} GB RAM) — cloud AI recommended for best experience`
);
}
// Provider selection
const provider = await p.select({
message: "Which AI provider would you like to use?",
options: getProviderOptions(hw.tier),
});
if (p.isCancel(provider)) {
p.cancel("Cancelled");
process.exit(0);
return;
}
// Print next-step instructions
console.log("");
console.log(pc.bold("To install and start Nexus:"));
console.log(pc.cyan(" npx paperclipai@latest onboard"));
console.log("");
if (provider === "local") {
console.log(pc.dim("Make sure Ollama is installed: https://ollama.com"));
console.log("");
} else if (provider === "puter") {
console.log(pc.dim("Puter provides free AI — no setup needed"));
console.log("");
}
p.outro(pc.bold("Happy building!"));
}

View file

@ -0,0 +1,58 @@
import os from "node:os";
import si from "systeminformation";
export type HardwareTier = "gpu" | "apple_silicon" | "cpu_only";
export interface HardwareResult {
tier: HardwareTier;
totalGb: number;
gpuName?: string;
gpuVramGb?: number;
}
/**
* Detect the hardware tier of the current machine.
*
* Priority:
* 1. Apple Silicon (darwin + CPU model starts with "Apple")
* 2. GPU with >= 4 GB VRAM (detected via systeminformation with 3s timeout)
* 3. CPU-only fallback
*
* @param platform - Override process.platform for testing
*/
export async function detectHardware(platform?: string): Promise<HardwareResult> {
const resolvedPlatform = platform ?? process.platform;
const totalGb = Math.round((os.totalmem() / (1024 * 1024 * 1024)) * 10) / 10;
// Apple Silicon path
if (resolvedPlatform === "darwin") {
const cpus = os.cpus();
if (cpus.length > 0 && cpus[0]?.model?.startsWith("Apple")) {
return { tier: "apple_silicon", totalGb };
}
}
// GPU detection with 3-second timeout
try {
const timeoutPromise = new Promise<never>((_, reject) =>
setTimeout(() => reject(new Error("GPU detection timeout")), 3000)
);
const graphicsPromise = si.graphics();
const graphics = await Promise.race([graphicsPromise, timeoutPromise]);
const controller = graphics.controllers?.[0];
const vram = controller?.vram ?? 0;
if (controller && vram >= 4096) {
return {
tier: "gpu",
totalGb,
gpuName: controller.model,
gpuVramGb: Math.round(vram / 1024),
};
}
} catch {
// Timeout or error — fall through to cpu_only
}
return { tier: "cpu_only", totalGb };
}

View file

@ -0,0 +1,13 @@
// CLI entry point — shebang handled by esbuild banner
import { program } from "commander";
import { bootstrap } from "./bootstrap.js";
program
.name("buildthis")
.description("Bootstrap your AI workspace")
.version("0.1.0")
.action(async () => {
await bootstrap();
});
program.parse();

View file

@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src"
},
"include": ["src"]
}

View file

@ -0,0 +1,7 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["src/**/*.test.ts"],
},
});