install-glo 2.0.0 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/__tests__/ai-analysis.test.mjs +108 -0
- package/__tests__/lighthouse.test.mjs +192 -0
- package/__tests__/model.test.mjs +59 -0
- package/__tests__/source-discovery.test.mjs +78 -0
- package/__tests__/vitals.test.mjs +24 -0
- package/glo-loop.mjs +28 -463
- package/lib/ai-analysis.mjs +107 -0
- package/lib/display.mjs +103 -0
- package/lib/lighthouse.mjs +86 -0
- package/lib/model.mjs +21 -0
- package/lib/source-discovery.mjs +111 -0
- package/lib/vitals.mjs +40 -0
- package/package.json +5 -3
package/lib/display.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import { VITALS } from "./vitals.mjs";
|
|
3
|
+
|
|
4
|
+
export function formatVital(key, value) {
|
|
5
|
+
const info = VITALS[key];
|
|
6
|
+
if (!info || value === undefined || value === null) return null;
|
|
7
|
+
const passed = value <= info.good;
|
|
8
|
+
const formatted =
|
|
9
|
+
info.unit === "ms" ? `${Math.round(value)}ms` : value.toFixed(3);
|
|
10
|
+
const icon = passed ? chalk.green("✓") : chalk.red("✗");
|
|
11
|
+
const color = passed ? chalk.green : chalk.red;
|
|
12
|
+
return (
|
|
13
|
+
` ${chalk.white(key.padEnd(6))}` +
|
|
14
|
+
`${color(formatted.padStart(9))}` +
|
|
15
|
+
` ${chalk.dim(`(good: <${info.good}${info.unit})`)} ${icon}`
|
|
16
|
+
);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function printBanner() {
|
|
20
|
+
const lines = [
|
|
21
|
+
"",
|
|
22
|
+
chalk.hex("#FF8C00").bold(
|
|
23
|
+
" ┌─────────────────────────────────────────────────────┐"
|
|
24
|
+
),
|
|
25
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
26
|
+
chalk.hex("#FF8C00").bold(
|
|
27
|
+
" T H E G L O L O O P "
|
|
28
|
+
) +
|
|
29
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
30
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
31
|
+
chalk.dim(
|
|
32
|
+
" Web Vitals Optimization Engine "
|
|
33
|
+
) +
|
|
34
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
35
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
36
|
+
chalk.dim(
|
|
37
|
+
" "
|
|
38
|
+
) +
|
|
39
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
40
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
41
|
+
chalk.hex("#4AF626")(" G") +
|
|
42
|
+
chalk.white("ather → ") +
|
|
43
|
+
chalk.dim("run Lighthouse, extract metrics ") +
|
|
44
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
45
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
46
|
+
chalk.hex("#4AF626")(" L") +
|
|
47
|
+
chalk.white("everage → ") +
|
|
48
|
+
chalk.dim("AI analyzes code + diagnostics ") +
|
|
49
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
50
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
51
|
+
chalk.hex("#4AF626")(" O") +
|
|
52
|
+
chalk.white("perate → ") +
|
|
53
|
+
chalk.dim("apply fix, re-measure, repeat ") +
|
|
54
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
55
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
56
|
+
chalk.dim(
|
|
57
|
+
" "
|
|
58
|
+
) +
|
|
59
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
60
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
61
|
+
chalk.dim(" ↻ repeat until target met") +
|
|
62
|
+
chalk.dim(
|
|
63
|
+
" "
|
|
64
|
+
) +
|
|
65
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
66
|
+
chalk.hex("#FF8C00").bold(
|
|
67
|
+
" └─────────────────────────────────────────────────────┘"
|
|
68
|
+
),
|
|
69
|
+
"",
|
|
70
|
+
];
|
|
71
|
+
console.log(lines.join("\n"));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function printScore(score) {
|
|
75
|
+
const colored =
|
|
76
|
+
score >= 90
|
|
77
|
+
? chalk.green(`${score}/100`)
|
|
78
|
+
: score >= 50
|
|
79
|
+
? chalk.yellow(`${score}/100`)
|
|
80
|
+
: chalk.red(`${score}/100`);
|
|
81
|
+
console.log(chalk.white(` Score `) + chalk.bold(colored));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function printSuggestion(suggestion) {
|
|
85
|
+
for (const line of suggestion.split("\n")) {
|
|
86
|
+
if (
|
|
87
|
+
line.startsWith("DIAGNOSIS:") ||
|
|
88
|
+
line.startsWith("FILE:") ||
|
|
89
|
+
line.startsWith("LINE:") ||
|
|
90
|
+
line.startsWith("WHY:")
|
|
91
|
+
) {
|
|
92
|
+
const [label, ...rest] = line.split(":");
|
|
93
|
+
console.log(
|
|
94
|
+
chalk.hex("#FF8C00")(` ${label}:`) +
|
|
95
|
+
chalk.white(rest.join(":"))
|
|
96
|
+
);
|
|
97
|
+
} else if (line.startsWith("BEFORE:") || line.startsWith("AFTER:")) {
|
|
98
|
+
console.log(chalk.hex("#FF8C00")(` ${line}`));
|
|
99
|
+
} else {
|
|
100
|
+
console.log(chalk.hex("#89b4fa")(` ${line}`));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { execSync as defaultExecSync } from "node:child_process";
|
|
2
|
+
import { VITALS } from "./vitals.mjs";
|
|
3
|
+
|
|
4
|
+
export function checkLighthouse({ execSync = defaultExecSync } = {}) {
|
|
5
|
+
try {
|
|
6
|
+
execSync("npx -y lighthouse --version", { stdio: "pipe" });
|
|
7
|
+
return true;
|
|
8
|
+
} catch {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function runLighthouse(url, { execSync = defaultExecSync } = {}) {
|
|
14
|
+
const cmd = [
|
|
15
|
+
"npx -y lighthouse",
|
|
16
|
+
`"${url}"`,
|
|
17
|
+
"--output=json",
|
|
18
|
+
'--chrome-flags="--headless --no-sandbox"',
|
|
19
|
+
"--only-categories=performance",
|
|
20
|
+
"--quiet",
|
|
21
|
+
].join(" ");
|
|
22
|
+
|
|
23
|
+
const result = execSync(cmd, {
|
|
24
|
+
maxBuffer: 100 * 1024 * 1024,
|
|
25
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
26
|
+
});
|
|
27
|
+
return JSON.parse(result.toString());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function extractMetrics(report) {
|
|
31
|
+
const metrics = {};
|
|
32
|
+
for (const [key, info] of Object.entries(VITALS)) {
|
|
33
|
+
const audit = report.audits?.[info.audit];
|
|
34
|
+
if (audit) {
|
|
35
|
+
metrics[key] = {
|
|
36
|
+
value: audit.numericValue,
|
|
37
|
+
display: audit.displayValue,
|
|
38
|
+
score: audit.score,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
metrics.performanceScore = Math.round(
|
|
43
|
+
(report.categories?.performance?.score || 0) * 100
|
|
44
|
+
);
|
|
45
|
+
return metrics;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function extractDiagnostics(report) {
|
|
49
|
+
const relevant = [
|
|
50
|
+
"render-blocking-resources",
|
|
51
|
+
"unused-css-rules",
|
|
52
|
+
"unused-javascript",
|
|
53
|
+
"modern-image-formats",
|
|
54
|
+
"uses-optimized-images",
|
|
55
|
+
"uses-responsive-images",
|
|
56
|
+
"offscreen-images",
|
|
57
|
+
"unminified-css",
|
|
58
|
+
"unminified-javascript",
|
|
59
|
+
"dom-size",
|
|
60
|
+
"critical-request-chains",
|
|
61
|
+
"largest-contentful-paint-element",
|
|
62
|
+
"layout-shift-elements",
|
|
63
|
+
"long-tasks",
|
|
64
|
+
"mainthread-work-breakdown",
|
|
65
|
+
"bootup-time",
|
|
66
|
+
"font-display",
|
|
67
|
+
"uses-text-compression",
|
|
68
|
+
"duplicated-javascript",
|
|
69
|
+
"legacy-javascript",
|
|
70
|
+
"total-byte-weight",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const issues = [];
|
|
74
|
+
for (const id of relevant) {
|
|
75
|
+
const audit = report.audits?.[id];
|
|
76
|
+
if (audit && audit.score !== null && audit.score < 1) {
|
|
77
|
+
issues.push({
|
|
78
|
+
id,
|
|
79
|
+
title: audit.title,
|
|
80
|
+
displayValue: audit.displayValue || "",
|
|
81
|
+
score: audit.score,
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return issues.sort((a, b) => a.score - b.score);
|
|
86
|
+
}
|
package/lib/model.mjs
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { createAnthropic as defaultCreateAnthropic } from "@ai-sdk/anthropic";
|
|
2
|
+
import { createOpenAI as defaultCreateOpenAI } from "@ai-sdk/openai";
|
|
3
|
+
|
|
4
|
+
export function getModel({
|
|
5
|
+
env = process.env,
|
|
6
|
+
createAnthropic = defaultCreateAnthropic,
|
|
7
|
+
createOpenAI = defaultCreateOpenAI,
|
|
8
|
+
} = {}) {
|
|
9
|
+
if (env.ANTHROPIC_API_KEY) {
|
|
10
|
+
const anthropic = createAnthropic();
|
|
11
|
+
return {
|
|
12
|
+
model: anthropic("claude-sonnet-4-20250514"),
|
|
13
|
+
label: "Claude (Anthropic)",
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
if (env.OPENAI_API_KEY) {
|
|
17
|
+
const openai = createOpenAI();
|
|
18
|
+
return { model: openai("gpt-4o-mini"), label: "GPT-4o-mini (OpenAI)" };
|
|
19
|
+
}
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import {
|
|
2
|
+
readFileSync as defaultReadFileSync,
|
|
3
|
+
existsSync as defaultExistsSync,
|
|
4
|
+
} from "node:fs";
|
|
5
|
+
import { join, relative } from "node:path";
|
|
6
|
+
|
|
7
|
+
const MAX_FILE_SIZE = 15_000;
|
|
8
|
+
const MAX_IMPORT_SIZE = 8_000;
|
|
9
|
+
|
|
10
|
+
function pageCandidates(routePath) {
|
|
11
|
+
return [
|
|
12
|
+
// Next.js App Router
|
|
13
|
+
join("app", routePath, "page.tsx"),
|
|
14
|
+
join("app", routePath, "page.jsx"),
|
|
15
|
+
join("app", routePath, "page.js"),
|
|
16
|
+
join("app", routePath, "layout.tsx"),
|
|
17
|
+
join("app", routePath, "layout.jsx"),
|
|
18
|
+
join("app", "layout.tsx"),
|
|
19
|
+
join("app", "layout.jsx"),
|
|
20
|
+
// src/app
|
|
21
|
+
join("src", "app", routePath, "page.tsx"),
|
|
22
|
+
join("src", "app", routePath, "layout.tsx"),
|
|
23
|
+
join("src", "app", "layout.tsx"),
|
|
24
|
+
// Next.js Pages Router
|
|
25
|
+
join("pages", routePath + ".tsx"),
|
|
26
|
+
join("pages", routePath + ".jsx"),
|
|
27
|
+
join("pages", routePath, "index.tsx"),
|
|
28
|
+
join("pages", routePath, "index.jsx"),
|
|
29
|
+
// Config files relevant to performance
|
|
30
|
+
"next.config.ts",
|
|
31
|
+
"next.config.js",
|
|
32
|
+
"next.config.mjs",
|
|
33
|
+
"vite.config.ts",
|
|
34
|
+
"vite.config.js",
|
|
35
|
+
];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function truncate(content, limit) {
|
|
39
|
+
return content.length > limit
|
|
40
|
+
? content.slice(0, limit) + "\n// ... truncated"
|
|
41
|
+
: content;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function safeRead(fullPath, limit, { readFileSync, existsSync }) {
|
|
45
|
+
if (!existsSync(fullPath)) return null;
|
|
46
|
+
try {
|
|
47
|
+
const content = readFileSync(fullPath, "utf8");
|
|
48
|
+
return truncate(content, limit);
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function resolveImports(pageFile, projectRoot, files, { readFileSync, existsSync }) {
|
|
55
|
+
const importRegex = /from\s+["']([./][^"']+)["']/g;
|
|
56
|
+
let match;
|
|
57
|
+
while ((match = importRegex.exec(pageFile.content)) !== null) {
|
|
58
|
+
const importPath = match[1];
|
|
59
|
+
const possiblePaths = [
|
|
60
|
+
importPath + ".tsx",
|
|
61
|
+
importPath + ".jsx",
|
|
62
|
+
importPath + ".ts",
|
|
63
|
+
importPath + ".js",
|
|
64
|
+
join(importPath, "index.tsx"),
|
|
65
|
+
];
|
|
66
|
+
for (const p of possiblePaths) {
|
|
67
|
+
const resolved = join(
|
|
68
|
+
projectRoot,
|
|
69
|
+
pageFile.path.replace(/[^/]+$/, ""),
|
|
70
|
+
p
|
|
71
|
+
);
|
|
72
|
+
if (existsSync(resolved)) {
|
|
73
|
+
const content = safeRead(resolved, MAX_IMPORT_SIZE, { readFileSync, existsSync });
|
|
74
|
+
if (content !== null) {
|
|
75
|
+
const relPath = relative(projectRoot, resolved);
|
|
76
|
+
if (!files.find((f) => f.path === relPath)) {
|
|
77
|
+
files.push({ path: relPath, content });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function discoverPageFiles(
|
|
87
|
+
projectRoot,
|
|
88
|
+
route,
|
|
89
|
+
{ readFileSync = defaultReadFileSync, existsSync = defaultExistsSync } = {}
|
|
90
|
+
) {
|
|
91
|
+
const routePath = route === "/" ? "" : route.replace(/^\//, "");
|
|
92
|
+
const files = [];
|
|
93
|
+
const deps = { readFileSync, existsSync };
|
|
94
|
+
|
|
95
|
+
const seen = new Set();
|
|
96
|
+
for (const rel of pageCandidates(routePath)) {
|
|
97
|
+
if (seen.has(rel)) continue;
|
|
98
|
+
seen.add(rel);
|
|
99
|
+
const full = join(projectRoot, rel);
|
|
100
|
+
const content = safeRead(full, MAX_FILE_SIZE, deps);
|
|
101
|
+
if (content !== null) {
|
|
102
|
+
files.push({ path: rel, content });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (files.length > 0) {
|
|
107
|
+
resolveImports(files[0], projectRoot, files, deps);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return files;
|
|
111
|
+
}
|
package/lib/vitals.mjs
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// Web Vitals constants and thresholds
|
|
2
|
+
|
|
3
|
+
export const VITALS = {
|
|
4
|
+
LCP: {
|
|
5
|
+
good: 2500,
|
|
6
|
+
unit: "ms",
|
|
7
|
+
name: "Largest Contentful Paint",
|
|
8
|
+
audit: "largest-contentful-paint",
|
|
9
|
+
},
|
|
10
|
+
FCP: {
|
|
11
|
+
good: 1800,
|
|
12
|
+
unit: "ms",
|
|
13
|
+
name: "First Contentful Paint",
|
|
14
|
+
audit: "first-contentful-paint",
|
|
15
|
+
},
|
|
16
|
+
CLS: {
|
|
17
|
+
good: 0.1,
|
|
18
|
+
unit: "",
|
|
19
|
+
name: "Cumulative Layout Shift",
|
|
20
|
+
audit: "cumulative-layout-shift",
|
|
21
|
+
},
|
|
22
|
+
TBT: {
|
|
23
|
+
good: 200,
|
|
24
|
+
unit: "ms",
|
|
25
|
+
name: "Total Blocking Time",
|
|
26
|
+
audit: "total-blocking-time",
|
|
27
|
+
},
|
|
28
|
+
SI: {
|
|
29
|
+
good: 3400,
|
|
30
|
+
unit: "ms",
|
|
31
|
+
name: "Speed Index",
|
|
32
|
+
audit: "speed-index",
|
|
33
|
+
},
|
|
34
|
+
TTFB: {
|
|
35
|
+
good: 800,
|
|
36
|
+
unit: "ms",
|
|
37
|
+
name: "Time to First Byte",
|
|
38
|
+
audit: "server-response-time",
|
|
39
|
+
},
|
|
40
|
+
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "install-glo",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.2",
|
|
4
4
|
"description": "GLO Loop — AI-powered web vitals optimization engine built with Vercel AI SDK",
|
|
5
5
|
"main": "index.mjs",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,8 @@
|
|
|
10
10
|
"about-glo-loop": "about.mjs"
|
|
11
11
|
},
|
|
12
12
|
"scripts": {
|
|
13
|
-
"postinstall": "node postinstall.mjs"
|
|
13
|
+
"postinstall": "node postinstall.mjs",
|
|
14
|
+
"test": "node --test __tests__/*.test.mjs"
|
|
14
15
|
},
|
|
15
16
|
"type": "module",
|
|
16
17
|
"keywords": [
|
|
@@ -35,6 +36,7 @@
|
|
|
35
36
|
"@ai-sdk/openai": "^3.0.47",
|
|
36
37
|
"ai": "^6.0.134",
|
|
37
38
|
"boxen": "^8.0.1",
|
|
38
|
-
"chalk": "^5.4.1"
|
|
39
|
+
"chalk": "^5.4.1",
|
|
40
|
+
"zod": "^3.24.0"
|
|
39
41
|
}
|
|
40
42
|
}
|