install-glo 2.0.0
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/README.md +92 -0
- package/about.mjs +67 -0
- package/card.mjs +236 -0
- package/glo-loop.mjs +674 -0
- package/index.mjs +16 -0
- package/package.json +40 -0
- package/postinstall.mjs +43 -0
package/README.md
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
# install-glo
|
|
2
|
+
|
|
3
|
+
An npm package that does two things:
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx install-glo # → animated business card
|
|
7
|
+
npx install-glo ai # → AI-powered web vitals optimizer
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
No install required. Just `npx`.
|
|
11
|
+
|
|
12
|
+
---
|
|
13
|
+
|
|
14
|
+
## `npx install-glo`
|
|
15
|
+
|
|
16
|
+
Renders an animated ASCII neuro shader (a port of a WebGL sine-noise algorithm, in Catppuccin Mocha palette), followed by a business card for **Gonzalo "Glo" Maldonado**.
|
|
17
|
+
|
|
18
|
+
## `npx install-glo ai`
|
|
19
|
+
|
|
20
|
+
Starts the **GLO Loop** — an iterative web performance optimizer built on the [Vercel AI SDK](https://sdk.vercel.ai).
|
|
21
|
+
|
|
22
|
+
It does this:
|
|
23
|
+
|
|
24
|
+
1. **Gather** — Runs Lighthouse on your URL, extracts Core Web Vitals and diagnostics
|
|
25
|
+
2. **Leverage** — Reads your source files (Next.js App Router, Pages Router, configs), sends metrics + code to Claude or GPT-4o via the Vercel AI SDK
|
|
26
|
+
3. **Operate** — AI returns one surgical fix: file path, line number, before/after code, estimated improvement
|
|
27
|
+
4. **Repeat** — Re-runs Lighthouse to measure the effect, loops until the target vital hits "good"
|
|
28
|
+
|
|
29
|
+
### Quick start
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# 1. Set a provider key
|
|
33
|
+
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY=sk-...
|
|
34
|
+
|
|
35
|
+
# 2. Start your dev server
|
|
36
|
+
|
|
37
|
+
# 3. Run it
|
|
38
|
+
npx install-glo ai
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
It will prompt for:
|
|
42
|
+
- **Page URL** (default: `http://localhost:3000`)
|
|
43
|
+
- **Target vital** — one of LCP, FCP, CLS, TBT, SI, or TTFB
|
|
44
|
+
- **Max loops** (default: 10)
|
|
45
|
+
|
|
46
|
+
### Supported vitals
|
|
47
|
+
|
|
48
|
+
| Vital | Full Name | "Good" Threshold |
|
|
49
|
+
|-------|-----------|-------------------|
|
|
50
|
+
| LCP | Largest Contentful Paint | < 2500ms |
|
|
51
|
+
| FCP | First Contentful Paint | < 1800ms |
|
|
52
|
+
| CLS | Cumulative Layout Shift | < 0.1 |
|
|
53
|
+
| TBT | Total Blocking Time | < 200ms |
|
|
54
|
+
| SI | Speed Index | < 3400ms |
|
|
55
|
+
| TTFB | Time to First Byte | < 800ms |
|
|
56
|
+
|
|
57
|
+
### Requirements
|
|
58
|
+
|
|
59
|
+
- Chrome or Chromium (Lighthouse runs headless Chrome)
|
|
60
|
+
- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY`
|
|
61
|
+
- A running dev server at the target URL
|
|
62
|
+
|
|
63
|
+
## `npx install-glo about`
|
|
64
|
+
|
|
65
|
+
Prints a formatted explainer of what the GLO Loop is and how to use it.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## Install as a dependency
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
npm install install-glo
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
The postinstall script adds two npm scripts to your project:
|
|
76
|
+
|
|
77
|
+
- `npm run glo-loop` — starts the optimization loop
|
|
78
|
+
- `npm run about-glo-loop` — prints the explainer
|
|
79
|
+
|
|
80
|
+
## Built with
|
|
81
|
+
|
|
82
|
+
- [Vercel AI SDK](https://sdk.vercel.ai) (`ai`, `@ai-sdk/anthropic`, `@ai-sdk/openai`)
|
|
83
|
+
- Lighthouse (via headless Chrome)
|
|
84
|
+
- chalk, boxen
|
|
85
|
+
|
|
86
|
+
## About
|
|
87
|
+
|
|
88
|
+
Created by **Gonzalo "Glo" Maldonado** — CTO / VP Eng / Technical Co-Founder.
|
|
89
|
+
20+ years, 5 exits (Yammer → Microsoft $1.2B, Nextdoor IPO).
|
|
90
|
+
AI Infrastructure, Distributed Systems, Engineering Leadership.
|
|
91
|
+
|
|
92
|
+
[intro.co/GonzaloMaldonado](https://intro.co/GonzaloMaldonado) · [sanscourier.ai](https://sanscourier.ai) · [github.com/elg0nz](https://github.com/elg0nz)
|
package/about.mjs
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import chalk from "chalk";
|
|
4
|
+
|
|
5
|
+
const o = chalk.hex("#FF8C00");
|
|
6
|
+
const g = chalk.hex("#4AF626");
|
|
7
|
+
const d = chalk.dim;
|
|
8
|
+
const w = chalk.white;
|
|
9
|
+
|
|
10
|
+
console.log(`
|
|
11
|
+
${o.bold(" ┌─────────────────────────────────────────────────────┐")}
|
|
12
|
+
${o.bold(" │")}${o.bold(" T H E G L O L O O P ")}${o.bold("│")}
|
|
13
|
+
${o.bold(" │")}${d(" Web Vitals Optimization Engine ")}${o.bold("│")}
|
|
14
|
+
${o.bold(" └─────────────────────────────────────────────────────┘")}
|
|
15
|
+
|
|
16
|
+
${w.bold(" What is it?")}
|
|
17
|
+
|
|
18
|
+
A metrics-centric optimization loop for web performance.
|
|
19
|
+
${d("Runs Lighthouse, uses AI to diagnose issues, suggests")}
|
|
20
|
+
${d("surgical fixes, re-measures. Repeat until your target is met.")}
|
|
21
|
+
|
|
22
|
+
${w.bold(" The Loop")}
|
|
23
|
+
|
|
24
|
+
${g("G")}${w("ather")} ${d("→ Run Lighthouse, extract web vitals")}
|
|
25
|
+
${g("L")}${w("everage")} ${d("→ AI reads your source code + diagnostics")}
|
|
26
|
+
${g("O")}${w("perate")} ${d("→ Apply fix, re-measure, repeat")}
|
|
27
|
+
|
|
28
|
+
${w.bold(" Supported Vitals")}
|
|
29
|
+
|
|
30
|
+
${g("LCP")} ${d("Largest Contentful Paint (good: <2500ms)")}
|
|
31
|
+
${g("FCP")} ${d("First Contentful Paint (good: <1800ms)")}
|
|
32
|
+
${g("CLS")} ${d("Cumulative Layout Shift (good: <0.1)")}
|
|
33
|
+
${g("TBT")} ${d("Total Blocking Time (good: <200ms)")}
|
|
34
|
+
${g("SI")} ${d("Speed Index (good: <3400ms)")}
|
|
35
|
+
${g("TTFB")} ${d("Time to First Byte (good: <800ms)")}
|
|
36
|
+
|
|
37
|
+
${w.bold(" Usage")}
|
|
38
|
+
|
|
39
|
+
${w("npm run glo-loop")}
|
|
40
|
+
${d("Interactive — asks for URL, target vital, and max loops.")}
|
|
41
|
+
|
|
42
|
+
${d("Requires an AI provider key:")}
|
|
43
|
+
${g("export ANTHROPIC_API_KEY=sk-ant-...")} ${d("(recommended)")}
|
|
44
|
+
${g("export OPENAI_API_KEY=sk-...")}
|
|
45
|
+
|
|
46
|
+
${d("Also requires Chrome (for Lighthouse).")}
|
|
47
|
+
|
|
48
|
+
${w.bold(" Requirements")}
|
|
49
|
+
|
|
50
|
+
${d("Chrome/Chromium installed (Lighthouse uses it)")}
|
|
51
|
+
${d("Dev server running on the target URL")}
|
|
52
|
+
${d("ANTHROPIC_API_KEY or OPENAI_API_KEY set")}
|
|
53
|
+
|
|
54
|
+
${w.bold(" Built With")}
|
|
55
|
+
|
|
56
|
+
${d("Vercel AI SDK")} ${w("(npm i ai)")} ${d("+ Lighthouse + your source code.")}
|
|
57
|
+
|
|
58
|
+
${w.bold(" About the Creator")}
|
|
59
|
+
|
|
60
|
+
${o("Gonzalo \"Glo\" Maldonado")}
|
|
61
|
+
${w("CTO / VP Eng / Technical Co-Founder")}
|
|
62
|
+
${d("20+ years, 5 exits (Yammer → Microsoft $1.2B, Nextdoor IPO)")}
|
|
63
|
+
${d("AI Infrastructure, Distributed Systems, Engineering Leadership")}
|
|
64
|
+
|
|
65
|
+
${o("Book a call:")} ${w.underline("intro.co/GonzaloMaldonado")}
|
|
66
|
+
${o("Website:")} ${w.underline("sanscourier.ai")}
|
|
67
|
+
`);
|
package/card.mjs
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import boxen from "boxen";
|
|
3
|
+
|
|
4
|
+
// ── Neuro Shader (ASCII port) ──────────────────────────────────────────
|
|
5
|
+
// Simplified port of the SansCourier WebGL neuro noise shader.
|
|
6
|
+
// Uses the same rotate-and-accumulate-sine algorithm, rendered as
|
|
7
|
+
// dithered ASCII characters in Catppuccin Mocha palette.
|
|
8
|
+
|
|
9
|
+
const CHARS = " .:-=+*#%@";
|
|
10
|
+
const WIDTH = 58;
|
|
11
|
+
const HEIGHT = 18;
|
|
12
|
+
const FRAMES = 42;
|
|
13
|
+
const FRAME_MS = 60;
|
|
14
|
+
|
|
15
|
+
const catppuccin = {
|
|
16
|
+
base: "#1e1e2e",
|
|
17
|
+
blue: "#89b4fa",
|
|
18
|
+
lavender: "#b4befe",
|
|
19
|
+
text: "#cdd6f4",
|
|
20
|
+
orange: "#FF8C00",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function rot2(x, y, a) {
|
|
24
|
+
const c = Math.cos(a);
|
|
25
|
+
const s = Math.sin(a);
|
|
26
|
+
return [c * x - s * y, s * x + c * y];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function neuroShape(ux, uy, t) {
|
|
30
|
+
let sax = 0,
|
|
31
|
+
say = 0;
|
|
32
|
+
let rx = 0,
|
|
33
|
+
ry = 0;
|
|
34
|
+
let scale = 8.0;
|
|
35
|
+
let uvx = ux,
|
|
36
|
+
uvy = uy;
|
|
37
|
+
|
|
38
|
+
for (let j = 0; j < 8; j++) {
|
|
39
|
+
[uvx, uvy] = rot2(uvx, uvy, 1.0);
|
|
40
|
+
[sax, say] = rot2(sax, say, 1.0);
|
|
41
|
+
const lx = uvx * scale + j + sax - t;
|
|
42
|
+
const ly = uvy * scale + j + say - t;
|
|
43
|
+
sax += Math.sin(lx);
|
|
44
|
+
say += Math.sin(ly);
|
|
45
|
+
rx += (0.5 + 0.5 * Math.cos(lx)) / scale;
|
|
46
|
+
ry += (0.5 + 0.5 * Math.cos(ly)) / scale;
|
|
47
|
+
scale *= 1.2;
|
|
48
|
+
}
|
|
49
|
+
return rx + ry;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function renderFrame(t) {
|
|
53
|
+
const lines = [];
|
|
54
|
+
for (let y = 0; y < HEIGHT; y++) {
|
|
55
|
+
let line = "";
|
|
56
|
+
for (let x = 0; x < WIDTH; x++) {
|
|
57
|
+
const ux = ((x / WIDTH - 0.5) * 2.0 * WIDTH) / HEIGHT;
|
|
58
|
+
const uy = (y / HEIGHT - 0.5) * 2.0;
|
|
59
|
+
|
|
60
|
+
let noise = neuroShape(ux * 0.7, uy * 0.7, t);
|
|
61
|
+
noise = 1.1 * Math.pow(noise, 2.0);
|
|
62
|
+
noise = Math.pow(noise, 1.2);
|
|
63
|
+
noise = Math.min(1.0, noise);
|
|
64
|
+
|
|
65
|
+
const idx = Math.floor(noise * (CHARS.length - 1));
|
|
66
|
+
const ch = CHARS[idx];
|
|
67
|
+
|
|
68
|
+
// Color based on intensity - blend from base to blue to text
|
|
69
|
+
if (noise < 0.25) {
|
|
70
|
+
line += chalk.hex(catppuccin.base)(ch);
|
|
71
|
+
} else if (noise < 0.5) {
|
|
72
|
+
line += chalk.hex(catppuccin.blue)(ch);
|
|
73
|
+
} else if (noise < 0.75) {
|
|
74
|
+
line += chalk.hex(catppuccin.lavender)(ch);
|
|
75
|
+
} else {
|
|
76
|
+
line += chalk.hex(catppuccin.text)(ch);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
lines.push(line);
|
|
80
|
+
}
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function sleep(ms) {
|
|
85
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Business Card ──────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function buildCard() {
|
|
91
|
+
const d = {
|
|
92
|
+
name: chalk.bold.hex("#FF8C00")(" Gonzalo \"Glo\" Maldonado"),
|
|
93
|
+
title: chalk.white(" CTO / VP Eng / Technical Co-Founder"),
|
|
94
|
+
tagline: chalk.dim.italic(
|
|
95
|
+
" Ship value. Say what matters. Measure what counts."
|
|
96
|
+
),
|
|
97
|
+
divider: chalk.hex("#FF8C00")(
|
|
98
|
+
" ─────────────────────────────────────"
|
|
99
|
+
),
|
|
100
|
+
exits: chalk.white(" 5 exits") + chalk.dim(" including:"),
|
|
101
|
+
exit1:
|
|
102
|
+
chalk.hex("#4AF626")(" Yammer → Microsoft") +
|
|
103
|
+
chalk.dim(" ($1.2B)"),
|
|
104
|
+
exit2: chalk.hex("#4AF626")(" Nextdoor → IPO"),
|
|
105
|
+
experience:
|
|
106
|
+
chalk.white(" 20+ years") + chalk.dim(" engineering leadership"),
|
|
107
|
+
focus:
|
|
108
|
+
chalk.white(" Focus: ") +
|
|
109
|
+
chalk.dim("AI Infrastructure, Distributed Systems"),
|
|
110
|
+
web:
|
|
111
|
+
chalk.hex("#FF8C00")(" web") +
|
|
112
|
+
chalk.dim(" → ") +
|
|
113
|
+
chalk.white("sanscourier.ai"),
|
|
114
|
+
book:
|
|
115
|
+
chalk.hex("#FF8C00")(" book") +
|
|
116
|
+
chalk.dim(" → ") +
|
|
117
|
+
chalk.white("intro.co/GonzaloMaldonado"),
|
|
118
|
+
linkedin:
|
|
119
|
+
chalk.hex("#FF8C00")(" linkedin") +
|
|
120
|
+
chalk.dim(" → ") +
|
|
121
|
+
chalk.white("linkedin.com/in/elg0nz"),
|
|
122
|
+
github:
|
|
123
|
+
chalk.hex("#FF8C00")(" github") +
|
|
124
|
+
chalk.dim(" → ") +
|
|
125
|
+
chalk.white("github.com/elg0nz"),
|
|
126
|
+
email:
|
|
127
|
+
chalk.hex("#FF8C00")(" email") +
|
|
128
|
+
chalk.dim(" → ") +
|
|
129
|
+
chalk.white("glo@sanscourier.ai"),
|
|
130
|
+
cta:
|
|
131
|
+
chalk.bold.hex("#FF8C00")(" Ready to talk? ") +
|
|
132
|
+
chalk.underline.white("intro.co/GonzaloMaldonado"),
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const card = [
|
|
136
|
+
"",
|
|
137
|
+
d.name,
|
|
138
|
+
d.title,
|
|
139
|
+
d.tagline,
|
|
140
|
+
"",
|
|
141
|
+
d.divider,
|
|
142
|
+
"",
|
|
143
|
+
d.exits,
|
|
144
|
+
d.exit1,
|
|
145
|
+
d.exit2,
|
|
146
|
+
d.experience,
|
|
147
|
+
d.focus,
|
|
148
|
+
"",
|
|
149
|
+
d.divider,
|
|
150
|
+
"",
|
|
151
|
+
d.web,
|
|
152
|
+
d.book,
|
|
153
|
+
d.linkedin,
|
|
154
|
+
d.github,
|
|
155
|
+
d.email,
|
|
156
|
+
"",
|
|
157
|
+
d.divider,
|
|
158
|
+
"",
|
|
159
|
+
d.cta,
|
|
160
|
+
"",
|
|
161
|
+
].join("\n");
|
|
162
|
+
|
|
163
|
+
return boxen(card, {
|
|
164
|
+
padding: 1,
|
|
165
|
+
margin: 1,
|
|
166
|
+
borderStyle: "double",
|
|
167
|
+
borderColor: "#FF8C00",
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
async function main() {
|
|
174
|
+
const isTTY = process.stdout.isTTY;
|
|
175
|
+
|
|
176
|
+
if (isTTY) {
|
|
177
|
+
// Hide cursor during animation
|
|
178
|
+
process.stdout.write("\x1B[?25l");
|
|
179
|
+
|
|
180
|
+
const label = chalk.hex(catppuccin.orange)(
|
|
181
|
+
" ╔══════════════════════════════════════════════════════════╗"
|
|
182
|
+
);
|
|
183
|
+
const labelBottom = chalk.hex(catppuccin.orange)(
|
|
184
|
+
" ╚══════════════════════════════════════════════════════════╝"
|
|
185
|
+
);
|
|
186
|
+
const brandText = chalk.bold.hex(catppuccin.orange)(
|
|
187
|
+
" sanscourier.ai"
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
for (let f = 0; f < FRAMES; f++) {
|
|
191
|
+
const t = (f / FRAMES) * Math.PI * 2 * 0.8;
|
|
192
|
+
const frame = renderFrame(t);
|
|
193
|
+
|
|
194
|
+
// Move cursor to top-left and draw
|
|
195
|
+
process.stdout.write("\x1B[H\x1B[2J");
|
|
196
|
+
process.stdout.write("\n");
|
|
197
|
+
process.stdout.write(label + "\n");
|
|
198
|
+
process.stdout.write(frame + "\n");
|
|
199
|
+
process.stdout.write(labelBottom + "\n");
|
|
200
|
+
process.stdout.write(brandText + "\n");
|
|
201
|
+
|
|
202
|
+
await sleep(FRAME_MS);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Show cursor again
|
|
206
|
+
process.stdout.write("\x1B[?25l");
|
|
207
|
+
process.stdout.write("\x1B[H\x1B[2J");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
console.log(buildCard());
|
|
211
|
+
console.log(
|
|
212
|
+
chalk.dim(
|
|
213
|
+
"\n Tip: Run " +
|
|
214
|
+
chalk.white("npx install-glo") +
|
|
215
|
+
" anytime to see this card again."
|
|
216
|
+
)
|
|
217
|
+
);
|
|
218
|
+
console.log(
|
|
219
|
+
chalk.dim(
|
|
220
|
+
" Run " +
|
|
221
|
+
chalk.white("npx install-glo ai") +
|
|
222
|
+
" to chat with an AI that knows Glo.\n"
|
|
223
|
+
)
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
// Restore cursor
|
|
227
|
+
if (isTTY) {
|
|
228
|
+
process.stdout.write("\x1B[?25h");
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
main().catch(() => {
|
|
233
|
+
// Restore cursor on error
|
|
234
|
+
process.stdout.write("\x1B[?25h");
|
|
235
|
+
process.exit(1);
|
|
236
|
+
});
|
package/glo-loop.mjs
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createInterface } from "node:readline/promises";
|
|
4
|
+
import { execSync } from "node:child_process";
|
|
5
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
6
|
+
import { join, relative } from "node:path";
|
|
7
|
+
import { generateText } from "ai";
|
|
8
|
+
import { createAnthropic } from "@ai-sdk/anthropic";
|
|
9
|
+
import { createOpenAI } from "@ai-sdk/openai";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
|
|
12
|
+
// ── Provider ───────────────────────────────────────────────────────────
|
|
13
|
+
|
|
14
|
+
function getModel() {
|
|
15
|
+
if (process.env.ANTHROPIC_API_KEY) {
|
|
16
|
+
const anthropic = createAnthropic();
|
|
17
|
+
return {
|
|
18
|
+
model: anthropic("claude-sonnet-4-20250514"),
|
|
19
|
+
label: "Claude (Anthropic)",
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
if (process.env.OPENAI_API_KEY) {
|
|
23
|
+
const openai = createOpenAI();
|
|
24
|
+
return { model: openai("gpt-4o-mini"), label: "GPT-4o-mini (OpenAI)" };
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// ── Web Vitals ─────────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
const VITALS = {
|
|
32
|
+
LCP: {
|
|
33
|
+
good: 2500,
|
|
34
|
+
unit: "ms",
|
|
35
|
+
name: "Largest Contentful Paint",
|
|
36
|
+
audit: "largest-contentful-paint",
|
|
37
|
+
},
|
|
38
|
+
FCP: {
|
|
39
|
+
good: 1800,
|
|
40
|
+
unit: "ms",
|
|
41
|
+
name: "First Contentful Paint",
|
|
42
|
+
audit: "first-contentful-paint",
|
|
43
|
+
},
|
|
44
|
+
CLS: {
|
|
45
|
+
good: 0.1,
|
|
46
|
+
unit: "",
|
|
47
|
+
name: "Cumulative Layout Shift",
|
|
48
|
+
audit: "cumulative-layout-shift",
|
|
49
|
+
},
|
|
50
|
+
TBT: {
|
|
51
|
+
good: 200,
|
|
52
|
+
unit: "ms",
|
|
53
|
+
name: "Total Blocking Time",
|
|
54
|
+
audit: "total-blocking-time",
|
|
55
|
+
},
|
|
56
|
+
SI: {
|
|
57
|
+
good: 3400,
|
|
58
|
+
unit: "ms",
|
|
59
|
+
name: "Speed Index",
|
|
60
|
+
audit: "speed-index",
|
|
61
|
+
},
|
|
62
|
+
TTFB: {
|
|
63
|
+
good: 800,
|
|
64
|
+
unit: "ms",
|
|
65
|
+
name: "Time to First Byte",
|
|
66
|
+
audit: "server-response-time",
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// ── Lighthouse ─────────────────────────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
function checkLighthouse() {
|
|
73
|
+
try {
|
|
74
|
+
execSync("npx -y lighthouse --version", { stdio: "pipe" });
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function runLighthouse(url) {
|
|
82
|
+
const cmd = [
|
|
83
|
+
"npx -y lighthouse",
|
|
84
|
+
`"${url}"`,
|
|
85
|
+
"--output=json",
|
|
86
|
+
'--chrome-flags="--headless --no-sandbox"',
|
|
87
|
+
"--only-categories=performance",
|
|
88
|
+
"--quiet",
|
|
89
|
+
].join(" ");
|
|
90
|
+
|
|
91
|
+
const result = execSync(cmd, {
|
|
92
|
+
maxBuffer: 100 * 1024 * 1024,
|
|
93
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
94
|
+
});
|
|
95
|
+
return JSON.parse(result.toString());
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function extractMetrics(report) {
|
|
99
|
+
const metrics = {};
|
|
100
|
+
for (const [key, info] of Object.entries(VITALS)) {
|
|
101
|
+
const audit = report.audits?.[info.audit];
|
|
102
|
+
if (audit) {
|
|
103
|
+
metrics[key] = {
|
|
104
|
+
value: audit.numericValue,
|
|
105
|
+
display: audit.displayValue,
|
|
106
|
+
score: audit.score,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
metrics.performanceScore = Math.round(
|
|
111
|
+
(report.categories?.performance?.score || 0) * 100
|
|
112
|
+
);
|
|
113
|
+
return metrics;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function extractDiagnostics(report) {
|
|
117
|
+
const relevant = [
|
|
118
|
+
"render-blocking-resources",
|
|
119
|
+
"unused-css-rules",
|
|
120
|
+
"unused-javascript",
|
|
121
|
+
"modern-image-formats",
|
|
122
|
+
"uses-optimized-images",
|
|
123
|
+
"uses-responsive-images",
|
|
124
|
+
"offscreen-images",
|
|
125
|
+
"unminified-css",
|
|
126
|
+
"unminified-javascript",
|
|
127
|
+
"dom-size",
|
|
128
|
+
"critical-request-chains",
|
|
129
|
+
"largest-contentful-paint-element",
|
|
130
|
+
"layout-shift-elements",
|
|
131
|
+
"long-tasks",
|
|
132
|
+
"mainthread-work-breakdown",
|
|
133
|
+
"bootup-time",
|
|
134
|
+
"font-display",
|
|
135
|
+
"uses-text-compression",
|
|
136
|
+
"duplicated-javascript",
|
|
137
|
+
"legacy-javascript",
|
|
138
|
+
"total-byte-weight",
|
|
139
|
+
];
|
|
140
|
+
|
|
141
|
+
const issues = [];
|
|
142
|
+
for (const id of relevant) {
|
|
143
|
+
const audit = report.audits?.[id];
|
|
144
|
+
if (audit && audit.score !== null && audit.score < 1) {
|
|
145
|
+
issues.push({
|
|
146
|
+
id,
|
|
147
|
+
title: audit.title,
|
|
148
|
+
displayValue: audit.displayValue || "",
|
|
149
|
+
score: audit.score,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return issues.sort((a, b) => a.score - b.score); // worst first
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ── Source File Discovery ──────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
function discoverPageFiles(projectRoot, route) {
|
|
159
|
+
const routePath = route === "/" ? "" : route.replace(/^\//, "");
|
|
160
|
+
const files = [];
|
|
161
|
+
|
|
162
|
+
const candidates = [
|
|
163
|
+
// Next.js App Router
|
|
164
|
+
join("app", routePath, "page.tsx"),
|
|
165
|
+
join("app", routePath, "page.jsx"),
|
|
166
|
+
join("app", routePath, "page.js"),
|
|
167
|
+
join("app", routePath, "layout.tsx"),
|
|
168
|
+
join("app", routePath, "layout.jsx"),
|
|
169
|
+
join("app", "layout.tsx"),
|
|
170
|
+
join("app", "layout.jsx"),
|
|
171
|
+
// src/app
|
|
172
|
+
join("src", "app", routePath, "page.tsx"),
|
|
173
|
+
join("src", "app", routePath, "layout.tsx"),
|
|
174
|
+
join("src", "app", "layout.tsx"),
|
|
175
|
+
// Next.js Pages Router
|
|
176
|
+
join("pages", routePath + ".tsx"),
|
|
177
|
+
join("pages", routePath + ".jsx"),
|
|
178
|
+
join("pages", routePath, "index.tsx"),
|
|
179
|
+
join("pages", routePath, "index.jsx"),
|
|
180
|
+
// Config files relevant to performance
|
|
181
|
+
"next.config.ts",
|
|
182
|
+
"next.config.js",
|
|
183
|
+
"next.config.mjs",
|
|
184
|
+
"vite.config.ts",
|
|
185
|
+
"vite.config.js",
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
for (const rel of candidates) {
|
|
189
|
+
const full = join(projectRoot, rel);
|
|
190
|
+
if (existsSync(full)) {
|
|
191
|
+
try {
|
|
192
|
+
const content = readFileSync(full, "utf8");
|
|
193
|
+
if (content.length < 15000) {
|
|
194
|
+
files.push({ path: rel, content });
|
|
195
|
+
} else {
|
|
196
|
+
files.push({
|
|
197
|
+
path: rel,
|
|
198
|
+
content: content.slice(0, 15000) + "\n// ... truncated",
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
} catch {}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Scan for component imports from the page file
|
|
206
|
+
if (files.length > 0) {
|
|
207
|
+
const pageFile = files[0];
|
|
208
|
+
const importRegex = /from\s+["']([./][^"']+)["']/g;
|
|
209
|
+
let match;
|
|
210
|
+
while ((match = importRegex.exec(pageFile.content)) !== null) {
|
|
211
|
+
const importPath = match[1];
|
|
212
|
+
const possiblePaths = [
|
|
213
|
+
importPath + ".tsx",
|
|
214
|
+
importPath + ".jsx",
|
|
215
|
+
importPath + ".ts",
|
|
216
|
+
importPath + ".js",
|
|
217
|
+
join(importPath, "index.tsx"),
|
|
218
|
+
];
|
|
219
|
+
for (const p of possiblePaths) {
|
|
220
|
+
const resolved = join(
|
|
221
|
+
projectRoot,
|
|
222
|
+
files[0].path.replace(/[^/]+$/, ""),
|
|
223
|
+
p
|
|
224
|
+
);
|
|
225
|
+
if (existsSync(resolved)) {
|
|
226
|
+
try {
|
|
227
|
+
const content = readFileSync(resolved, "utf8");
|
|
228
|
+
const relPath = relative(projectRoot, resolved);
|
|
229
|
+
if (!files.find((f) => f.path === relPath)) {
|
|
230
|
+
files.push({
|
|
231
|
+
path: relPath,
|
|
232
|
+
content:
|
|
233
|
+
content.length > 8000
|
|
234
|
+
? content.slice(0, 8000) + "\n// ... truncated"
|
|
235
|
+
: content,
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
} catch {}
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
return files;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ── Display Helpers ────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
function formatVital(key, value) {
|
|
251
|
+
const info = VITALS[key];
|
|
252
|
+
if (!info || value === undefined || value === null) return null;
|
|
253
|
+
const passed = key === "CLS" ? value <= info.good : value <= info.good;
|
|
254
|
+
const formatted =
|
|
255
|
+
info.unit === "ms" ? `${Math.round(value)}ms` : value.toFixed(3);
|
|
256
|
+
const icon = passed ? chalk.green("✓") : chalk.red("✗");
|
|
257
|
+
const color = passed ? chalk.green : chalk.red;
|
|
258
|
+
return (
|
|
259
|
+
` ${chalk.white(key.padEnd(6))}` +
|
|
260
|
+
`${color(formatted.padStart(9))}` +
|
|
261
|
+
` ${chalk.dim(`(good: <${info.good}${info.unit})`)} ${icon}`
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function printBanner() {
|
|
266
|
+
const lines = [
|
|
267
|
+
"",
|
|
268
|
+
chalk.hex("#FF8C00").bold(
|
|
269
|
+
" ┌─────────────────────────────────────────────────────┐"
|
|
270
|
+
),
|
|
271
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
272
|
+
chalk.hex("#FF8C00").bold(
|
|
273
|
+
" T H E G L O L O O P "
|
|
274
|
+
) +
|
|
275
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
276
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
277
|
+
chalk.dim(
|
|
278
|
+
" Web Vitals Optimization Engine "
|
|
279
|
+
) +
|
|
280
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
281
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
282
|
+
chalk.dim(
|
|
283
|
+
" "
|
|
284
|
+
) +
|
|
285
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
286
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
287
|
+
chalk.hex("#4AF626")(" G") +
|
|
288
|
+
chalk.white("ather → ") +
|
|
289
|
+
chalk.dim("run Lighthouse, extract metrics ") +
|
|
290
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
291
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
292
|
+
chalk.hex("#4AF626")(" L") +
|
|
293
|
+
chalk.white("everage → ") +
|
|
294
|
+
chalk.dim("AI analyzes code + diagnostics ") +
|
|
295
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
296
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
297
|
+
chalk.hex("#4AF626")(" O") +
|
|
298
|
+
chalk.white("perate → ") +
|
|
299
|
+
chalk.dim("apply fix, re-measure, repeat ") +
|
|
300
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
301
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
302
|
+
chalk.dim(
|
|
303
|
+
" "
|
|
304
|
+
) +
|
|
305
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
306
|
+
chalk.hex("#FF8C00").bold(" │") +
|
|
307
|
+
chalk.dim(" ↻ repeat until target met") +
|
|
308
|
+
chalk.dim(
|
|
309
|
+
" "
|
|
310
|
+
) +
|
|
311
|
+
chalk.hex("#FF8C00").bold("│"),
|
|
312
|
+
chalk.hex("#FF8C00").bold(
|
|
313
|
+
" └─────────────────────────────────────────────────────┘"
|
|
314
|
+
),
|
|
315
|
+
"",
|
|
316
|
+
];
|
|
317
|
+
console.log(lines.join("\n"));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ── AI Analysis ────────────────────────────────────────────────────────
|
|
321
|
+
|
|
322
|
+
async function analyzeWithAI(
|
|
323
|
+
model,
|
|
324
|
+
targetVital,
|
|
325
|
+
metrics,
|
|
326
|
+
diagnostics,
|
|
327
|
+
sourceFiles,
|
|
328
|
+
loopNumber,
|
|
329
|
+
previousSuggestions
|
|
330
|
+
) {
|
|
331
|
+
const vitalInfo = VITALS[targetVital];
|
|
332
|
+
|
|
333
|
+
const sourceContext = sourceFiles
|
|
334
|
+
.map((f) => `--- ${f.path} ---\n${f.content}`)
|
|
335
|
+
.join("\n\n");
|
|
336
|
+
|
|
337
|
+
const diagText = diagnostics
|
|
338
|
+
.slice(0, 10)
|
|
339
|
+
.map(
|
|
340
|
+
(d) =>
|
|
341
|
+
`- ${d.title}${d.displayValue ? ` (${d.displayValue})` : ""} [score: ${d.score}]`
|
|
342
|
+
)
|
|
343
|
+
.join("\n");
|
|
344
|
+
|
|
345
|
+
const metricsText = Object.entries(metrics)
|
|
346
|
+
.filter(([k]) => k !== "performanceScore")
|
|
347
|
+
.map(([k, v]) => {
|
|
348
|
+
const info = VITALS[k];
|
|
349
|
+
if (!info) return null;
|
|
350
|
+
const val = info.unit === "ms" ? `${Math.round(v.value)}ms` : v.value?.toFixed(3);
|
|
351
|
+
const status = v.value <= info.good ? "PASS" : "FAIL";
|
|
352
|
+
return ` ${k}: ${val} (good: <${info.good}${info.unit}) [${status}]`;
|
|
353
|
+
})
|
|
354
|
+
.filter(Boolean)
|
|
355
|
+
.join("\n");
|
|
356
|
+
|
|
357
|
+
const prevContext =
|
|
358
|
+
previousSuggestions.length > 0
|
|
359
|
+
? `\n\nPrevious suggestions already applied:\n${previousSuggestions.map((s, i) => `${i + 1}. ${s}`).join("\n")}\n\nDo NOT repeat these. Find the NEXT optimization.`
|
|
360
|
+
: "";
|
|
361
|
+
|
|
362
|
+
const prompt = `You are a frontend infrastructure engineer optimizing web performance.
|
|
363
|
+
|
|
364
|
+
## Current Metrics (Loop ${loopNumber})
|
|
365
|
+
Performance Score: ${metrics.performanceScore}/100
|
|
366
|
+
${metricsText}
|
|
367
|
+
|
|
368
|
+
## Target
|
|
369
|
+
Optimize ${targetVital} (${vitalInfo.name}): currently ${metrics[targetVital]?.value !== undefined ? (vitalInfo.unit === "ms" ? Math.round(metrics[targetVital].value) + "ms" : metrics[targetVital].value.toFixed(3)) : "unknown"}, target: <${vitalInfo.good}${vitalInfo.unit}
|
|
370
|
+
|
|
371
|
+
## Lighthouse Diagnostics (sorted by severity)
|
|
372
|
+
${diagText || "No failing diagnostics."}
|
|
373
|
+
|
|
374
|
+
## Source Files
|
|
375
|
+
${sourceContext || "No source files found for this route."}
|
|
376
|
+
${prevContext}
|
|
377
|
+
|
|
378
|
+
## Instructions
|
|
379
|
+
|
|
380
|
+
Respond in EXACTLY this format (no markdown fences):
|
|
381
|
+
|
|
382
|
+
DIAGNOSIS: One sentence explaining what is causing the ${targetVital} issue.
|
|
383
|
+
|
|
384
|
+
FILE: path/to/file.ext
|
|
385
|
+
LINE: approximate line number (or "new" if adding to config)
|
|
386
|
+
|
|
387
|
+
BEFORE:
|
|
388
|
+
<exact code to replace, or "N/A" if adding new code>
|
|
389
|
+
|
|
390
|
+
AFTER:
|
|
391
|
+
<optimized code>
|
|
392
|
+
|
|
393
|
+
WHY: One sentence on expected impact with estimated improvement in ${vitalInfo.unit || "score"}.
|
|
394
|
+
|
|
395
|
+
Keep the fix SURGICAL — one change per loop. Prefer high-impact, low-risk changes:
|
|
396
|
+
- Image optimization (priority, sizes, lazy/eager, format)
|
|
397
|
+
- Font loading (display: swap, preload, subset)
|
|
398
|
+
- Bundle optimization (dynamic imports, tree shaking)
|
|
399
|
+
- Render-blocking resources (async, defer, preload)
|
|
400
|
+
- Layout shift prevention (explicit dimensions, aspect-ratio)
|
|
401
|
+
- Server response (caching, compression, CDN)
|
|
402
|
+
- Component-level code splitting`;
|
|
403
|
+
|
|
404
|
+
const result = await generateText({
|
|
405
|
+
model,
|
|
406
|
+
prompt,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
return result.text;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ── Main ───────────────────────────────────────────────────────────────
|
|
413
|
+
|
|
414
|
+
async function main() {
|
|
415
|
+
printBanner();
|
|
416
|
+
|
|
417
|
+
const resolved = getModel();
|
|
418
|
+
if (!resolved) {
|
|
419
|
+
console.log(chalk.white(" Set an API key to power the AI analysis:\n"));
|
|
420
|
+
console.log(
|
|
421
|
+
chalk.hex("#4AF626")(" export ANTHROPIC_API_KEY=sk-ant-...") +
|
|
422
|
+
chalk.dim(" (recommended)")
|
|
423
|
+
);
|
|
424
|
+
console.log(chalk.hex("#4AF626")(" export OPENAI_API_KEY=sk-..."));
|
|
425
|
+
console.log(
|
|
426
|
+
chalk.dim("\n Then run: ") +
|
|
427
|
+
chalk.white("npm run glo-loop") +
|
|
428
|
+
"\n"
|
|
429
|
+
);
|
|
430
|
+
process.exit(1);
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
const { model, label } = resolved;
|
|
434
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
435
|
+
|
|
436
|
+
console.log(chalk.dim(` AI: ${label} via Vercel AI SDK\n`));
|
|
437
|
+
|
|
438
|
+
// ── Collect inputs ────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
const url = await rl.question(
|
|
441
|
+
chalk.hex("#FF8C00")(" Page URL ") +
|
|
442
|
+
chalk.dim("(default: http://localhost:3000): ")
|
|
443
|
+
);
|
|
444
|
+
const targetUrl = url.trim() || "http://localhost:3000";
|
|
445
|
+
|
|
446
|
+
// Extract route from URL for source file discovery
|
|
447
|
+
let route = "/";
|
|
448
|
+
try {
|
|
449
|
+
const parsed = new URL(targetUrl);
|
|
450
|
+
route = parsed.pathname;
|
|
451
|
+
} catch {}
|
|
452
|
+
|
|
453
|
+
console.log("");
|
|
454
|
+
console.log(chalk.white(" Available web vitals:"));
|
|
455
|
+
for (const [key, info] of Object.entries(VITALS)) {
|
|
456
|
+
console.log(
|
|
457
|
+
chalk.hex("#4AF626")(` ${key.padEnd(6)}`) +
|
|
458
|
+
chalk.dim(`${info.name} (good: <${info.good}${info.unit})`)
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
console.log("");
|
|
462
|
+
|
|
463
|
+
const vitalInput = await rl.question(
|
|
464
|
+
chalk.hex("#FF8C00")(" Target vital ") + chalk.dim("(default: LCP): ")
|
|
465
|
+
);
|
|
466
|
+
const targetVital = (vitalInput.trim().toUpperCase() || "LCP");
|
|
467
|
+
if (!VITALS[targetVital]) {
|
|
468
|
+
console.log(chalk.red(`\n Unknown vital: ${targetVital}`));
|
|
469
|
+
console.log(chalk.dim(` Choose from: ${Object.keys(VITALS).join(", ")}\n`));
|
|
470
|
+
rl.close();
|
|
471
|
+
process.exit(1);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const maxInput = await rl.question(
|
|
475
|
+
chalk.hex("#FF8C00")(" Max loops ") + chalk.dim("(default: 10): ")
|
|
476
|
+
);
|
|
477
|
+
const maxLoops = parseInt(maxInput.trim(), 10) || 10;
|
|
478
|
+
|
|
479
|
+
console.log("");
|
|
480
|
+
console.log(
|
|
481
|
+
chalk.hex("#FF8C00")(" Target: ") +
|
|
482
|
+
chalk.white(
|
|
483
|
+
`${targetVital} < ${VITALS[targetVital].good}${VITALS[targetVital].unit}`
|
|
484
|
+
) +
|
|
485
|
+
chalk.dim(` on ${targetUrl}`)
|
|
486
|
+
);
|
|
487
|
+
console.log(
|
|
488
|
+
chalk.hex("#FF8C00")(" Loops: ") + chalk.white(`${maxLoops} max`)
|
|
489
|
+
);
|
|
490
|
+
|
|
491
|
+
// Discover source files
|
|
492
|
+
const projectRoot = process.env.INIT_CWD || process.cwd();
|
|
493
|
+
const sourceFiles = discoverPageFiles(projectRoot, route);
|
|
494
|
+
if (sourceFiles.length > 0) {
|
|
495
|
+
console.log(
|
|
496
|
+
chalk.hex("#FF8C00")(" Files: ") +
|
|
497
|
+
chalk.dim(sourceFiles.map((f) => f.path).join(", "))
|
|
498
|
+
);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const previousSuggestions = [];
|
|
502
|
+
|
|
503
|
+
// ── The Loop ────────────────────────────────────────────────────
|
|
504
|
+
|
|
505
|
+
for (let loop = 1; loop <= maxLoops; loop++) {
|
|
506
|
+
console.log(
|
|
507
|
+
chalk.hex("#FF8C00")(
|
|
508
|
+
`\n ── Loop ${loop}/${maxLoops} ${"─".repeat(42)}`
|
|
509
|
+
)
|
|
510
|
+
);
|
|
511
|
+
|
|
512
|
+
// ── GATHER ──────────────────────────────────────────────────
|
|
513
|
+
|
|
514
|
+
console.log(chalk.hex("#4AF626").bold("\n GATHER"));
|
|
515
|
+
console.log(chalk.dim(" Running Lighthouse...\n"));
|
|
516
|
+
|
|
517
|
+
let report, metrics, diagnostics;
|
|
518
|
+
try {
|
|
519
|
+
report = runLighthouse(targetUrl);
|
|
520
|
+
metrics = extractMetrics(report);
|
|
521
|
+
diagnostics = extractDiagnostics(report);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
console.log(chalk.red(` Lighthouse failed: ${err.message}`));
|
|
524
|
+
console.log(chalk.dim(" Is the page running? Is Chrome installed?\n"));
|
|
525
|
+
const retry = await rl.question(
|
|
526
|
+
chalk.hex("#FF8C00")(" Retry? ") + chalk.dim("[y/n]: ")
|
|
527
|
+
);
|
|
528
|
+
if (retry.trim().toLowerCase() === "y") {
|
|
529
|
+
loop--;
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
break;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
console.log(
|
|
536
|
+
chalk.white(` Score `) +
|
|
537
|
+
chalk.bold(
|
|
538
|
+
metrics.performanceScore >= 90
|
|
539
|
+
? chalk.green(`${metrics.performanceScore}/100`)
|
|
540
|
+
: metrics.performanceScore >= 50
|
|
541
|
+
? chalk.yellow(`${metrics.performanceScore}/100`)
|
|
542
|
+
: chalk.red(`${metrics.performanceScore}/100`)
|
|
543
|
+
)
|
|
544
|
+
);
|
|
545
|
+
|
|
546
|
+
for (const key of Object.keys(VITALS)) {
|
|
547
|
+
const line = formatVital(key, metrics[key]?.value);
|
|
548
|
+
if (line) console.log(line);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
// Check if target is already met
|
|
552
|
+
const currentValue = metrics[targetVital]?.value;
|
|
553
|
+
if (currentValue !== undefined && currentValue <= VITALS[targetVital].good) {
|
|
554
|
+
console.log(
|
|
555
|
+
chalk.green.bold(
|
|
556
|
+
`\n Target met! ${targetVital} = ${VITALS[targetVital].unit === "ms" ? Math.round(currentValue) + "ms" : currentValue.toFixed(3)} (good: <${VITALS[targetVital].good}${VITALS[targetVital].unit})`
|
|
557
|
+
)
|
|
558
|
+
);
|
|
559
|
+
if (loop === 1) {
|
|
560
|
+
console.log(
|
|
561
|
+
chalk.dim(
|
|
562
|
+
" This page is already performing well for " + targetVital + "."
|
|
563
|
+
)
|
|
564
|
+
);
|
|
565
|
+
} else {
|
|
566
|
+
console.log(
|
|
567
|
+
chalk.dim(` Took ${loop - 1} optimization loop(s).`)
|
|
568
|
+
);
|
|
569
|
+
}
|
|
570
|
+
console.log(
|
|
571
|
+
chalk.dim(
|
|
572
|
+
"\n Want deeper optimization? " +
|
|
573
|
+
chalk.white.underline("intro.co/GonzaloMaldonado") +
|
|
574
|
+
"\n"
|
|
575
|
+
)
|
|
576
|
+
);
|
|
577
|
+
break;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
// ── LEVERAGE ─────────────────────────────────────────────────
|
|
581
|
+
|
|
582
|
+
console.log(chalk.hex("#4AF626").bold("\n LEVERAGE"));
|
|
583
|
+
console.log(chalk.dim(" Analyzing with AI...\n"));
|
|
584
|
+
|
|
585
|
+
let suggestion;
|
|
586
|
+
try {
|
|
587
|
+
suggestion = await analyzeWithAI(
|
|
588
|
+
model,
|
|
589
|
+
targetVital,
|
|
590
|
+
metrics,
|
|
591
|
+
diagnostics,
|
|
592
|
+
sourceFiles,
|
|
593
|
+
loop,
|
|
594
|
+
previousSuggestions
|
|
595
|
+
);
|
|
596
|
+
} catch (err) {
|
|
597
|
+
console.log(chalk.red(` AI analysis failed: ${err.message}\n`));
|
|
598
|
+
break;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
// Display the suggestion
|
|
602
|
+
for (const line of suggestion.split("\n")) {
|
|
603
|
+
if (
|
|
604
|
+
line.startsWith("DIAGNOSIS:") ||
|
|
605
|
+
line.startsWith("FILE:") ||
|
|
606
|
+
line.startsWith("LINE:") ||
|
|
607
|
+
line.startsWith("WHY:")
|
|
608
|
+
) {
|
|
609
|
+
const [label, ...rest] = line.split(":");
|
|
610
|
+
console.log(
|
|
611
|
+
chalk.hex("#FF8C00")(` ${label}:`) +
|
|
612
|
+
chalk.white(rest.join(":"))
|
|
613
|
+
);
|
|
614
|
+
} else if (line.startsWith("BEFORE:") || line.startsWith("AFTER:")) {
|
|
615
|
+
console.log(chalk.hex("#FF8C00")(` ${line}`));
|
|
616
|
+
} else {
|
|
617
|
+
console.log(chalk.hex("#89b4fa")(` ${line}`));
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// ── OPERATE ─────────────────────────────────────────────────
|
|
622
|
+
|
|
623
|
+
console.log(chalk.hex("#4AF626").bold("\n OPERATE"));
|
|
624
|
+
const action = await rl.question(
|
|
625
|
+
chalk.hex("#FF8C00")(" Apply this fix? ") +
|
|
626
|
+
chalk.dim("[y = apply / s = skip / q = quit]: ")
|
|
627
|
+
);
|
|
628
|
+
|
|
629
|
+
const choice = action.trim().toLowerCase();
|
|
630
|
+
if (choice === "q" || choice === "quit") {
|
|
631
|
+
console.log(chalk.dim("\n Loop ended by user.\n"));
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
if (choice === "y" || choice === "yes") {
|
|
636
|
+
previousSuggestions.push(suggestion.split("\n")[0]); // store diagnosis
|
|
637
|
+
console.log(
|
|
638
|
+
chalk.dim(
|
|
639
|
+
" Apply the change above to your code, then press Enter to re-measure."
|
|
640
|
+
)
|
|
641
|
+
);
|
|
642
|
+
await rl.question(chalk.dim(" Press Enter when ready..."));
|
|
643
|
+
// Re-read source files in case they changed
|
|
644
|
+
const updatedFiles = discoverPageFiles(projectRoot, route);
|
|
645
|
+
if (updatedFiles.length > 0) {
|
|
646
|
+
sourceFiles.length = 0;
|
|
647
|
+
sourceFiles.push(...updatedFiles);
|
|
648
|
+
}
|
|
649
|
+
} else {
|
|
650
|
+
previousSuggestions.push(`SKIPPED: ${suggestion.split("\n")[0]}`);
|
|
651
|
+
console.log(chalk.dim(" Skipped. Moving to next suggestion.\n"));
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
if (loop === maxLoops) {
|
|
655
|
+
console.log(
|
|
656
|
+
chalk.hex("#FF8C00")(`\n Reached max loops (${maxLoops}).`)
|
|
657
|
+
);
|
|
658
|
+
console.log(
|
|
659
|
+
chalk.dim(
|
|
660
|
+
" For deeper performance work: " +
|
|
661
|
+
chalk.white.underline("intro.co/GonzaloMaldonado") +
|
|
662
|
+
"\n"
|
|
663
|
+
)
|
|
664
|
+
);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
rl.close();
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
main().catch((err) => {
|
|
672
|
+
console.error(chalk.red(`\n Error: ${err.message}\n`));
|
|
673
|
+
process.exit(1);
|
|
674
|
+
});
|
package/index.mjs
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// install-glo CLI router
|
|
4
|
+
// npx install-glo → business card
|
|
5
|
+
// npx install-glo ai → glo-loop (web vitals optimizer)
|
|
6
|
+
// npx install-glo about → about-glo-loop
|
|
7
|
+
|
|
8
|
+
const cmd = process.argv[2];
|
|
9
|
+
|
|
10
|
+
if (cmd === "ai" || cmd === "loop") {
|
|
11
|
+
await import("./glo-loop.mjs");
|
|
12
|
+
} else if (cmd === "about") {
|
|
13
|
+
await import("./about.mjs");
|
|
14
|
+
} else {
|
|
15
|
+
await import("./card.mjs");
|
|
16
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "install-glo",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "GLO Loop — AI-powered web vitals optimization engine built with Vercel AI SDK",
|
|
5
|
+
"main": "index.mjs",
|
|
6
|
+
"bin": {
|
|
7
|
+
"install-glo": "index.mjs",
|
|
8
|
+
"glo": "index.mjs",
|
|
9
|
+
"glo-loop": "glo-loop.mjs",
|
|
10
|
+
"about-glo-loop": "about.mjs"
|
|
11
|
+
},
|
|
12
|
+
"scripts": {
|
|
13
|
+
"postinstall": "node postinstall.mjs"
|
|
14
|
+
},
|
|
15
|
+
"type": "module",
|
|
16
|
+
"keywords": [
|
|
17
|
+
"web-vitals",
|
|
18
|
+
"lighthouse",
|
|
19
|
+
"performance",
|
|
20
|
+
"optimization",
|
|
21
|
+
"ai-sdk",
|
|
22
|
+
"vercel-ai",
|
|
23
|
+
"lcp",
|
|
24
|
+
"cls",
|
|
25
|
+
"core-web-vitals",
|
|
26
|
+
"devtool",
|
|
27
|
+
"cli",
|
|
28
|
+
"gonzalo-maldonado",
|
|
29
|
+
"sanscourier"
|
|
30
|
+
],
|
|
31
|
+
"author": "Gonzalo Maldonado <glo@sanscourier.ai>",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"@ai-sdk/anthropic": "^3.0.63",
|
|
35
|
+
"@ai-sdk/openai": "^3.0.47",
|
|
36
|
+
"ai": "^6.0.134",
|
|
37
|
+
"boxen": "^8.0.1",
|
|
38
|
+
"chalk": "^5.4.1"
|
|
39
|
+
}
|
|
40
|
+
}
|
package/postinstall.mjs
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
|
|
6
|
+
// INIT_CWD is the directory where `npm install` was originally run.
|
|
7
|
+
// This ensures we modify the host project's package.json, not our own.
|
|
8
|
+
const projectRoot = process.env.INIT_CWD || process.cwd();
|
|
9
|
+
const pkgPath = resolve(projectRoot, "package.json");
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
const raw = readFileSync(pkgPath, "utf8");
|
|
13
|
+
const pkg = JSON.parse(raw);
|
|
14
|
+
|
|
15
|
+
// Don't modify our own package.json
|
|
16
|
+
if (pkg.name === "install-glo") process.exit(0);
|
|
17
|
+
|
|
18
|
+
let modified = false;
|
|
19
|
+
pkg.scripts = pkg.scripts || {};
|
|
20
|
+
|
|
21
|
+
if (!pkg.scripts["glo-loop"]) {
|
|
22
|
+
pkg.scripts["glo-loop"] = "glo-loop";
|
|
23
|
+
modified = true;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
if (!pkg.scripts["about-glo-loop"]) {
|
|
27
|
+
pkg.scripts["about-glo-loop"] = "about-glo-loop";
|
|
28
|
+
modified = true;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (modified) {
|
|
32
|
+
// Preserve original formatting (detect indent)
|
|
33
|
+
const indent = raw.match(/^(\s+)"/m)?.[1] || " ";
|
|
34
|
+
writeFileSync(pkgPath, JSON.stringify(pkg, null, indent) + "\n");
|
|
35
|
+
console.log(
|
|
36
|
+
"\n \x1b[38;2;255;140;0m✓\x1b[0m Added scripts to package.json:"
|
|
37
|
+
);
|
|
38
|
+
console.log(" \x1b[37mnpm run glo-loop\x1b[0m → Web vitals optimization loop");
|
|
39
|
+
console.log(" \x1b[37mnpm run about-glo-loop\x1b[0m → What is the GLO Loop?\n");
|
|
40
|
+
}
|
|
41
|
+
} catch {
|
|
42
|
+
// Silently fail — this is a postinstall hook, should never block install
|
|
43
|
+
}
|