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:
parent
9c6ff93a89
commit
e149e01458
10 changed files with 535 additions and 0 deletions
13
packages/buildthis/esbuild.config.mjs
Normal file
13
packages/buildthis/esbuild.config.mjs
Normal 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,
|
||||||
|
};
|
||||||
39
packages/buildthis/package.json
Normal file
39
packages/buildthis/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
92
packages/buildthis/src/__tests__/bootstrap.test.ts
Normal file
92
packages/buildthis/src/__tests__/bootstrap.test.ts
Normal 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");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
136
packages/buildthis/src/__tests__/hardware.test.ts
Normal file
136
packages/buildthis/src/__tests__/hardware.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
24
packages/buildthis/src/banner.ts
Normal file
24
packages/buildthis/src/banner.ts
Normal 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"));
|
||||||
|
}
|
||||||
145
packages/buildthis/src/bootstrap.ts
Normal file
145
packages/buildthis/src/bootstrap.ts
Normal 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!"));
|
||||||
|
}
|
||||||
58
packages/buildthis/src/hardware.ts
Normal file
58
packages/buildthis/src/hardware.ts
Normal 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 };
|
||||||
|
}
|
||||||
13
packages/buildthis/src/index.ts
Normal file
13
packages/buildthis/src/index.ts
Normal 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();
|
||||||
8
packages/buildthis/tsconfig.json
Normal file
8
packages/buildthis/tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "dist",
|
||||||
|
"rootDir": "src"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
packages/buildthis/vitest.config.ts
Normal file
7
packages/buildthis/vitest.config.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.test.ts"],
|
||||||
|
},
|
||||||
|
});
|
||||||
Loading…
Add table
Reference in a new issue