diff --git a/packages/buildthis/esbuild.config.mjs b/packages/buildthis/esbuild.config.mjs new file mode 100644 index 00000000..910ad9b7 --- /dev/null +++ b/packages/buildthis/esbuild.config.mjs @@ -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, +}; diff --git a/packages/buildthis/package.json b/packages/buildthis/package.json new file mode 100644 index 00000000..93443807 --- /dev/null +++ b/packages/buildthis/package.json @@ -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" + } +} diff --git a/packages/buildthis/src/__tests__/bootstrap.test.ts b/packages/buildthis/src/__tests__/bootstrap.test.ts new file mode 100644 index 00000000..fbfce8d3 --- /dev/null +++ b/packages/buildthis/src/__tests__/bootstrap.test.ts @@ -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"); + } + }); +}); diff --git a/packages/buildthis/src/__tests__/hardware.test.ts b/packages/buildthis/src/__tests__/hardware.test.ts new file mode 100644 index 00000000..6283876f --- /dev/null +++ b/packages/buildthis/src/__tests__/hardware.test.ts @@ -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"); + }); +}); diff --git a/packages/buildthis/src/banner.ts b/packages/buildthis/src/banner.ts new file mode 100644 index 00000000..7ae3e12a --- /dev/null +++ b/packages/buildthis/src/banner.ts @@ -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")); +} diff --git a/packages/buildthis/src/bootstrap.ts b/packages/buildthis/src/bootstrap.ts new file mode 100644 index 00000000..ad30286f --- /dev/null +++ b/packages/buildthis/src/bootstrap.ts @@ -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 { + 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 { + 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!")); +} diff --git a/packages/buildthis/src/hardware.ts b/packages/buildthis/src/hardware.ts new file mode 100644 index 00000000..c6147583 --- /dev/null +++ b/packages/buildthis/src/hardware.ts @@ -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 { + 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((_, 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 }; +} diff --git a/packages/buildthis/src/index.ts b/packages/buildthis/src/index.ts new file mode 100644 index 00000000..67ab2b02 --- /dev/null +++ b/packages/buildthis/src/index.ts @@ -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(); diff --git a/packages/buildthis/tsconfig.json b/packages/buildthis/tsconfig.json new file mode 100644 index 00000000..5a24989c --- /dev/null +++ b/packages/buildthis/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src"] +} diff --git a/packages/buildthis/vitest.config.ts b/packages/buildthis/vitest.config.ts new file mode 100644 index 00000000..ae847ff6 --- /dev/null +++ b/packages/buildthis/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts"], + }, +});