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