laxy-verify 1.0.1 → 1.1.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/dist/auth.d.ts +6 -0
- package/dist/auth.js +186 -0
- package/dist/cli.js +78 -0
- package/dist/entitlement.d.ts +11 -0
- package/dist/entitlement.js +63 -0
- package/dist/grade.d.ts +2 -0
- package/dist/grade.js +5 -1
- package/dist/multi-viewport.d.ts +20 -0
- package/dist/multi-viewport.js +155 -0
- package/package.json +2 -2
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const LAXY_API_URL: string;
|
|
2
|
+
export declare function loadToken(): string | null;
|
|
3
|
+
export declare function saveToken(token: string, email: string, expiresInSec: number): void;
|
|
4
|
+
export declare function clearToken(): void;
|
|
5
|
+
export declare function whoami(): void;
|
|
6
|
+
export declare function login(emailArg?: string): Promise<void>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.LAXY_API_URL = void 0;
|
|
37
|
+
exports.loadToken = loadToken;
|
|
38
|
+
exports.saveToken = saveToken;
|
|
39
|
+
exports.clearToken = clearToken;
|
|
40
|
+
exports.whoami = whoami;
|
|
41
|
+
exports.login = login;
|
|
42
|
+
/**
|
|
43
|
+
* auth.ts — laxy-verify CLI 인증 토큰 저장/로드/삭제
|
|
44
|
+
*
|
|
45
|
+
* 토큰은 ~/.laxy/credentials.json 에 저장됩니다.
|
|
46
|
+
* 로그인: POST /api/cli-auth (이메일 + 비밀번호)
|
|
47
|
+
* 로그아웃: 로컬 파일 삭제
|
|
48
|
+
*/
|
|
49
|
+
const fs = __importStar(require("node:fs"));
|
|
50
|
+
const path = __importStar(require("node:path"));
|
|
51
|
+
const os = __importStar(require("node:os"));
|
|
52
|
+
const readline = __importStar(require("node:readline"));
|
|
53
|
+
const CREDENTIALS_DIR = path.join(os.homedir(), ".laxy");
|
|
54
|
+
const CREDENTIALS_PATH = path.join(CREDENTIALS_DIR, "credentials.json");
|
|
55
|
+
exports.LAXY_API_URL = process.env.LAXY_API_URL ?? "https://laxy.io";
|
|
56
|
+
function loadToken() {
|
|
57
|
+
// 환경변수 우선
|
|
58
|
+
const envToken = process.env.LAXY_TOKEN;
|
|
59
|
+
if (envToken)
|
|
60
|
+
return envToken;
|
|
61
|
+
try {
|
|
62
|
+
if (!fs.existsSync(CREDENTIALS_PATH))
|
|
63
|
+
return null;
|
|
64
|
+
const raw = fs.readFileSync(CREDENTIALS_PATH, "utf-8");
|
|
65
|
+
const creds = JSON.parse(raw);
|
|
66
|
+
if (!creds.token)
|
|
67
|
+
return null;
|
|
68
|
+
// 만료 여부 로컬 체크 (서버도 재확인하지만 UX 개선)
|
|
69
|
+
if (creds.expires_at) {
|
|
70
|
+
const exp = new Date(creds.expires_at).getTime();
|
|
71
|
+
if (exp < Date.now()) {
|
|
72
|
+
console.error(" ⚠️ 저장된 CLI 토큰이 만료되었습니다. 다시 로그인하세요: laxy-verify login");
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return creds.token;
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function saveToken(token, email, expiresInSec) {
|
|
83
|
+
if (!fs.existsSync(CREDENTIALS_DIR)) {
|
|
84
|
+
fs.mkdirSync(CREDENTIALS_DIR, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
const creds = {
|
|
87
|
+
token,
|
|
88
|
+
email,
|
|
89
|
+
saved_at: new Date().toISOString(),
|
|
90
|
+
expires_at: new Date(Date.now() + expiresInSec * 1000).toISOString(),
|
|
91
|
+
};
|
|
92
|
+
fs.writeFileSync(CREDENTIALS_PATH, JSON.stringify(creds, null, 2), { encoding: "utf-8", mode: 0o600 });
|
|
93
|
+
}
|
|
94
|
+
function clearToken() {
|
|
95
|
+
if (fs.existsSync(CREDENTIALS_PATH)) {
|
|
96
|
+
fs.rmSync(CREDENTIALS_PATH);
|
|
97
|
+
console.log(" 로그아웃 완료 — 인증 정보를 삭제했습니다.");
|
|
98
|
+
}
|
|
99
|
+
else {
|
|
100
|
+
console.log(" 저장된 인증 정보가 없습니다.");
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function whoami() {
|
|
104
|
+
const envToken = process.env.LAXY_TOKEN;
|
|
105
|
+
if (envToken) {
|
|
106
|
+
console.log(" 인증: 환경변수 LAXY_TOKEN 사용 중");
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
if (!fs.existsSync(CREDENTIALS_PATH)) {
|
|
111
|
+
console.log(" 로그인되지 않았습니다. laxy-verify login 명령으로 로그인하세요.");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const raw = fs.readFileSync(CREDENTIALS_PATH, "utf-8");
|
|
115
|
+
const creds = JSON.parse(raw);
|
|
116
|
+
const expDate = creds.expires_at ? new Date(creds.expires_at).toLocaleDateString("ko-KR") : "알 수 없음";
|
|
117
|
+
console.log(` 이메일: ${creds.email}`);
|
|
118
|
+
console.log(` 토큰 만료: ${expDate}`);
|
|
119
|
+
}
|
|
120
|
+
catch {
|
|
121
|
+
console.log(" 인증 정보를 읽을 수 없습니다.");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
/** readline으로 비밀번호 입력 (터미널 에코 숨김) */
|
|
125
|
+
function promptPassword(prompt) {
|
|
126
|
+
return new Promise((resolve) => {
|
|
127
|
+
const rl = readline.createInterface({
|
|
128
|
+
input: process.stdin,
|
|
129
|
+
output: process.stdout,
|
|
130
|
+
terminal: true,
|
|
131
|
+
});
|
|
132
|
+
process.stdout.write(prompt);
|
|
133
|
+
// 에코 끄기 (Unix 계열만, Windows는 readline이 처리)
|
|
134
|
+
if (process.stdin.isTTY) {
|
|
135
|
+
// @ts-ignore — private API
|
|
136
|
+
process.stdin._handle?.setRawMode?.(true);
|
|
137
|
+
}
|
|
138
|
+
let password = "";
|
|
139
|
+
process.stdin.on("data", (chunk) => {
|
|
140
|
+
const char = chunk.toString("utf-8");
|
|
141
|
+
if (char === "\n" || char === "\r" || char === "\u0004") {
|
|
142
|
+
process.stdout.write("\n");
|
|
143
|
+
rl.close();
|
|
144
|
+
resolve(password);
|
|
145
|
+
}
|
|
146
|
+
else if (char === "\u0008" || char === "\u007f") {
|
|
147
|
+
password = password.slice(0, -1);
|
|
148
|
+
}
|
|
149
|
+
else {
|
|
150
|
+
password += char;
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
async function login(emailArg) {
|
|
156
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
157
|
+
const askEmail = () => {
|
|
158
|
+
if (emailArg) {
|
|
159
|
+
rl.close();
|
|
160
|
+
return Promise.resolve(emailArg);
|
|
161
|
+
}
|
|
162
|
+
return new Promise((res) => rl.question(" 이메일: ", (ans) => { rl.close(); res(ans.trim()); }));
|
|
163
|
+
};
|
|
164
|
+
const email = await askEmail();
|
|
165
|
+
const password = await promptPassword(" 비밀번호: ");
|
|
166
|
+
console.log("\n 로그인 중...");
|
|
167
|
+
let res;
|
|
168
|
+
try {
|
|
169
|
+
res = await fetch(`${exports.LAXY_API_URL}/api/cli-auth`, {
|
|
170
|
+
method: "POST",
|
|
171
|
+
headers: { "Content-Type": "application/json" },
|
|
172
|
+
body: JSON.stringify({ email, password }),
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
catch (err) {
|
|
176
|
+
console.error(` ❌ 서버에 연결할 수 없습니다. (${exports.LAXY_API_URL})`);
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
const data = (await res.json());
|
|
180
|
+
if (!res.ok || !data.token) {
|
|
181
|
+
console.error(` ❌ ${data.error ?? "로그인에 실패했습니다."}`);
|
|
182
|
+
process.exit(1);
|
|
183
|
+
}
|
|
184
|
+
saveToken(data.token, email, data.expires_in ?? 30 * 24 * 60 * 60);
|
|
185
|
+
console.log(" ✅ 로그인 성공! laxy-verify . 을 실행해 검증을 시작하세요.");
|
|
186
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -46,6 +46,9 @@ const init_js_1 = require("./init.js");
|
|
|
46
46
|
const badge_js_1 = require("./badge.js");
|
|
47
47
|
const comment_js_1 = require("./comment.js");
|
|
48
48
|
const status_js_1 = require("./status.js");
|
|
49
|
+
const auth_js_1 = require("./auth.js");
|
|
50
|
+
const entitlement_js_1 = require("./entitlement.js");
|
|
51
|
+
const multi_viewport_js_1 = require("./multi-viewport.js");
|
|
49
52
|
function parseArgs() {
|
|
50
53
|
const raw = process.argv.slice(2);
|
|
51
54
|
// Single-pass: collect flags and first positional
|
|
@@ -77,8 +80,26 @@ function parseArgs() {
|
|
|
77
80
|
}
|
|
78
81
|
}
|
|
79
82
|
}
|
|
83
|
+
// 서브커맨드 처리: login [email], logout, whoami
|
|
84
|
+
let subcommand;
|
|
85
|
+
let subcommandArg;
|
|
86
|
+
if (projectDir === "login" || projectDir === "logout" || projectDir === "whoami") {
|
|
87
|
+
subcommand = projectDir;
|
|
88
|
+
projectDir = ".";
|
|
89
|
+
// login 뒤에 이메일이 올 수 있음
|
|
90
|
+
if (subcommand === "login" && raw.length > 0 && !raw[0].startsWith("-")) {
|
|
91
|
+
// already consumed via loop above if it was first; re-check flags
|
|
92
|
+
}
|
|
93
|
+
// 첫 번째 non-flag를 이메일로 취급
|
|
94
|
+
subcommandArg = flags["email"];
|
|
95
|
+
if (!subcommandArg) {
|
|
96
|
+
// login PSM@example.com 형태 — positional already captured in projectDir before reset
|
|
97
|
+
}
|
|
98
|
+
}
|
|
80
99
|
return {
|
|
81
100
|
projectDir: path.resolve(projectDir),
|
|
101
|
+
subcommand,
|
|
102
|
+
subcommandArg,
|
|
82
103
|
format: flags["format"] ?? "console",
|
|
83
104
|
ciMode: flags["ci"] !== undefined || process.env.CI === "true",
|
|
84
105
|
configPath: flags["config"],
|
|
@@ -86,6 +107,8 @@ function parseArgs() {
|
|
|
86
107
|
skipLighthouse: flags["skip-lighthouse"] !== undefined,
|
|
87
108
|
badge: flags["badge"] !== undefined,
|
|
88
109
|
init: flags["init"] !== undefined,
|
|
110
|
+
multiViewport: flags["multi-viewport"] !== undefined,
|
|
111
|
+
failureAnalysis: flags["failure-analysis"] !== undefined,
|
|
89
112
|
};
|
|
90
113
|
}
|
|
91
114
|
function writeResultFile(projectDir, result) {
|
|
@@ -128,6 +151,22 @@ function consoleOutput(result) {
|
|
|
128
151
|
}
|
|
129
152
|
async function run() {
|
|
130
153
|
const args = parseArgs();
|
|
154
|
+
// ── 서브커맨드 처리 ──────────────────────────────────────────────────────
|
|
155
|
+
if (args.subcommand === "login") {
|
|
156
|
+
await (0, auth_js_1.login)(args.subcommandArg);
|
|
157
|
+
process.exit(0);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (args.subcommand === "logout") {
|
|
161
|
+
(0, auth_js_1.clearToken)();
|
|
162
|
+
process.exit(0);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (args.subcommand === "whoami") {
|
|
166
|
+
(0, auth_js_1.whoami)();
|
|
167
|
+
process.exit(0);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
131
170
|
// --init
|
|
132
171
|
if (args.init) {
|
|
133
172
|
(0, init_js_1.runInit)(args.projectDir);
|
|
@@ -202,7 +241,31 @@ async function run() {
|
|
|
202
241
|
seo: config.thresholds.seo,
|
|
203
242
|
bestPractices: config.thresholds.bestPractices,
|
|
204
243
|
};
|
|
244
|
+
// ── 플랜 기능 조회 (토큰 없으면 Free 폴백) ──────────────────────────────
|
|
245
|
+
let entitlements = null;
|
|
246
|
+
try {
|
|
247
|
+
entitlements = await (0, entitlement_js_1.getEntitlements)();
|
|
248
|
+
(0, entitlement_js_1.printPlanBanner)(entitlements);
|
|
249
|
+
}
|
|
250
|
+
catch {
|
|
251
|
+
// 네트워크 오류 시 Free로 진행
|
|
252
|
+
}
|
|
253
|
+
const features = entitlements ?? {
|
|
254
|
+
plan: "free",
|
|
255
|
+
gold_grade: false,
|
|
256
|
+
lighthouse_runs_3: false,
|
|
257
|
+
verbose_failure: false,
|
|
258
|
+
multi_viewport: false,
|
|
259
|
+
failure_analysis: false,
|
|
260
|
+
fast_lane: false,
|
|
261
|
+
};
|
|
262
|
+
// Pro 이상이면 Lighthouse 3회 실행 (더 정밀)
|
|
263
|
+
if (features.lighthouse_runs_3 && config.lighthouse_runs < 3) {
|
|
264
|
+
config = { ...config, lighthouse_runs: 3 };
|
|
265
|
+
}
|
|
205
266
|
// Phase 2: Dev server + Lighthouse (only if build succeeded and not skipped)
|
|
267
|
+
let multiViewportScores = null;
|
|
268
|
+
let allViewportsOk = false;
|
|
206
269
|
if (buildResult.success && !args.skipLighthouse) {
|
|
207
270
|
let servePid;
|
|
208
271
|
try {
|
|
@@ -224,6 +287,20 @@ async function run() {
|
|
|
224
287
|
catch (lhErr) {
|
|
225
288
|
console.error(`Lighthouse error: ${lhErr instanceof Error ? lhErr.message : String(lhErr)}`);
|
|
226
289
|
}
|
|
290
|
+
// ── Pro+: 멀티 뷰포트 (dev server가 돌리는 중) ─────────────────────
|
|
291
|
+
if (args.multiViewport && !features.multi_viewport) {
|
|
292
|
+
console.log("\n ⚠️ --multi-viewport는 Pro+ 플랜 전용입니다. laxy-verify login 으로 로그인하세요.");
|
|
293
|
+
}
|
|
294
|
+
else if (features.multi_viewport) {
|
|
295
|
+
try {
|
|
296
|
+
multiViewportScores = await (0, multi_viewport_js_1.runMultiViewportLighthouse)(port);
|
|
297
|
+
(0, multi_viewport_js_1.printMultiViewportResults)(multiViewportScores, adjustedThresholds);
|
|
298
|
+
allViewportsOk = (0, multi_viewport_js_1.allViewportsPass)(multiViewportScores, adjustedThresholds);
|
|
299
|
+
}
|
|
300
|
+
catch (mvErr) {
|
|
301
|
+
console.error(`Multi-viewport error: ${mvErr instanceof Error ? mvErr.message : String(mvErr)}`);
|
|
302
|
+
}
|
|
303
|
+
}
|
|
227
304
|
}
|
|
228
305
|
catch (serveErr) {
|
|
229
306
|
console.error(`Dev server error: ${serveErr instanceof Error ? serveErr.message : String(serveErr)}`);
|
|
@@ -240,6 +317,7 @@ async function run() {
|
|
|
240
317
|
scores,
|
|
241
318
|
thresholds: adjustedThresholds,
|
|
242
319
|
failOn: config.fail_on,
|
|
320
|
+
goldEligible: features.gold_grade && allViewportsOk,
|
|
243
321
|
});
|
|
244
322
|
// Build result object
|
|
245
323
|
const resultObj = {
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface EntitlementFeatures {
|
|
2
|
+
plan: string;
|
|
3
|
+
gold_grade: boolean;
|
|
4
|
+
lighthouse_runs_3: boolean;
|
|
5
|
+
verbose_failure: boolean;
|
|
6
|
+
multi_viewport: boolean;
|
|
7
|
+
failure_analysis: boolean;
|
|
8
|
+
fast_lane: boolean;
|
|
9
|
+
}
|
|
10
|
+
export declare function getEntitlements(): Promise<EntitlementFeatures>;
|
|
11
|
+
export declare function printPlanBanner(features: EntitlementFeatures): void;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getEntitlements = getEntitlements;
|
|
4
|
+
exports.printPlanBanner = printPlanBanner;
|
|
5
|
+
/**
|
|
6
|
+
* entitlement.ts — Pro/Pro+ 기능 게이팅
|
|
7
|
+
*
|
|
8
|
+
* /api/v1/cli-entitlement 를 호출하여 사용자의 플랜 기능을 조회합니다.
|
|
9
|
+
* 결과는 5분간 메모리 캐시됩니다.
|
|
10
|
+
* 토큰이 없거나 서버 오류 시 Free 플랜으로 폴백합니다.
|
|
11
|
+
*/
|
|
12
|
+
const auth_js_1 = require("./auth.js");
|
|
13
|
+
const FREE_FEATURES = {
|
|
14
|
+
plan: "free",
|
|
15
|
+
gold_grade: false,
|
|
16
|
+
lighthouse_runs_3: false,
|
|
17
|
+
verbose_failure: false,
|
|
18
|
+
multi_viewport: false,
|
|
19
|
+
failure_analysis: false,
|
|
20
|
+
fast_lane: false,
|
|
21
|
+
};
|
|
22
|
+
let cache = null;
|
|
23
|
+
const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
|
|
24
|
+
async function getEntitlements() {
|
|
25
|
+
// 캐시 유효 시 반환
|
|
26
|
+
if (cache && Date.now() - cache.fetchedAt < CACHE_TTL_MS) {
|
|
27
|
+
return cache.features;
|
|
28
|
+
}
|
|
29
|
+
const token = (0, auth_js_1.loadToken)();
|
|
30
|
+
if (!token)
|
|
31
|
+
return FREE_FEATURES;
|
|
32
|
+
try {
|
|
33
|
+
const res = await fetch(`${auth_js_1.LAXY_API_URL}/api/v1/cli-entitlement`, {
|
|
34
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
35
|
+
});
|
|
36
|
+
if (!res.ok) {
|
|
37
|
+
if (res.status === 401) {
|
|
38
|
+
console.error(" ⚠️ CLI 토큰이 만료되었거나 유효하지 않습니다. laxy-verify login 으로 다시 로그인하세요.");
|
|
39
|
+
}
|
|
40
|
+
return FREE_FEATURES;
|
|
41
|
+
}
|
|
42
|
+
const features = (await res.json());
|
|
43
|
+
cache = { features, fetchedAt: Date.now() };
|
|
44
|
+
return features;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
// 네트워크 오류 → Free로 폴백 (오프라인에서도 기본 검증 가능)
|
|
48
|
+
return FREE_FEATURES;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function printPlanBanner(features) {
|
|
52
|
+
const planLabels = {
|
|
53
|
+
free: "Free",
|
|
54
|
+
pro: "Pro",
|
|
55
|
+
pro_plus: "Pro+",
|
|
56
|
+
team: "Team",
|
|
57
|
+
enterprise: "Enterprise",
|
|
58
|
+
};
|
|
59
|
+
const label = planLabels[features.plan] ?? features.plan;
|
|
60
|
+
if (features.plan !== "free") {
|
|
61
|
+
console.log(` 플랜: ${label}`);
|
|
62
|
+
}
|
|
63
|
+
}
|
package/dist/grade.d.ts
CHANGED
|
@@ -26,6 +26,8 @@ export declare function calculateGrade(options: {
|
|
|
26
26
|
bestPractices: number;
|
|
27
27
|
};
|
|
28
28
|
failOn: VerificationGrade;
|
|
29
|
+
/** Pro+ 전용: true면 Gold 달성 가능 (모든 뷰포트 Lighthouse 통과 시) */
|
|
30
|
+
goldEligible?: boolean;
|
|
29
31
|
}): GradeResult;
|
|
30
32
|
export declare function gradeToColor(grade: VerificationGrade): {
|
|
31
33
|
text: string;
|
package/dist/grade.js
CHANGED
|
@@ -18,11 +18,15 @@ function isWorseOrEqual(actual, threshold) {
|
|
|
18
18
|
return GRADE_ORDER.indexOf(actual) > GRADE_ORDER.indexOf(threshold);
|
|
19
19
|
}
|
|
20
20
|
function calculateGrade(options) {
|
|
21
|
-
const { buildSuccess, scores, thresholds, failOn } = options;
|
|
21
|
+
const { buildSuccess, scores, thresholds, failOn, goldEligible = false } = options;
|
|
22
22
|
let grade;
|
|
23
23
|
if (!buildSuccess) {
|
|
24
24
|
grade = "unverified";
|
|
25
25
|
}
|
|
26
|
+
else if (goldEligible && scores && getLighthousePass(scores, thresholds)) {
|
|
27
|
+
// Pro+: 모든 뷰포트 Lighthouse 통과 → Gold
|
|
28
|
+
grade = "gold";
|
|
29
|
+
}
|
|
26
30
|
else if (scores && getLighthousePass(scores, thresholds)) {
|
|
27
31
|
grade = "silver";
|
|
28
32
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { LighthouseScores } from "./grade.js";
|
|
2
|
+
export interface ViewportScores {
|
|
3
|
+
desktop: LighthouseScores | null;
|
|
4
|
+
tablet: LighthouseScores | null;
|
|
5
|
+
mobile: LighthouseScores | null;
|
|
6
|
+
}
|
|
7
|
+
export declare function runMultiViewportLighthouse(port: number): Promise<ViewportScores>;
|
|
8
|
+
export declare function printMultiViewportResults(scores: ViewportScores, thresholds: {
|
|
9
|
+
performance: number;
|
|
10
|
+
accessibility: number;
|
|
11
|
+
seo: number;
|
|
12
|
+
bestPractices: number;
|
|
13
|
+
}): void;
|
|
14
|
+
/** 모든 뷰포트가 임계치를 통과하면 true */
|
|
15
|
+
export declare function allViewportsPass(scores: ViewportScores, thresholds: {
|
|
16
|
+
performance: number;
|
|
17
|
+
accessibility: number;
|
|
18
|
+
seo: number;
|
|
19
|
+
bestPractices: number;
|
|
20
|
+
}): boolean;
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.runMultiViewportLighthouse = runMultiViewportLighthouse;
|
|
37
|
+
exports.printMultiViewportResults = printMultiViewportResults;
|
|
38
|
+
exports.allViewportsPass = allViewportsPass;
|
|
39
|
+
/**
|
|
40
|
+
* multi-viewport.ts — Pro+ 멀티 뷰포트 Lighthouse 실행
|
|
41
|
+
*
|
|
42
|
+
* 데스크톱·태블릿·모바일 3종 뷰포트에서 각각 Lighthouse를 실행하고
|
|
43
|
+
* 각 뷰포트의 점수를 반환합니다.
|
|
44
|
+
*/
|
|
45
|
+
const node_child_process_1 = require("node:child_process");
|
|
46
|
+
const fs = __importStar(require("node:fs"));
|
|
47
|
+
const path = __importStar(require("node:path"));
|
|
48
|
+
const node_module_1 = require("node:module");
|
|
49
|
+
const req = (0, node_module_1.createRequire)(__filename);
|
|
50
|
+
function resolveLhciBin() {
|
|
51
|
+
return req.resolve("@lhci/cli/src/cli.js");
|
|
52
|
+
}
|
|
53
|
+
const VIEWPORTS = [
|
|
54
|
+
{ name: "desktop", preset: "desktop" },
|
|
55
|
+
{ name: "tablet", preset: "desktop", screenEmulation: "1024x768" },
|
|
56
|
+
{ name: "mobile", preset: "perf" },
|
|
57
|
+
];
|
|
58
|
+
async function runLighthouseForViewport(port, viewport, outputDir) {
|
|
59
|
+
const lhciBin = resolveLhciBin();
|
|
60
|
+
const vpDir = path.join(outputDir, viewport.name);
|
|
61
|
+
if (!fs.existsSync(vpDir))
|
|
62
|
+
fs.mkdirSync(vpDir, { recursive: true });
|
|
63
|
+
const args = [
|
|
64
|
+
lhciBin,
|
|
65
|
+
"collect",
|
|
66
|
+
`--url=http://localhost:${port}`,
|
|
67
|
+
"--numberOfRuns=1",
|
|
68
|
+
`--outputDir=${vpDir}`,
|
|
69
|
+
`--preset=${viewport.preset}`,
|
|
70
|
+
];
|
|
71
|
+
const child = (0, node_child_process_1.spawn)("node", args, { shell: false, stdio: ["ignore", "pipe", "pipe"] });
|
|
72
|
+
child.stdout?.on("data", (chunk) => {
|
|
73
|
+
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
74
|
+
for (const l of lines)
|
|
75
|
+
console.log(` [lhci:${viewport.name}] ${l}`);
|
|
76
|
+
});
|
|
77
|
+
child.stderr?.on("data", (chunk) => {
|
|
78
|
+
const lines = chunk.toString().split("\n").filter(Boolean);
|
|
79
|
+
for (const l of lines)
|
|
80
|
+
console.error(` [lhci:${viewport.name}] ${l}`);
|
|
81
|
+
});
|
|
82
|
+
const code = await new Promise((res) => child.on("exit", (c) => res(c ?? 1)));
|
|
83
|
+
if (code !== 0)
|
|
84
|
+
return null;
|
|
85
|
+
// 결과 JSON 파싱
|
|
86
|
+
try {
|
|
87
|
+
const files = fs.readdirSync(vpDir).filter((f) => f.startsWith("lhr-") && f.endsWith(".json"));
|
|
88
|
+
if (files.length === 0)
|
|
89
|
+
return null;
|
|
90
|
+
const scores = files.map((f) => {
|
|
91
|
+
const lhr = JSON.parse(fs.readFileSync(path.join(vpDir, f), "utf-8"));
|
|
92
|
+
return {
|
|
93
|
+
performance: Math.round((lhr.categories.performance?.score ?? 0) * 100),
|
|
94
|
+
accessibility: Math.round((lhr.categories.accessibility?.score ?? 0) * 100),
|
|
95
|
+
seo: Math.round((lhr.categories.seo?.score ?? 0) * 100),
|
|
96
|
+
bestPractices: Math.round((lhr.categories["best-practices"]?.score ?? 0) * 100),
|
|
97
|
+
};
|
|
98
|
+
});
|
|
99
|
+
// 중앙값
|
|
100
|
+
const med = (arr) => {
|
|
101
|
+
const s = [...arr].sort((a, b) => a - b);
|
|
102
|
+
const m = Math.floor(s.length / 2);
|
|
103
|
+
return s.length % 2 !== 0 ? s[m] : (s[m - 1] + s[m]) / 2;
|
|
104
|
+
};
|
|
105
|
+
return {
|
|
106
|
+
performance: med(scores.map((s) => s.performance)),
|
|
107
|
+
accessibility: med(scores.map((s) => s.accessibility)),
|
|
108
|
+
seo: med(scores.map((s) => s.seo)),
|
|
109
|
+
bestPractices: med(scores.map((s) => s.bestPractices)),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
async function runMultiViewportLighthouse(port) {
|
|
117
|
+
console.log("\n [Pro+] 멀티 뷰포트 Lighthouse 실행 중 (데스크톱 / 태블릿 / 모바일)...");
|
|
118
|
+
const outputDir = ".lighthouseci-mvp";
|
|
119
|
+
const results = { desktop: null, tablet: null, mobile: null };
|
|
120
|
+
for (const vp of VIEWPORTS) {
|
|
121
|
+
console.log(`\n 뷰포트: ${vp.name}`);
|
|
122
|
+
results[vp.name] = await runLighthouseForViewport(port, vp, outputDir);
|
|
123
|
+
}
|
|
124
|
+
return results;
|
|
125
|
+
}
|
|
126
|
+
function printMultiViewportResults(scores, thresholds) {
|
|
127
|
+
const check = (passed) => (passed ? "✅" : "❌");
|
|
128
|
+
const labels = ["desktop", "tablet", "mobile"];
|
|
129
|
+
const vpLabel = { desktop: "데스크톱", tablet: "태블릿 ", mobile: "모바일 " };
|
|
130
|
+
console.log("\n [Pro+] 멀티 뷰포트 결과:");
|
|
131
|
+
for (const vp of labels) {
|
|
132
|
+
const s = scores[vp];
|
|
133
|
+
if (!s) {
|
|
134
|
+
console.log(` ${vpLabel[vp]}: 실행 실패`);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const all = s.performance >= thresholds.performance &&
|
|
138
|
+
s.accessibility >= thresholds.accessibility &&
|
|
139
|
+
s.seo >= thresholds.seo &&
|
|
140
|
+
s.bestPractices >= thresholds.bestPractices;
|
|
141
|
+
console.log(` ${vpLabel[vp]}: P=${s.performance} A=${s.accessibility} SEO=${s.seo} BP=${s.bestPractices} ${check(all)}`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
/** 모든 뷰포트가 임계치를 통과하면 true */
|
|
145
|
+
function allViewportsPass(scores, thresholds) {
|
|
146
|
+
return ["desktop", "tablet", "mobile"].every((vp) => {
|
|
147
|
+
const s = scores[vp];
|
|
148
|
+
if (!s)
|
|
149
|
+
return false;
|
|
150
|
+
return (s.performance >= thresholds.performance &&
|
|
151
|
+
s.accessibility >= thresholds.accessibility &&
|
|
152
|
+
s.seo >= thresholds.seo &&
|
|
153
|
+
s.bestPractices >= thresholds.bestPractices);
|
|
154
|
+
});
|
|
155
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "laxy-verify",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Frontend quality gate: build + Lighthouse verification",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"bin": {
|
|
7
|
-
"laxy-verify": "
|
|
7
|
+
"laxy-verify": "dist/cli.js"
|
|
8
8
|
},
|
|
9
9
|
"files": [
|
|
10
10
|
"dist/"
|