guardrail-ship 1.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/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mock-implementation.d.ts +1 -0
- package/dist/mock-implementation.d.ts.map +1 -0
- package/dist/mock-implementation.js +2 -0
- package/dist/mock-implementation.js.map +1 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.d.ts +5 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.d.ts.map +1 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.js +92 -0
- package/dist/mockproof/__tests__/import-graph-scanner.test.js.map +1 -0
- package/dist/mockproof/import-graph-scanner.d.ts +93 -0
- package/dist/mockproof/import-graph-scanner.d.ts.map +1 -0
- package/dist/mockproof/import-graph-scanner.js +411 -0
- package/dist/mockproof/import-graph-scanner.js.map +1 -0
- package/dist/mockproof/index.d.ts +10 -0
- package/dist/mockproof/index.d.ts.map +1 -0
- package/dist/mockproof/index.js +10 -0
- package/dist/mockproof/index.js.map +1 -0
- package/dist/reality-mode/auth-enforcer.d.ts +13 -0
- package/dist/reality-mode/auth-enforcer.d.ts.map +1 -0
- package/dist/reality-mode/auth-enforcer.js +90 -0
- package/dist/reality-mode/auth-enforcer.js.map +1 -0
- package/dist/reality-mode/explorer/critical-flows.d.ts +71 -0
- package/dist/reality-mode/explorer/critical-flows.d.ts.map +1 -0
- package/dist/reality-mode/explorer/critical-flows.js +463 -0
- package/dist/reality-mode/explorer/critical-flows.js.map +1 -0
- package/dist/reality-mode/explorer/flow-parser.d.ts +52 -0
- package/dist/reality-mode/explorer/flow-parser.d.ts.map +1 -0
- package/dist/reality-mode/explorer/flow-parser.js +250 -0
- package/dist/reality-mode/explorer/flow-parser.js.map +1 -0
- package/dist/reality-mode/explorer/index.d.ts +11 -0
- package/dist/reality-mode/explorer/index.d.ts.map +1 -0
- package/dist/reality-mode/explorer/index.js +11 -0
- package/dist/reality-mode/explorer/index.js.map +1 -0
- package/dist/reality-mode/explorer/runtime-explorer.d.ts +35 -0
- package/dist/reality-mode/explorer/runtime-explorer.d.ts.map +1 -0
- package/dist/reality-mode/explorer/runtime-explorer.js +688 -0
- package/dist/reality-mode/explorer/runtime-explorer.js.map +1 -0
- package/dist/reality-mode/explorer/surface-discovery.d.ts +60 -0
- package/dist/reality-mode/explorer/surface-discovery.d.ts.map +1 -0
- package/dist/reality-mode/explorer/surface-discovery.js +357 -0
- package/dist/reality-mode/explorer/surface-discovery.js.map +1 -0
- package/dist/reality-mode/explorer/types.d.ts +275 -0
- package/dist/reality-mode/explorer/types.d.ts.map +1 -0
- package/dist/reality-mode/explorer/types.js +8 -0
- package/dist/reality-mode/explorer/types.js.map +1 -0
- package/dist/reality-mode/fake-success-detector.d.ts +10 -0
- package/dist/reality-mode/fake-success-detector.d.ts.map +1 -0
- package/dist/reality-mode/fake-success-detector.js +76 -0
- package/dist/reality-mode/fake-success-detector.js.map +1 -0
- package/dist/reality-mode/index.d.ts +14 -0
- package/dist/reality-mode/index.d.ts.map +1 -0
- package/dist/reality-mode/index.js +14 -0
- package/dist/reality-mode/index.js.map +1 -0
- package/dist/reality-mode/reality-scanner.d.ts +48 -0
- package/dist/reality-mode/reality-scanner.d.ts.map +1 -0
- package/dist/reality-mode/reality-scanner.js +516 -0
- package/dist/reality-mode/reality-scanner.js.map +1 -0
- package/dist/reality-mode/report-generator.d.ts +11 -0
- package/dist/reality-mode/report-generator.d.ts.map +1 -0
- package/dist/reality-mode/report-generator.js +233 -0
- package/dist/reality-mode/report-generator.js.map +1 -0
- package/dist/reality-mode/traffic-classifier.d.ts +14 -0
- package/dist/reality-mode/traffic-classifier.d.ts.map +1 -0
- package/dist/reality-mode/traffic-classifier.js +131 -0
- package/dist/reality-mode/traffic-classifier.js.map +1 -0
- package/dist/reality-mode/types.d.ts +90 -0
- package/dist/reality-mode/types.d.ts.map +1 -0
- package/dist/reality-mode/types.js +2 -0
- package/dist/reality-mode/types.js.map +1 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.d.ts +5 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.d.ts.map +1 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.js +146 -0
- package/dist/ship-badge/__tests__/ship-badge-generator.test.js.map +1 -0
- package/dist/ship-badge/index.d.ts +9 -0
- package/dist/ship-badge/index.d.ts.map +1 -0
- package/dist/ship-badge/index.js +9 -0
- package/dist/ship-badge/index.js.map +1 -0
- package/dist/ship-badge/ship-badge-generator.d.ts +136 -0
- package/dist/ship-badge/ship-badge-generator.d.ts.map +1 -0
- package/dist/ship-badge/ship-badge-generator.js +681 -0
- package/dist/ship-badge/ship-badge-generator.js.map +1 -0
- package/package.json +20 -0
- package/src/index.ts +7 -0
- package/src/mock-implementation.ts +0 -0
- package/src/mockproof/__tests__/import-graph-scanner.test.ts +115 -0
- package/src/mockproof/import-graph-scanner.d.ts +93 -0
- package/src/mockproof/import-graph-scanner.d.ts.map +1 -0
- package/src/mockproof/import-graph-scanner.js +482 -0
- package/src/mockproof/import-graph-scanner.ts +540 -0
- package/src/mockproof/index.ts +18 -0
- package/src/reality-mode/auth-enforcer.ts +97 -0
- package/src/reality-mode/explorer/critical-flows.ts +504 -0
- package/src/reality-mode/explorer/flow-parser.ts +293 -0
- package/src/reality-mode/explorer/index.ts +22 -0
- package/src/reality-mode/explorer/runtime-explorer.ts +715 -0
- package/src/reality-mode/explorer/surface-discovery.ts +498 -0
- package/src/reality-mode/explorer/templates/example-flows/auth-flow.yaml +41 -0
- package/src/reality-mode/explorer/templates/example-flows/checkout-flow.yaml +66 -0
- package/src/reality-mode/explorer/templates/example-flows/contact-form.yaml +43 -0
- package/src/reality-mode/explorer/templates/github-action.yml +132 -0
- package/src/reality-mode/explorer/types.ts +356 -0
- package/src/reality-mode/fake-success-detector.ts +89 -0
- package/src/reality-mode/index.ts +19 -0
- package/src/reality-mode/reality-scanner.d.ts +123 -0
- package/src/reality-mode/reality-scanner.d.ts.map +1 -0
- package/src/reality-mode/reality-scanner.js +526 -0
- package/src/reality-mode/reality-scanner.ts +576 -0
- package/src/reality-mode/report-generator.ts +253 -0
- package/src/reality-mode/traffic-classifier.ts +169 -0
- package/src/reality-mode/types.ts +95 -0
- package/src/ship-badge/__tests__/ship-badge-generator.test.ts +162 -0
- package/src/ship-badge/index.ts +16 -0
- package/src/ship-badge/ship-badge-generator.d.ts +136 -0
- package/src/ship-badge/ship-badge-generator.d.ts.map +1 -0
- package/src/ship-badge/ship-badge-generator.js +779 -0
- package/src/ship-badge/ship-badge-generator.ts +873 -0
|
@@ -0,0 +1,779 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Ship Badge Generator
|
|
4
|
+
*
|
|
5
|
+
* "One-click shareable proof that your app is real."
|
|
6
|
+
*
|
|
7
|
+
* Generates badges + hosted permalinks for:
|
|
8
|
+
* ✅ No Mock Data Detected
|
|
9
|
+
* ✅ No Localhost/Ngrok
|
|
10
|
+
* ✅ All required env vars present
|
|
11
|
+
* ✅ Billing not simulated
|
|
12
|
+
* ✅ DB is real
|
|
13
|
+
* ✅ OAuth callbacks not localhost
|
|
14
|
+
*
|
|
15
|
+
* Vibecoders slap this on README / landing page / Product Hunt for social proof.
|
|
16
|
+
*/
|
|
17
|
+
var __createBinding =
|
|
18
|
+
(this && this.__createBinding) ||
|
|
19
|
+
(Object.create
|
|
20
|
+
? function (o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
23
|
+
if (
|
|
24
|
+
!desc ||
|
|
25
|
+
("get" in desc ? !m.__esModule : desc.writable || desc.configurable)
|
|
26
|
+
) {
|
|
27
|
+
desc = {
|
|
28
|
+
enumerable: true,
|
|
29
|
+
get: function () {
|
|
30
|
+
return m[k];
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
Object.defineProperty(o, k2, desc);
|
|
35
|
+
}
|
|
36
|
+
: function (o, m, k, k2) {
|
|
37
|
+
if (k2 === undefined) k2 = k;
|
|
38
|
+
o[k2] = m[k];
|
|
39
|
+
});
|
|
40
|
+
var __setModuleDefault =
|
|
41
|
+
(this && this.__setModuleDefault) ||
|
|
42
|
+
(Object.create
|
|
43
|
+
? function (o, v) {
|
|
44
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
45
|
+
}
|
|
46
|
+
: function (o, v) {
|
|
47
|
+
o["default"] = v;
|
|
48
|
+
});
|
|
49
|
+
var __importStar =
|
|
50
|
+
(this && this.__importStar) ||
|
|
51
|
+
(function () {
|
|
52
|
+
var ownKeys = function (o) {
|
|
53
|
+
ownKeys =
|
|
54
|
+
Object.getOwnPropertyNames ||
|
|
55
|
+
function (o) {
|
|
56
|
+
var ar = [];
|
|
57
|
+
for (var k in o)
|
|
58
|
+
if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
59
|
+
return ar;
|
|
60
|
+
};
|
|
61
|
+
return ownKeys(o);
|
|
62
|
+
};
|
|
63
|
+
return function (mod) {
|
|
64
|
+
if (mod && mod.__esModule) return mod;
|
|
65
|
+
var result = {};
|
|
66
|
+
if (mod != null)
|
|
67
|
+
for (var k = ownKeys(mod), i = 0; i < k.length; i++)
|
|
68
|
+
if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
69
|
+
__setModuleDefault(result, mod);
|
|
70
|
+
return result;
|
|
71
|
+
};
|
|
72
|
+
})();
|
|
73
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
74
|
+
exports.shipBadgeGenerator = exports.ShipBadgeGenerator = void 0;
|
|
75
|
+
const fs = __importStar(require("fs"));
|
|
76
|
+
const path = __importStar(require("path"));
|
|
77
|
+
const crypto = __importStar(require("crypto"));
|
|
78
|
+
const BADGE_COLORS = {
|
|
79
|
+
pass: "#4ade80", // Green
|
|
80
|
+
fail: "#f87171", // Red
|
|
81
|
+
warning: "#fbbf24", // Yellow
|
|
82
|
+
skip: "#9ca3af", // Gray
|
|
83
|
+
ship: "#22c55e", // Bright green
|
|
84
|
+
noship: "#ef4444", // Bright red
|
|
85
|
+
};
|
|
86
|
+
class ShipBadgeGenerator {
|
|
87
|
+
/**
|
|
88
|
+
* Run all ship checks and generate badges
|
|
89
|
+
*/
|
|
90
|
+
async generateShipBadge(config) {
|
|
91
|
+
const projectName = config.projectName || path.basename(config.projectPath);
|
|
92
|
+
const projectId = this.generateProjectId(config.projectPath);
|
|
93
|
+
// Run all checks
|
|
94
|
+
const checks = await this.runAllChecks(config.projectPath);
|
|
95
|
+
// Calculate verdict
|
|
96
|
+
const { verdict, score } = this.calculateVerdict(checks);
|
|
97
|
+
// Generate badges
|
|
98
|
+
const badges = this.generateAllBadges(checks, verdict, score);
|
|
99
|
+
// Generate permalink (would be hosted on Guardrail servers in production)
|
|
100
|
+
const permalink = `https://Guardrail.dev/badge/${projectId}`;
|
|
101
|
+
const embedCode = this.generateEmbedCode(projectId, verdict, projectName);
|
|
102
|
+
// Save badges if output dir specified
|
|
103
|
+
if (config.outputDir) {
|
|
104
|
+
await this.saveBadges(badges, config.outputDir);
|
|
105
|
+
}
|
|
106
|
+
const result = {
|
|
107
|
+
projectId,
|
|
108
|
+
projectName,
|
|
109
|
+
verdict,
|
|
110
|
+
score,
|
|
111
|
+
checks,
|
|
112
|
+
badges,
|
|
113
|
+
timestamp: new Date().toISOString(),
|
|
114
|
+
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(), // 7 days
|
|
115
|
+
permalink,
|
|
116
|
+
embedCode,
|
|
117
|
+
};
|
|
118
|
+
// Save result JSON
|
|
119
|
+
if (config.outputDir) {
|
|
120
|
+
await fs.promises.writeFile(
|
|
121
|
+
path.join(config.outputDir, "ship-badge-result.json"),
|
|
122
|
+
JSON.stringify(result, null, 2),
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return result;
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Run all ship-worthiness checks
|
|
129
|
+
*/
|
|
130
|
+
async runAllChecks(projectPath) {
|
|
131
|
+
const checks = [];
|
|
132
|
+
// 1. No Mock Data
|
|
133
|
+
checks.push(await this.checkNoMockData(projectPath));
|
|
134
|
+
// 2. No Localhost/Ngrok
|
|
135
|
+
checks.push(await this.checkNoLocalhost(projectPath));
|
|
136
|
+
// 3. Env Vars Present
|
|
137
|
+
checks.push(await this.checkEnvVars(projectPath));
|
|
138
|
+
// 4. Real Billing (not simulated)
|
|
139
|
+
checks.push(await this.checkRealBilling(projectPath));
|
|
140
|
+
// 5. Real Database
|
|
141
|
+
checks.push(await this.checkRealDatabase(projectPath));
|
|
142
|
+
// 6. OAuth Callbacks
|
|
143
|
+
checks.push(await this.checkOAuthCallbacks(projectPath));
|
|
144
|
+
return checks;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Check for mock data patterns
|
|
148
|
+
*/
|
|
149
|
+
async checkNoMockData(projectPath) {
|
|
150
|
+
const patterns = [
|
|
151
|
+
/MockProvider/g,
|
|
152
|
+
/useMock\(/g,
|
|
153
|
+
/mock-context/g,
|
|
154
|
+
/const\s+mock\w*\s*=/gi,
|
|
155
|
+
/lorem\s+ipsum/gi,
|
|
156
|
+
/john\.doe|jane\.doe/gi,
|
|
157
|
+
/user@example\.com/gi,
|
|
158
|
+
];
|
|
159
|
+
const issues = [];
|
|
160
|
+
const files = await this.findSourceFiles(projectPath);
|
|
161
|
+
for (const file of files.slice(0, 100)) {
|
|
162
|
+
// Limit for performance
|
|
163
|
+
try {
|
|
164
|
+
const content = await fs.promises.readFile(file, "utf-8");
|
|
165
|
+
const relativePath = path.relative(projectPath, file);
|
|
166
|
+
// Skip test files
|
|
167
|
+
if (this.isTestFile(relativePath)) continue;
|
|
168
|
+
for (const pattern of patterns) {
|
|
169
|
+
pattern.lastIndex = 0;
|
|
170
|
+
if (pattern.test(content)) {
|
|
171
|
+
issues.push(`${relativePath}: ${pattern.source}`);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
} catch (e) {
|
|
176
|
+
// Skip unreadable files
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return {
|
|
180
|
+
id: "no-mock-data",
|
|
181
|
+
name: "No Mock Data Detected",
|
|
182
|
+
shortName: "Mock Data",
|
|
183
|
+
status: issues.length === 0 ? "pass" : "fail",
|
|
184
|
+
message:
|
|
185
|
+
issues.length === 0
|
|
186
|
+
? "No mock data patterns found in production code"
|
|
187
|
+
: `Found ${issues.length} mock data patterns`,
|
|
188
|
+
details: issues.slice(0, 5),
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Check for localhost/ngrok URLs
|
|
193
|
+
*/
|
|
194
|
+
async checkNoLocalhost(projectPath) {
|
|
195
|
+
const patterns = [
|
|
196
|
+
/localhost:\d+/g,
|
|
197
|
+
/127\.0\.0\.1:\d+/g,
|
|
198
|
+
/\.ngrok\.io/g,
|
|
199
|
+
/\.ngrok-free\.app/g,
|
|
200
|
+
/jsonplaceholder\.typicode\.com/g,
|
|
201
|
+
];
|
|
202
|
+
const issues = [];
|
|
203
|
+
const configFiles = [
|
|
204
|
+
".env",
|
|
205
|
+
".env.production",
|
|
206
|
+
"next.config.js",
|
|
207
|
+
"next.config.mjs",
|
|
208
|
+
"vite.config.ts",
|
|
209
|
+
"vite.config.js",
|
|
210
|
+
"src/config/api.ts",
|
|
211
|
+
"src/lib/api.ts",
|
|
212
|
+
];
|
|
213
|
+
for (const configFile of configFiles) {
|
|
214
|
+
const filePath = path.join(projectPath, configFile);
|
|
215
|
+
if (fs.existsSync(filePath)) {
|
|
216
|
+
try {
|
|
217
|
+
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
218
|
+
for (const pattern of patterns) {
|
|
219
|
+
pattern.lastIndex = 0;
|
|
220
|
+
const matches = content.match(pattern);
|
|
221
|
+
if (matches) {
|
|
222
|
+
issues.push(`${configFile}: ${matches[0]}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
} catch (e) {
|
|
226
|
+
// Skip
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return {
|
|
231
|
+
id: "no-localhost",
|
|
232
|
+
name: "No Localhost/Ngrok",
|
|
233
|
+
shortName: "Real URLs",
|
|
234
|
+
status: issues.length === 0 ? "pass" : "fail",
|
|
235
|
+
message:
|
|
236
|
+
issues.length === 0
|
|
237
|
+
? "No localhost or temporary URLs in config"
|
|
238
|
+
: `Found ${issues.length} localhost/ngrok URLs`,
|
|
239
|
+
details: issues,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Check for required environment variables
|
|
244
|
+
*/
|
|
245
|
+
async checkEnvVars(projectPath) {
|
|
246
|
+
const requiredVars = [
|
|
247
|
+
"DATABASE_URL",
|
|
248
|
+
"API_URL",
|
|
249
|
+
"NEXTAUTH_URL",
|
|
250
|
+
"NEXTAUTH_SECRET",
|
|
251
|
+
];
|
|
252
|
+
const envPath = path.join(projectPath, ".env");
|
|
253
|
+
const envProdPath = path.join(projectPath, ".env.production");
|
|
254
|
+
let envContent = "";
|
|
255
|
+
if (fs.existsSync(envProdPath)) {
|
|
256
|
+
envContent = await fs.promises.readFile(envProdPath, "utf-8");
|
|
257
|
+
} else if (fs.existsSync(envPath)) {
|
|
258
|
+
envContent = await fs.promises.readFile(envPath, "utf-8");
|
|
259
|
+
}
|
|
260
|
+
// Also check .env.example to see what's expected
|
|
261
|
+
const examplePath = path.join(projectPath, ".env.example");
|
|
262
|
+
let expectedVars = [];
|
|
263
|
+
if (fs.existsSync(examplePath)) {
|
|
264
|
+
const exampleContent = await fs.promises.readFile(examplePath, "utf-8");
|
|
265
|
+
expectedVars = exampleContent
|
|
266
|
+
.split("\n")
|
|
267
|
+
.filter((line) => line.includes("=") && !line.startsWith("#"))
|
|
268
|
+
.map((line) => line.split("=")[0].trim());
|
|
269
|
+
}
|
|
270
|
+
const missing = [];
|
|
271
|
+
const varsToCheck = expectedVars.length > 0 ? expectedVars : requiredVars;
|
|
272
|
+
for (const varName of varsToCheck) {
|
|
273
|
+
const regex = new RegExp(`^${varName}=.+`, "m");
|
|
274
|
+
if (!regex.test(envContent)) {
|
|
275
|
+
missing.push(varName);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const hasEnvFile = fs.existsSync(envPath) || fs.existsSync(envProdPath);
|
|
279
|
+
return {
|
|
280
|
+
id: "env-vars",
|
|
281
|
+
name: "Environment Variables Present",
|
|
282
|
+
shortName: "Env Vars",
|
|
283
|
+
status: !hasEnvFile ? "skip" : missing.length === 0 ? "pass" : "warning",
|
|
284
|
+
message: !hasEnvFile
|
|
285
|
+
? "No .env file found"
|
|
286
|
+
: missing.length === 0
|
|
287
|
+
? "All expected environment variables are set"
|
|
288
|
+
: `Missing ${missing.length} environment variables`,
|
|
289
|
+
details: missing.slice(0, 5),
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Check for real billing (not demo/test)
|
|
294
|
+
*/
|
|
295
|
+
async checkRealBilling(projectPath) {
|
|
296
|
+
const testKeyPatterns = [
|
|
297
|
+
/sk_test_/g,
|
|
298
|
+
/pk_test_/g,
|
|
299
|
+
/STRIPE_TEST/g,
|
|
300
|
+
/demo_billing/gi,
|
|
301
|
+
/simulate.*payment/gi,
|
|
302
|
+
/fake.*billing/gi,
|
|
303
|
+
];
|
|
304
|
+
const issues = [];
|
|
305
|
+
const files = await this.findSourceFiles(projectPath);
|
|
306
|
+
// Check for Stripe/billing related files
|
|
307
|
+
const billingFiles = files.filter((f) =>
|
|
308
|
+
/stripe|billing|payment|checkout/i.test(f),
|
|
309
|
+
);
|
|
310
|
+
if (billingFiles.length === 0) {
|
|
311
|
+
return {
|
|
312
|
+
id: "real-billing",
|
|
313
|
+
name: "Billing Not Simulated",
|
|
314
|
+
shortName: "Billing",
|
|
315
|
+
status: "skip",
|
|
316
|
+
message: "No billing code detected",
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
for (const file of billingFiles) {
|
|
320
|
+
try {
|
|
321
|
+
const content = await fs.promises.readFile(file, "utf-8");
|
|
322
|
+
const relativePath = path.relative(projectPath, file);
|
|
323
|
+
if (this.isTestFile(relativePath)) continue;
|
|
324
|
+
for (const pattern of testKeyPatterns) {
|
|
325
|
+
pattern.lastIndex = 0;
|
|
326
|
+
if (pattern.test(content)) {
|
|
327
|
+
issues.push(`${relativePath}: ${pattern.source}`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
} catch (e) {
|
|
331
|
+
// Skip
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return {
|
|
335
|
+
id: "real-billing",
|
|
336
|
+
name: "Billing Not Simulated",
|
|
337
|
+
shortName: "Billing",
|
|
338
|
+
status: issues.length === 0 ? "pass" : "fail",
|
|
339
|
+
message:
|
|
340
|
+
issues.length === 0
|
|
341
|
+
? "No test billing keys or demo billing code found"
|
|
342
|
+
: `Found ${issues.length} test/demo billing patterns`,
|
|
343
|
+
details: issues.slice(0, 5),
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Check for real database connection
|
|
348
|
+
*/
|
|
349
|
+
async checkRealDatabase(projectPath) {
|
|
350
|
+
const fakeDbPatterns = [
|
|
351
|
+
/sqlite:memory/gi,
|
|
352
|
+
/\.sqlite$/gi,
|
|
353
|
+
/mockdb/gi,
|
|
354
|
+
/fake.*database/gi,
|
|
355
|
+
/in-memory.*db/gi,
|
|
356
|
+
];
|
|
357
|
+
const envPath = path.join(projectPath, ".env");
|
|
358
|
+
const envProdPath = path.join(projectPath, ".env.production");
|
|
359
|
+
let dbUrl = "";
|
|
360
|
+
for (const p of [envProdPath, envPath]) {
|
|
361
|
+
if (fs.existsSync(p)) {
|
|
362
|
+
const content = await fs.promises.readFile(p, "utf-8");
|
|
363
|
+
const match = content.match(/DATABASE_URL=(.+)/);
|
|
364
|
+
if (match) {
|
|
365
|
+
dbUrl = match[1];
|
|
366
|
+
break;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
if (!dbUrl) {
|
|
371
|
+
return {
|
|
372
|
+
id: "real-database",
|
|
373
|
+
name: "Database Is Real",
|
|
374
|
+
shortName: "Database",
|
|
375
|
+
status: "skip",
|
|
376
|
+
message: "No DATABASE_URL found",
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
const isFake =
|
|
380
|
+
fakeDbPatterns.some((p) => p.test(dbUrl)) || /localhost/.test(dbUrl);
|
|
381
|
+
return {
|
|
382
|
+
id: "real-database",
|
|
383
|
+
name: "Database Is Real",
|
|
384
|
+
shortName: "Database",
|
|
385
|
+
status: isFake ? "warning" : "pass",
|
|
386
|
+
message: isFake
|
|
387
|
+
? "Database URL points to local/fake database"
|
|
388
|
+
: "Database URL appears to be a real hosted database",
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Check OAuth callback URLs
|
|
393
|
+
*/
|
|
394
|
+
async checkOAuthCallbacks(projectPath) {
|
|
395
|
+
const authFiles = [
|
|
396
|
+
"src/app/api/auth/[...nextauth]/route.ts",
|
|
397
|
+
"src/pages/api/auth/[...nextauth].ts",
|
|
398
|
+
"src/lib/auth.ts",
|
|
399
|
+
"src/config/auth.ts",
|
|
400
|
+
];
|
|
401
|
+
const issues = [];
|
|
402
|
+
for (const authFile of authFiles) {
|
|
403
|
+
const filePath = path.join(projectPath, authFile);
|
|
404
|
+
if (fs.existsSync(filePath)) {
|
|
405
|
+
try {
|
|
406
|
+
const content = await fs.promises.readFile(filePath, "utf-8");
|
|
407
|
+
if (/callbackUrl.*localhost/i.test(content)) {
|
|
408
|
+
issues.push(`${authFile}: localhost callback URL`);
|
|
409
|
+
}
|
|
410
|
+
if (/redirect.*localhost/i.test(content)) {
|
|
411
|
+
issues.push(`${authFile}: localhost redirect`);
|
|
412
|
+
}
|
|
413
|
+
} catch (e) {
|
|
414
|
+
// Skip
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
// Also check NEXTAUTH_URL
|
|
419
|
+
const envPath = path.join(projectPath, ".env");
|
|
420
|
+
if (fs.existsSync(envPath)) {
|
|
421
|
+
const content = await fs.promises.readFile(envPath, "utf-8");
|
|
422
|
+
const match = content.match(/NEXTAUTH_URL=(.+)/);
|
|
423
|
+
if (match && /localhost/i.test(match[1])) {
|
|
424
|
+
issues.push(".env: NEXTAUTH_URL points to localhost");
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
const hasAuthCode = authFiles.some((f) =>
|
|
428
|
+
fs.existsSync(path.join(projectPath, f)),
|
|
429
|
+
);
|
|
430
|
+
return {
|
|
431
|
+
id: "oauth-callbacks",
|
|
432
|
+
name: "OAuth Callbacks Not Localhost",
|
|
433
|
+
shortName: "OAuth",
|
|
434
|
+
status: !hasAuthCode ? "skip" : issues.length === 0 ? "pass" : "fail",
|
|
435
|
+
message: !hasAuthCode
|
|
436
|
+
? "No OAuth/auth code detected"
|
|
437
|
+
: issues.length === 0
|
|
438
|
+
? "OAuth callbacks configured for production"
|
|
439
|
+
: `Found ${issues.length} localhost OAuth issues`,
|
|
440
|
+
details: issues,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Calculate overall verdict
|
|
445
|
+
*/
|
|
446
|
+
calculateVerdict(checks) {
|
|
447
|
+
const activeChecks = checks.filter((c) => c.status !== "skip");
|
|
448
|
+
const passed = activeChecks.filter((c) => c.status === "pass").length;
|
|
449
|
+
const failed = activeChecks.filter((c) => c.status === "fail").length;
|
|
450
|
+
const warnings = activeChecks.filter((c) => c.status === "warning").length;
|
|
451
|
+
const score =
|
|
452
|
+
activeChecks.length > 0
|
|
453
|
+
? Math.round((passed / activeChecks.length) * 100)
|
|
454
|
+
: 100;
|
|
455
|
+
let verdict;
|
|
456
|
+
if (failed > 0) {
|
|
457
|
+
verdict = "no-ship";
|
|
458
|
+
} else if (warnings > 0) {
|
|
459
|
+
verdict = "review";
|
|
460
|
+
} else {
|
|
461
|
+
verdict = "ship";
|
|
462
|
+
}
|
|
463
|
+
return { verdict, score };
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Generate all badge SVGs
|
|
467
|
+
*/
|
|
468
|
+
generateAllBadges(checks, verdict, score) {
|
|
469
|
+
const mainColor =
|
|
470
|
+
verdict === "ship"
|
|
471
|
+
? BADGE_COLORS.ship
|
|
472
|
+
: verdict === "no-ship"
|
|
473
|
+
? BADGE_COLORS.noship
|
|
474
|
+
: BADGE_COLORS.warning;
|
|
475
|
+
return {
|
|
476
|
+
main: this.createBadge(
|
|
477
|
+
"Guardrail",
|
|
478
|
+
verdict === "ship"
|
|
479
|
+
? "🚀 SHIP IT"
|
|
480
|
+
: verdict === "no-ship"
|
|
481
|
+
? "🛑 NO SHIP"
|
|
482
|
+
: "⚠️ REVIEW",
|
|
483
|
+
mainColor,
|
|
484
|
+
),
|
|
485
|
+
mockData: this.createCheckBadge(
|
|
486
|
+
checks.find((c) => c.id === "no-mock-data"),
|
|
487
|
+
),
|
|
488
|
+
realApi: this.createCheckBadge(
|
|
489
|
+
checks.find((c) => c.id === "no-localhost"),
|
|
490
|
+
),
|
|
491
|
+
envVars: this.createCheckBadge(checks.find((c) => c.id === "env-vars")),
|
|
492
|
+
billing: this.createCheckBadge(
|
|
493
|
+
checks.find((c) => c.id === "real-billing"),
|
|
494
|
+
),
|
|
495
|
+
database: this.createCheckBadge(
|
|
496
|
+
checks.find((c) => c.id === "real-database"),
|
|
497
|
+
),
|
|
498
|
+
oauth: this.createCheckBadge(
|
|
499
|
+
checks.find((c) => c.id === "oauth-callbacks"),
|
|
500
|
+
),
|
|
501
|
+
combined: this.createCombinedBadge(checks, score),
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
/**
|
|
505
|
+
* Create a single check badge
|
|
506
|
+
*/
|
|
507
|
+
createCheckBadge(check) {
|
|
508
|
+
const icon =
|
|
509
|
+
check.status === "pass"
|
|
510
|
+
? "✅"
|
|
511
|
+
: check.status === "fail"
|
|
512
|
+
? "❌"
|
|
513
|
+
: check.status === "warning"
|
|
514
|
+
? "⚠️"
|
|
515
|
+
: "⏭️";
|
|
516
|
+
const color = BADGE_COLORS[check.status];
|
|
517
|
+
const label = check.shortName;
|
|
518
|
+
const value =
|
|
519
|
+
check.status === "pass"
|
|
520
|
+
? "Pass"
|
|
521
|
+
: check.status === "fail"
|
|
522
|
+
? "Fail"
|
|
523
|
+
: check.status === "warning"
|
|
524
|
+
? "Warning"
|
|
525
|
+
: "Skip";
|
|
526
|
+
return this.createBadge(label, `${icon} ${value}`, color);
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* Create a combined badge strip
|
|
530
|
+
*/
|
|
531
|
+
createCombinedBadge(checks, score) {
|
|
532
|
+
const passed = checks.filter((c) => c.status === "pass").length;
|
|
533
|
+
const total = checks.filter((c) => c.status !== "skip").length;
|
|
534
|
+
const color =
|
|
535
|
+
score >= 80
|
|
536
|
+
? BADGE_COLORS.pass
|
|
537
|
+
: score >= 50
|
|
538
|
+
? BADGE_COLORS.warning
|
|
539
|
+
: BADGE_COLORS.fail;
|
|
540
|
+
return this.createBadge(
|
|
541
|
+
"Ship Score",
|
|
542
|
+
`${passed}/${total} (${score}%)`,
|
|
543
|
+
color,
|
|
544
|
+
"for-the-badge",
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* Create SVG badge
|
|
549
|
+
*/
|
|
550
|
+
createBadge(label, value, color, style = "flat") {
|
|
551
|
+
const labelWidth = label.length * 7 + 10;
|
|
552
|
+
const valueWidth = value.length * 7 + 10;
|
|
553
|
+
const totalWidth = labelWidth + valueWidth;
|
|
554
|
+
const height = style === "for-the-badge" ? 28 : 20;
|
|
555
|
+
const fontSize = 11;
|
|
556
|
+
const labelBg = "#555";
|
|
557
|
+
if (style === "for-the-badge") {
|
|
558
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}">
|
|
559
|
+
<linearGradient id="smooth" x2="0" y2="100%">
|
|
560
|
+
<stop offset="0" stop-color="#fff" stop-opacity=".7"/>
|
|
561
|
+
<stop offset=".1" stop-color="#aaa" stop-opacity=".1"/>
|
|
562
|
+
<stop offset=".9" stop-color="#000" stop-opacity=".3"/>
|
|
563
|
+
<stop offset="1" stop-color="#000" stop-opacity=".5"/>
|
|
564
|
+
</linearGradient>
|
|
565
|
+
<rect rx="4" width="${totalWidth}" height="${height}" fill="${labelBg}"/>
|
|
566
|
+
<rect rx="4" x="${labelWidth}" width="${valueWidth}" height="${height}" fill="${color}"/>
|
|
567
|
+
<rect rx="4" width="${totalWidth}" height="${height}" fill="url(#smooth)"/>
|
|
568
|
+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="${fontSize}" font-weight="bold">
|
|
569
|
+
<text x="${labelWidth / 2}" y="${height / 2 + 4}" fill="#010101" fill-opacity=".3">${this.escapeHtml(label)}</text>
|
|
570
|
+
<text x="${labelWidth / 2}" y="${height / 2 + 3}">${this.escapeHtml(label)}</text>
|
|
571
|
+
<text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 4}" fill="#010101" fill-opacity=".3">${this.escapeHtml(value)}</text>
|
|
572
|
+
<text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 3}">${this.escapeHtml(value)}</text>
|
|
573
|
+
</g>
|
|
574
|
+
</svg>`;
|
|
575
|
+
}
|
|
576
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="${height}">
|
|
577
|
+
<linearGradient id="smooth" x2="0" y2="100%">
|
|
578
|
+
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
|
|
579
|
+
<stop offset="1" stop-opacity=".1"/>
|
|
580
|
+
</linearGradient>
|
|
581
|
+
<clipPath id="round">
|
|
582
|
+
<rect width="${totalWidth}" height="${height}" rx="3" fill="#fff"/>
|
|
583
|
+
</clipPath>
|
|
584
|
+
<g clip-path="url(#round)">
|
|
585
|
+
<rect width="${labelWidth}" height="${height}" fill="${labelBg}"/>
|
|
586
|
+
<rect x="${labelWidth}" width="${valueWidth}" height="${height}" fill="${color}"/>
|
|
587
|
+
<rect width="${totalWidth}" height="${height}" fill="url(#smooth)"/>
|
|
588
|
+
</g>
|
|
589
|
+
<g fill="#fff" text-anchor="middle" font-family="DejaVu Sans,Verdana,Geneva,sans-serif" font-size="${fontSize}">
|
|
590
|
+
<text x="${labelWidth / 2}" y="${height / 2 + 4}" fill="#010101" fill-opacity=".3">${this.escapeHtml(label)}</text>
|
|
591
|
+
<text x="${labelWidth / 2}" y="${height / 2 + 3}">${this.escapeHtml(label)}</text>
|
|
592
|
+
<text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 4}" fill="#010101" fill-opacity=".3">${this.escapeHtml(value)}</text>
|
|
593
|
+
<text x="${labelWidth + valueWidth / 2}" y="${height / 2 + 3}">${this.escapeHtml(value)}</text>
|
|
594
|
+
</g>
|
|
595
|
+
</svg>`;
|
|
596
|
+
}
|
|
597
|
+
/**
|
|
598
|
+
* Generate embed code for README
|
|
599
|
+
*/
|
|
600
|
+
generateEmbedCode(projectId, verdict, projectName) {
|
|
601
|
+
return `<!-- Guardrail Ship Badge -->
|
|
602
|
+
[](https://Guardrail.dev/badge/${projectId})
|
|
603
|
+
[](https://Guardrail.dev/badge/${projectId})
|
|
604
|
+
[](https://Guardrail.dev/badge/${projectId})
|
|
605
|
+
<!-- End Guardrail Ship Badge -->
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
**${projectName}** verified by [Guardrail](https://Guardrail.dev) - Stop shipping pretend features.`;
|
|
610
|
+
}
|
|
611
|
+
/**
|
|
612
|
+
* Save badges to directory
|
|
613
|
+
*/
|
|
614
|
+
async saveBadges(badges, outputDir) {
|
|
615
|
+
await fs.promises.mkdir(outputDir, { recursive: true });
|
|
616
|
+
const files = [
|
|
617
|
+
["main", "ship-status.svg"],
|
|
618
|
+
["mockData", "mock-data.svg"],
|
|
619
|
+
["realApi", "real-api.svg"],
|
|
620
|
+
["envVars", "env-vars.svg"],
|
|
621
|
+
["billing", "billing.svg"],
|
|
622
|
+
["database", "database.svg"],
|
|
623
|
+
["oauth", "oauth.svg"],
|
|
624
|
+
["combined", "ship-score.svg"],
|
|
625
|
+
];
|
|
626
|
+
for (const [key, filename] of files) {
|
|
627
|
+
await fs.promises.writeFile(
|
|
628
|
+
path.join(outputDir, filename),
|
|
629
|
+
badges[key],
|
|
630
|
+
"utf-8",
|
|
631
|
+
);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
/**
|
|
635
|
+
* Generate project ID from path
|
|
636
|
+
*/
|
|
637
|
+
generateProjectId(projectPath) {
|
|
638
|
+
const hash = crypto
|
|
639
|
+
.createHash("sha256")
|
|
640
|
+
.update(projectPath)
|
|
641
|
+
.digest("hex")
|
|
642
|
+
.slice(0, 12);
|
|
643
|
+
return hash;
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* Find source files
|
|
647
|
+
*/
|
|
648
|
+
async findSourceFiles(projectPath) {
|
|
649
|
+
const files = [];
|
|
650
|
+
const extensions = [".ts", ".tsx", ".js", ".jsx"];
|
|
651
|
+
const excludeDirs = [
|
|
652
|
+
"node_modules",
|
|
653
|
+
".git",
|
|
654
|
+
".next",
|
|
655
|
+
"dist",
|
|
656
|
+
"build",
|
|
657
|
+
"coverage",
|
|
658
|
+
];
|
|
659
|
+
const walk = async (dir) => {
|
|
660
|
+
try {
|
|
661
|
+
const entries = await fs.promises.readdir(dir, { withFileTypes: true });
|
|
662
|
+
for (const entry of entries) {
|
|
663
|
+
const fullPath = path.join(dir, entry.name);
|
|
664
|
+
if (entry.isDirectory()) {
|
|
665
|
+
if (
|
|
666
|
+
!excludeDirs.includes(entry.name) &&
|
|
667
|
+
!entry.name.startsWith(".")
|
|
668
|
+
) {
|
|
669
|
+
await walk(fullPath);
|
|
670
|
+
}
|
|
671
|
+
} else if (entry.isFile()) {
|
|
672
|
+
const ext = path.extname(entry.name);
|
|
673
|
+
if (extensions.includes(ext)) {
|
|
674
|
+
files.push(fullPath);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
} catch (e) {
|
|
679
|
+
// Skip
|
|
680
|
+
}
|
|
681
|
+
};
|
|
682
|
+
await walk(projectPath);
|
|
683
|
+
return files;
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Check if file is a test file
|
|
687
|
+
*/
|
|
688
|
+
isTestFile(filePath) {
|
|
689
|
+
const testPatterns = [
|
|
690
|
+
/__tests__/,
|
|
691
|
+
/\.test\./,
|
|
692
|
+
/\.spec\./,
|
|
693
|
+
/test\//,
|
|
694
|
+
/tests\//,
|
|
695
|
+
/e2e\//,
|
|
696
|
+
/__mocks__/,
|
|
697
|
+
/stories\//,
|
|
698
|
+
];
|
|
699
|
+
return testPatterns.some((p) => p.test(filePath));
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Escape HTML entities
|
|
703
|
+
*/
|
|
704
|
+
escapeHtml(text) {
|
|
705
|
+
return text
|
|
706
|
+
.replace(/&/g, "&")
|
|
707
|
+
.replace(/</g, "<")
|
|
708
|
+
.replace(/>/g, ">")
|
|
709
|
+
.replace(/"/g, """)
|
|
710
|
+
.replace(/'/g, "'");
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Generate human-readable report
|
|
714
|
+
*/
|
|
715
|
+
generateReport(result) {
|
|
716
|
+
const lines = [];
|
|
717
|
+
lines.push(
|
|
718
|
+
"╔══════════════════════════════════════════════════════════════╗",
|
|
719
|
+
);
|
|
720
|
+
lines.push(
|
|
721
|
+
"║ 🚀 Guardrail Ship Badge Report 🚀 ║",
|
|
722
|
+
);
|
|
723
|
+
lines.push(
|
|
724
|
+
"╚══════════════════════════════════════════════════════════════╝",
|
|
725
|
+
);
|
|
726
|
+
lines.push("");
|
|
727
|
+
const verdictEmoji =
|
|
728
|
+
result.verdict === "ship"
|
|
729
|
+
? "🚀"
|
|
730
|
+
: result.verdict === "no-ship"
|
|
731
|
+
? "🛑"
|
|
732
|
+
: "⚠️";
|
|
733
|
+
const verdictText =
|
|
734
|
+
result.verdict === "ship"
|
|
735
|
+
? "SHIP IT!"
|
|
736
|
+
: result.verdict === "no-ship"
|
|
737
|
+
? "NO SHIP"
|
|
738
|
+
: "NEEDS REVIEW";
|
|
739
|
+
lines.push(`${verdictEmoji} VERDICT: ${verdictText}`);
|
|
740
|
+
lines.push(` Ship Score: ${result.score}/100`);
|
|
741
|
+
lines.push(` Project: ${result.projectName}`);
|
|
742
|
+
lines.push("");
|
|
743
|
+
lines.push("─".repeat(64));
|
|
744
|
+
lines.push("");
|
|
745
|
+
lines.push("CHECKS:");
|
|
746
|
+
lines.push("");
|
|
747
|
+
for (const check of result.checks) {
|
|
748
|
+
const icon =
|
|
749
|
+
check.status === "pass"
|
|
750
|
+
? "✅"
|
|
751
|
+
: check.status === "fail"
|
|
752
|
+
? "❌"
|
|
753
|
+
: check.status === "warning"
|
|
754
|
+
? "⚠️"
|
|
755
|
+
: "⏭️";
|
|
756
|
+
lines.push(`${icon} ${check.name}`);
|
|
757
|
+
lines.push(` ${check.message}`);
|
|
758
|
+
if (check.details && check.details.length > 0) {
|
|
759
|
+
for (const detail of check.details) {
|
|
760
|
+
lines.push(` • ${detail}`);
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
lines.push("");
|
|
764
|
+
}
|
|
765
|
+
lines.push("─".repeat(64));
|
|
766
|
+
lines.push("");
|
|
767
|
+
lines.push("ADD TO YOUR README:");
|
|
768
|
+
lines.push("");
|
|
769
|
+
lines.push(result.embedCode);
|
|
770
|
+
lines.push("");
|
|
771
|
+
lines.push("─".repeat(64));
|
|
772
|
+
lines.push(`Permalink: ${result.permalink}`);
|
|
773
|
+
lines.push(`Generated: ${result.timestamp}`);
|
|
774
|
+
lines.push(`Expires: ${result.expiresAt}`);
|
|
775
|
+
return lines.join("\n");
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
exports.ShipBadgeGenerator = ShipBadgeGenerator;
|
|
779
|
+
exports.shipBadgeGenerator = new ShipBadgeGenerator();
|