lastgen 1.0.2 → 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/README.md +6 -0
- package/package.json +4 -2
- package/src/cli.ts +19 -6
- package/src/{github.ts → core/github.ts} +2 -5
- package/src/{proof.ts → core/proof.ts} +12 -8
- package/src/{types.ts → core/types.ts} +3 -0
- package/src/{verify.ts → core/verify.ts} +28 -89
- package/src/display.ts +2 -2
- package/src/hash.ts +11 -0
- package/src/serve.ts +77 -0
- package/src/verify-cli.ts +89 -0
- package/web/dist/assets/index-B5YmfSOd.js +5 -0
- package/web/dist/assets/index-xuvA0IWh.css +1 -0
- package/web/dist/index.html +64 -0
package/README.md
CHANGED
|
@@ -14,6 +14,8 @@ Check if you started coding before or after AI agents.
|
|
|
14
14
|
|
|
15
15
|
Claude Code shipped publicly on **February 21, 2025**. If your earliest verifiable commit is before that date, you get classified as **Last Gen**. If it's after, **AI Native**.
|
|
16
16
|
|
|
17
|
+
**[Try it in your browser →](https://pgagnidze.github.io/lastgen/)**
|
|
18
|
+
|
|
17
19
|
> [!IMPORTANT]
|
|
18
20
|
> This is a novelty tool for fun. It is not a measure of skill or credibility.
|
|
19
21
|
|
|
@@ -50,6 +52,9 @@ npx lastgen --badge torvalds
|
|
|
50
52
|
|
|
51
53
|
# JSON output
|
|
52
54
|
npx lastgen --json torvalds
|
|
55
|
+
|
|
56
|
+
# Launch web UI locally
|
|
57
|
+
npx lastgen serve
|
|
53
58
|
```
|
|
54
59
|
|
|
55
60
|
### Options
|
|
@@ -58,6 +63,7 @@ npx lastgen --json torvalds
|
|
|
58
63
|
--token <token> GitHub personal access token
|
|
59
64
|
--json Output as JSON
|
|
60
65
|
--badge Output as README badge markdown
|
|
66
|
+
--port <port> Port for web UI (default: 3000)
|
|
61
67
|
--no-color Disable colors
|
|
62
68
|
-h, --help Show help
|
|
63
69
|
-v, --version Show version
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lastgen",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Check if you started coding before or after AI agents",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
},
|
|
12
12
|
"files": [
|
|
13
13
|
"src",
|
|
14
|
+
"web/dist",
|
|
14
15
|
"README.md"
|
|
15
16
|
],
|
|
16
17
|
"scripts": {
|
|
@@ -20,7 +21,8 @@
|
|
|
20
21
|
"format": "prettier --write src/",
|
|
21
22
|
"format:check": "prettier --check src/",
|
|
22
23
|
"test": "node --test src/**/*.test.ts",
|
|
23
|
-
"start": "node src/index.ts"
|
|
24
|
+
"start": "node src/index.ts",
|
|
25
|
+
"prepublishOnly": "cd web && npm ci && npm run build"
|
|
24
26
|
},
|
|
25
27
|
"keywords": [
|
|
26
28
|
"developer",
|
package/src/cli.ts
CHANGED
|
@@ -7,10 +7,12 @@ import { readFileSync } from 'node:fs';
|
|
|
7
7
|
import { dirname, join } from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
9
|
|
|
10
|
-
import { fetchFirstCommit, fetchUser } from './github.ts';
|
|
11
|
-
import { createCertificate } from './proof.ts';
|
|
10
|
+
import { fetchFirstCommit, fetchUser } from './core/github.ts';
|
|
11
|
+
import { createCertificate } from './core/proof.ts';
|
|
12
|
+
import { nodeHash } from './hash.ts';
|
|
12
13
|
import { displayBadgeMarkdown, displayCertificate, displayJson, error, info } from './display.ts';
|
|
13
|
-
import { verifyCertificate } from './verify.ts';
|
|
14
|
+
import { verifyCertificate } from './verify-cli.ts';
|
|
15
|
+
import { serve } from './serve.ts';
|
|
14
16
|
|
|
15
17
|
function getVersion(): string {
|
|
16
18
|
try {
|
|
@@ -36,11 +38,13 @@ const HELP_BRIEF = `
|
|
|
36
38
|
Usage:
|
|
37
39
|
lastgen <username> Classify a GitHub user
|
|
38
40
|
lastgen verify <file.json> Verify a saved certificate
|
|
41
|
+
lastgen serve [--port <port>] Launch web UI
|
|
39
42
|
|
|
40
43
|
Options:
|
|
41
44
|
--token <token> GitHub personal access token
|
|
42
45
|
--json Output as JSON
|
|
43
46
|
--badge Output as README badge markdown
|
|
47
|
+
--port <port> Port for web UI (default: 3000)
|
|
44
48
|
--no-color Disable colors
|
|
45
49
|
-h, --help Show this help
|
|
46
50
|
-v, --version Show version
|
|
@@ -54,12 +58,14 @@ const HELP_BRIEF = `
|
|
|
54
58
|
npx lastgen --json torvalds > proof.json
|
|
55
59
|
npx lastgen verify proof.json
|
|
56
60
|
npx lastgen --badge torvalds
|
|
61
|
+
npx lastgen serve
|
|
57
62
|
`;
|
|
58
63
|
|
|
59
64
|
interface CliOptions {
|
|
60
65
|
command: string;
|
|
61
66
|
target: string;
|
|
62
67
|
token?: string;
|
|
68
|
+
port: number;
|
|
63
69
|
json: boolean;
|
|
64
70
|
badge: boolean;
|
|
65
71
|
help: boolean;
|
|
@@ -73,6 +79,7 @@ export function parseCli(argv: string[]): CliOptions {
|
|
|
73
79
|
token: { type: 'string' },
|
|
74
80
|
json: { type: 'boolean', default: false },
|
|
75
81
|
badge: { type: 'boolean', default: false },
|
|
82
|
+
port: { type: 'string' },
|
|
76
83
|
help: { type: 'boolean', short: 'h', default: false },
|
|
77
84
|
version: { type: 'boolean', short: 'v', default: false },
|
|
78
85
|
'no-color': { type: 'boolean', default: false },
|
|
@@ -83,11 +90,13 @@ export function parseCli(argv: string[]): CliOptions {
|
|
|
83
90
|
|
|
84
91
|
const first = positionals[0] ?? '';
|
|
85
92
|
const isVerify = first === 'verify';
|
|
93
|
+
const isServe = first === 'serve';
|
|
86
94
|
|
|
87
95
|
return {
|
|
88
|
-
command: isVerify ? 'verify' : first ? 'lookup' : '',
|
|
96
|
+
command: isVerify ? 'verify' : isServe ? 'serve' : first ? 'lookup' : '',
|
|
89
97
|
target: isVerify ? (positionals[1] ?? '') : first,
|
|
90
98
|
token: (values.token as string | undefined) ?? process.env['GITHUB_TOKEN'],
|
|
99
|
+
port: Number(values.port) || 3000,
|
|
91
100
|
json: Boolean(values.json),
|
|
92
101
|
badge: Boolean(values.badge),
|
|
93
102
|
help: Boolean(values.help),
|
|
@@ -117,6 +126,10 @@ export async function run(argv: string[]): Promise<void> {
|
|
|
117
126
|
await handleVerify(opts);
|
|
118
127
|
break;
|
|
119
128
|
}
|
|
129
|
+
case 'serve': {
|
|
130
|
+
serve(opts.port);
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
120
133
|
default: {
|
|
121
134
|
error(`Unknown command: ${opts.command}`);
|
|
122
135
|
process.stdout.write(HELP_BRIEF);
|
|
@@ -141,7 +154,7 @@ async function handleLookup(opts: CliOptions): Promise<void> {
|
|
|
141
154
|
fetchFirstCommit(opts.target, opts.token),
|
|
142
155
|
]);
|
|
143
156
|
|
|
144
|
-
const cert = createCertificate(user, firstCommit);
|
|
157
|
+
const cert = await createCertificate(nodeHash, user, firstCommit);
|
|
145
158
|
|
|
146
159
|
if (opts.badge) {
|
|
147
160
|
displayBadgeMarkdown(cert);
|
|
@@ -159,7 +172,7 @@ async function handleVerify(opts: CliOptions): Promise<void> {
|
|
|
159
172
|
return;
|
|
160
173
|
}
|
|
161
174
|
|
|
162
|
-
const valid = await verifyCertificate(opts.target, opts.token);
|
|
175
|
+
const valid = await verifyCertificate(opts.target, nodeHash, opts.token);
|
|
163
176
|
if (!valid) {
|
|
164
177
|
process.exitCode = 1;
|
|
165
178
|
}
|
|
@@ -6,7 +6,7 @@ import type { GitHubUser, FirstCommit, CommitDetail } from './types.ts';
|
|
|
6
6
|
import { CUTOFF_DATE } from './types.ts';
|
|
7
7
|
|
|
8
8
|
const GITHUB_API = 'https://api.github.com';
|
|
9
|
-
const USER_AGENT = 'lastgen
|
|
9
|
+
const USER_AGENT = 'lastgen';
|
|
10
10
|
|
|
11
11
|
function buildHeaders(token?: string): Record<string, string> {
|
|
12
12
|
const headers: Record<string, string> = {
|
|
@@ -33,10 +33,7 @@ async function githubFetch(
|
|
|
33
33
|
const resetDate = resetTimestamp
|
|
34
34
|
? new Date(Number(resetTimestamp) * 1000).toLocaleTimeString()
|
|
35
35
|
: 'soon';
|
|
36
|
-
throw new Error(
|
|
37
|
-
`GitHub API rate limit exceeded. Resets at ${resetDate}.\n` +
|
|
38
|
-
'Tip: set GITHUB_TOKEN or use --token for 5,000 requests/hour.',
|
|
39
|
-
);
|
|
36
|
+
throw new Error(`GitHub API rate limit exceeded. Resets at ${resetDate}.`);
|
|
40
37
|
}
|
|
41
38
|
}
|
|
42
39
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @fileoverview Era classification, hashing, and certificate generation.
|
|
3
|
+
* Platform-agnostic: hash function is injected via HashFn.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import type { Certificate, EraKey, FirstCommit, GitHubUser } from './types.ts';
|
|
6
|
+
import type { Certificate, EraKey, FirstCommit, GitHubUser, HashFn } from './types.ts';
|
|
8
7
|
|
|
9
8
|
import { CERTIFICATE_SALT, CERTIFICATE_VERSION, CUTOFF_DATE, THIRTY_DAYS_MS } from './types.ts';
|
|
10
9
|
|
|
@@ -48,12 +47,13 @@ function getEffectiveCommitDate(commit: FirstCommit): string {
|
|
|
48
47
|
return commit.date;
|
|
49
48
|
}
|
|
50
49
|
|
|
51
|
-
export function generateCertificateHash(
|
|
50
|
+
export async function generateCertificateHash(
|
|
51
|
+
hashFn: HashFn,
|
|
52
52
|
username: string,
|
|
53
53
|
githubId: number,
|
|
54
54
|
proofDate: string,
|
|
55
55
|
era: EraKey,
|
|
56
|
-
): string {
|
|
56
|
+
): Promise<string> {
|
|
57
57
|
const payload = JSON.stringify({
|
|
58
58
|
username,
|
|
59
59
|
githubId,
|
|
@@ -62,7 +62,7 @@ export function generateCertificateHash(
|
|
|
62
62
|
salt: CERTIFICATE_SALT,
|
|
63
63
|
});
|
|
64
64
|
|
|
65
|
-
return
|
|
65
|
+
return hashFn(payload);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
68
|
export function generateCertificateNumber(hash: string): string {
|
|
@@ -72,10 +72,14 @@ export function generateCertificateNumber(hash: string): string {
|
|
|
72
72
|
return `LGC-${prefix}-${padded}`;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
export function createCertificate(
|
|
75
|
+
export async function createCertificate(
|
|
76
|
+
hashFn: HashFn,
|
|
77
|
+
user: GitHubUser,
|
|
78
|
+
firstCommit: FirstCommit | null,
|
|
79
|
+
): Promise<Certificate> {
|
|
76
80
|
const proofDate = resolveProofDate(user, firstCommit);
|
|
77
81
|
const era = classifyEra(proofDate);
|
|
78
|
-
const hash = generateCertificateHash(user.login, user.id, proofDate, era);
|
|
82
|
+
const hash = await generateCertificateHash(hashFn, user.login, user.id, proofDate, era);
|
|
79
83
|
const certificateNumber = generateCertificateNumber(hash);
|
|
80
84
|
|
|
81
85
|
return {
|
|
@@ -1,16 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @fileoverview Certificate verification
|
|
2
|
+
* @fileoverview Certificate verification logic. Platform-agnostic: no I/O, no display.
|
|
3
|
+
* Accepts parsed data and a HashFn, returns structured results.
|
|
3
4
|
*/
|
|
4
5
|
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
import type { Certificate, VerifyResult } from './types.ts';
|
|
6
|
+
import type { Certificate, HashFn, VerifyResult } from './types.ts';
|
|
8
7
|
import { CUTOFF_DATE, THIRTY_DAYS_MS } from './types.ts';
|
|
9
8
|
import { fetchCommit } from './github.ts';
|
|
10
9
|
import { generateCertificateHash, resolveProofDate } from './proof.ts';
|
|
11
|
-
import { BOX_WIDTH, boxLine, boxRule, error, info, style } from './display.ts';
|
|
12
10
|
|
|
13
|
-
function isValidCertificate(data: unknown): data is Certificate {
|
|
11
|
+
export function isValidCertificate(data: unknown): data is Certificate {
|
|
14
12
|
if (typeof data !== 'object' || data === null) {
|
|
15
13
|
return false;
|
|
16
14
|
}
|
|
@@ -36,35 +34,22 @@ function escapeRegex(str: string): string {
|
|
|
36
34
|
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
return false;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
let cert: unknown;
|
|
49
|
-
try {
|
|
50
|
-
cert = JSON.parse(raw);
|
|
51
|
-
} catch {
|
|
52
|
-
error('Invalid JSON in certificate file.');
|
|
53
|
-
return false;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (!isValidCertificate(cert)) {
|
|
57
|
-
error('File is not a valid lastgen certificate.');
|
|
58
|
-
return false;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
info(`Verifying certificate ${cert.certificateNumber}...`);
|
|
62
|
-
info(`Developer: ${cert.identity.username}`);
|
|
63
|
-
info('');
|
|
37
|
+
export interface VerifyCertificateResult {
|
|
38
|
+
valid: boolean;
|
|
39
|
+
results: VerifyResult[];
|
|
40
|
+
certificateNumber: string;
|
|
41
|
+
username: string;
|
|
42
|
+
}
|
|
64
43
|
|
|
44
|
+
export async function verifyCertificateData(
|
|
45
|
+
cert: Certificate,
|
|
46
|
+
hashFn: HashFn,
|
|
47
|
+
token?: string,
|
|
48
|
+
): Promise<VerifyCertificateResult> {
|
|
65
49
|
const results: VerifyResult[] = [];
|
|
66
50
|
|
|
67
|
-
const expectedHash = generateCertificateHash(
|
|
51
|
+
const expectedHash = await generateCertificateHash(
|
|
52
|
+
hashFn,
|
|
68
53
|
cert.identity.username,
|
|
69
54
|
cert.identity.githubId,
|
|
70
55
|
cert.proof.proofDate,
|
|
@@ -116,7 +101,6 @@ export async function verifyCertificate(filePath: string, token?: string): Promi
|
|
|
116
101
|
|
|
117
102
|
if (cert.proof.firstCommit.sha) {
|
|
118
103
|
try {
|
|
119
|
-
info(`Fetching commit ${cert.proof.firstCommit.sha.slice(0, 7)} from GitHub...`);
|
|
120
104
|
const commitDetail = await fetchCommit(
|
|
121
105
|
cert.proof.firstCommit.repo,
|
|
122
106
|
cert.proof.firstCommit.sha,
|
|
@@ -132,15 +116,9 @@ export async function verifyCertificate(filePath: string, token?: string): Promi
|
|
|
132
116
|
|
|
133
117
|
const identityMatch = authorMatch || committerMatch || emailMatch;
|
|
134
118
|
const matchMethods: string[] = [];
|
|
135
|
-
if (authorMatch)
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (committerMatch) {
|
|
139
|
-
matchMethods.push('committer login');
|
|
140
|
-
}
|
|
141
|
-
if (emailMatch) {
|
|
142
|
-
matchMethods.push('noreply email');
|
|
143
|
-
}
|
|
119
|
+
if (authorMatch) matchMethods.push('author login');
|
|
120
|
+
if (committerMatch) matchMethods.push('committer login');
|
|
121
|
+
if (emailMatch) matchMethods.push('noreply email');
|
|
144
122
|
|
|
145
123
|
results.push({
|
|
146
124
|
check: 'Identity',
|
|
@@ -186,9 +164,8 @@ export async function verifyCertificate(filePath: string, token?: string): Promi
|
|
|
186
164
|
const authorTime = new Date(commitDetail.authorDate).getTime();
|
|
187
165
|
const committerTime = new Date(commitDetail.committerDate).getTime();
|
|
188
166
|
const driftMs = Math.abs(committerTime - authorTime);
|
|
189
|
-
const thirtyDaysMs = THIRTY_DAYS_MS;
|
|
190
167
|
const driftDays = Math.round(driftMs / (24 * 60 * 60 * 1000));
|
|
191
|
-
const consistent = driftMs <=
|
|
168
|
+
const consistent = driftMs <= THIRTY_DAYS_MS;
|
|
192
169
|
results.push({
|
|
193
170
|
check: 'Date consistency',
|
|
194
171
|
passed: consistent,
|
|
@@ -225,50 +202,12 @@ export async function verifyCertificate(filePath: string, token?: string): Promi
|
|
|
225
202
|
}
|
|
226
203
|
}
|
|
227
204
|
|
|
228
|
-
const
|
|
229
|
-
const lines: string[] = [];
|
|
230
|
-
|
|
231
|
-
lines.push(boxRule());
|
|
232
|
-
|
|
233
|
-
const title = style('bold', 'VERIFICATION');
|
|
234
|
-
const titleRaw = 'VERIFICATION';
|
|
235
|
-
const titlePadL = Math.floor((BOX_WIDTH - titleRaw.length) / 2);
|
|
236
|
-
const titlePadR = BOX_WIDTH - titleRaw.length - titlePadL;
|
|
237
|
-
lines.push(boxLine(' '.repeat(titlePadL) + title + ' '.repeat(titlePadR), BOX_WIDTH));
|
|
238
|
-
|
|
239
|
-
lines.push(boxRule());
|
|
205
|
+
const allPassed = results.every((r) => r.passed);
|
|
240
206
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
const maxLen = BOX_WIDTH - indent;
|
|
248
|
-
let remaining = result.detail;
|
|
249
|
-
while (remaining.length > 0) {
|
|
250
|
-
const chunk = remaining.slice(0, maxLen);
|
|
251
|
-
remaining = remaining.slice(maxLen);
|
|
252
|
-
const line = ' '.repeat(indent) + style('dim', chunk);
|
|
253
|
-
lines.push(boxLine(line, indent + chunk.length));
|
|
254
|
-
}
|
|
255
|
-
if (!result.passed) {
|
|
256
|
-
allPassed = false;
|
|
257
|
-
}
|
|
258
|
-
}
|
|
259
|
-
|
|
260
|
-
lines.push(boxRule());
|
|
261
|
-
|
|
262
|
-
if (allPassed) {
|
|
263
|
-
const msg = style('green', 'Certificate is valid.');
|
|
264
|
-
lines.push(boxLine(msg, 21));
|
|
265
|
-
} else {
|
|
266
|
-
const msg = style('red', 'Certificate verification failed.');
|
|
267
|
-
lines.push(boxLine(msg, 32));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
lines.push(boxRule());
|
|
271
|
-
|
|
272
|
-
out.write('\n' + lines.join('\n') + '\n\n');
|
|
273
|
-
return allPassed;
|
|
207
|
+
return {
|
|
208
|
+
valid: allPassed,
|
|
209
|
+
results,
|
|
210
|
+
certificateNumber: cert.certificateNumber,
|
|
211
|
+
username: cert.identity.username,
|
|
212
|
+
};
|
|
274
213
|
}
|
package/src/display.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { styleText } from 'node:util';
|
|
6
6
|
|
|
7
|
-
import type { Certificate } from './types.ts';
|
|
8
|
-
import { ERAS } from './types.ts';
|
|
7
|
+
import type { Certificate } from './core/types.ts';
|
|
8
|
+
import { ERAS } from './core/types.ts';
|
|
9
9
|
|
|
10
10
|
function shouldUseColor(): boolean {
|
|
11
11
|
if (process.env['NO_COLOR'] !== undefined) {
|
package/src/hash.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Node.js SHA-256 hash implementation using node:crypto.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
import type { HashFn } from './core/types.ts';
|
|
8
|
+
|
|
9
|
+
export const nodeHash: HashFn = async (data: string): Promise<string> => {
|
|
10
|
+
return createHash('sha256').update(data).digest('hex');
|
|
11
|
+
};
|
package/src/serve.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Static HTTP server for the pre-built web frontend.
|
|
3
|
+
* Zero dependencies — uses node:http and node:fs.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { createServer } from 'node:http';
|
|
7
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { join, extname } from 'node:path';
|
|
9
|
+
import { dirname } from 'node:path';
|
|
10
|
+
import { fileURLToPath } from 'node:url';
|
|
11
|
+
|
|
12
|
+
const MIME_TYPES: Record<string, string> = {
|
|
13
|
+
'.html': 'text/html; charset=utf-8',
|
|
14
|
+
'.css': 'text/css; charset=utf-8',
|
|
15
|
+
'.js': 'application/javascript; charset=utf-8',
|
|
16
|
+
'.json': 'application/json; charset=utf-8',
|
|
17
|
+
'.svg': 'image/svg+xml',
|
|
18
|
+
'.png': 'image/png',
|
|
19
|
+
'.ico': 'image/x-icon',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function serve(port: number = 3000): void {
|
|
23
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
24
|
+
const __dirname = dirname(__filename);
|
|
25
|
+
const distDir = join(__dirname, '..', 'web', 'dist');
|
|
26
|
+
|
|
27
|
+
if (!existsSync(distDir)) {
|
|
28
|
+
process.stderr.write(
|
|
29
|
+
` Web assets not found at ${distDir}\n` +
|
|
30
|
+
' Run "cd web && npm install && npm run build" first.\n',
|
|
31
|
+
);
|
|
32
|
+
process.exitCode = 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const server = createServer((req, res) => {
|
|
37
|
+
let urlPath = req.url ?? '/';
|
|
38
|
+
|
|
39
|
+
const qIndex = urlPath.indexOf('?');
|
|
40
|
+
if (qIndex !== -1) urlPath = urlPath.slice(0, qIndex);
|
|
41
|
+
|
|
42
|
+
if (urlPath.startsWith('/lastgen/')) {
|
|
43
|
+
urlPath = urlPath.slice('/lastgen'.length);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (urlPath === '/' || urlPath === '') urlPath = '/index.html';
|
|
47
|
+
|
|
48
|
+
const filePath = join(distDir, urlPath);
|
|
49
|
+
|
|
50
|
+
if (!filePath.startsWith(distDir)) {
|
|
51
|
+
res.writeHead(403);
|
|
52
|
+
res.end('Forbidden');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const content = readFileSync(filePath);
|
|
58
|
+
const ext = extname(filePath);
|
|
59
|
+
const contentType = MIME_TYPES[ext] ?? 'application/octet-stream';
|
|
60
|
+
res.writeHead(200, { 'Content-Type': contentType });
|
|
61
|
+
res.end(content);
|
|
62
|
+
} catch {
|
|
63
|
+
try {
|
|
64
|
+
const indexContent = readFileSync(join(distDir, 'index.html'));
|
|
65
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
66
|
+
res.end(indexContent);
|
|
67
|
+
} catch {
|
|
68
|
+
res.writeHead(404);
|
|
69
|
+
res.end('Not found');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
server.listen(port, '127.0.0.1', () => {
|
|
75
|
+
process.stderr.write(`\n lastgen web UI running at http://localhost:${port}/\n\n`);
|
|
76
|
+
});
|
|
77
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview CLI wrapper for certificate verification. Handles file I/O and display.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
import type { HashFn } from './core/types.ts';
|
|
8
|
+
import { isValidCertificate, verifyCertificateData } from './core/verify.ts';
|
|
9
|
+
import { BOX_WIDTH, boxLine, boxRule, error, info, style } from './display.ts';
|
|
10
|
+
|
|
11
|
+
export async function verifyCertificate(
|
|
12
|
+
filePath: string,
|
|
13
|
+
hashFn: HashFn,
|
|
14
|
+
token?: string,
|
|
15
|
+
): Promise<boolean> {
|
|
16
|
+
let raw: string;
|
|
17
|
+
try {
|
|
18
|
+
raw = readFileSync(filePath, 'utf-8');
|
|
19
|
+
} catch {
|
|
20
|
+
error(`Cannot read file: ${filePath}`);
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
let cert: unknown;
|
|
25
|
+
try {
|
|
26
|
+
cert = JSON.parse(raw);
|
|
27
|
+
} catch {
|
|
28
|
+
error('Invalid JSON in certificate file.');
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
if (!isValidCertificate(cert)) {
|
|
33
|
+
error('File is not a valid lastgen certificate.');
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
info(`Verifying certificate ${cert.certificateNumber}...`);
|
|
38
|
+
info(`Developer: ${cert.identity.username}`);
|
|
39
|
+
info('');
|
|
40
|
+
|
|
41
|
+
if (cert.proof.firstCommit.sha) {
|
|
42
|
+
info(`Fetching commit ${cert.proof.firstCommit.sha.slice(0, 7)} from GitHub...`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { valid, results } = await verifyCertificateData(cert, hashFn, token);
|
|
46
|
+
|
|
47
|
+
const out = process.stdout;
|
|
48
|
+
const lines: string[] = [];
|
|
49
|
+
|
|
50
|
+
lines.push(boxRule());
|
|
51
|
+
|
|
52
|
+
const title = style('bold', 'VERIFICATION');
|
|
53
|
+
const titleRaw = 'VERIFICATION';
|
|
54
|
+
const titlePadL = Math.floor((BOX_WIDTH - titleRaw.length) / 2);
|
|
55
|
+
const titlePadR = BOX_WIDTH - titleRaw.length - titlePadL;
|
|
56
|
+
lines.push(boxLine(' '.repeat(titlePadL) + title + ' '.repeat(titlePadR), BOX_WIDTH));
|
|
57
|
+
|
|
58
|
+
lines.push(boxRule());
|
|
59
|
+
|
|
60
|
+
for (const result of results) {
|
|
61
|
+
const icon = result.passed ? style('green', 'PASS') : style('red', 'FAIL');
|
|
62
|
+
const checkLine = `${icon} ${style('bold', result.check)}`;
|
|
63
|
+
lines.push(boxLine(checkLine, 4 + 2 + result.check.length));
|
|
64
|
+
const indent = 6;
|
|
65
|
+
const maxLen = BOX_WIDTH - indent;
|
|
66
|
+
let remaining = result.detail;
|
|
67
|
+
while (remaining.length > 0) {
|
|
68
|
+
const chunk = remaining.slice(0, maxLen);
|
|
69
|
+
remaining = remaining.slice(maxLen);
|
|
70
|
+
const line = ' '.repeat(indent) + style('dim', chunk);
|
|
71
|
+
lines.push(boxLine(line, indent + chunk.length));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
lines.push(boxRule());
|
|
76
|
+
|
|
77
|
+
if (valid) {
|
|
78
|
+
const msg = style('green', 'Certificate is valid.');
|
|
79
|
+
lines.push(boxLine(msg, 21));
|
|
80
|
+
} else {
|
|
81
|
+
const msg = style('red', 'Certificate verification failed.');
|
|
82
|
+
lines.push(boxLine(msg, 32));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
lines.push(boxRule());
|
|
86
|
+
|
|
87
|
+
out.write('\n' + lines.join('\n') + '\n\n');
|
|
88
|
+
return valid;
|
|
89
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
(function(){const e=document.createElement("link").relList;if(e&&e.supports&&e.supports("modulepreload"))return;for(const a of document.querySelectorAll('link[rel="modulepreload"]'))n(a);new MutationObserver(a=>{for(const i of a)if(i.type==="childList")for(const s of i.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&n(s)}).observe(document,{childList:!0,subtree:!0});function o(a){const i={};return a.integrity&&(i.integrity=a.integrity),a.referrerPolicy&&(i.referrerPolicy=a.referrerPolicy),a.crossOrigin==="use-credentials"?i.credentials="include":a.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function n(a){if(a.ep)return;a.ep=!0;const i=o(a);fetch(a.href,i)}})();const V="2025-02-21T00:00:00Z",lt={LAST_GEN:{title:"Last Generation Coder",description:"Wrote code before AI agents shipped"},AI_NATIVE:{title:"AI Native Coder",description:"First verifiable commit after AI agents shipped"}},ft="1.0",et="lastgen_v1",nt=720*60*60*1e3,M="https://api.github.com",pt="lastgen";function mt(t){return{Accept:"application/vnd.github.v3+json","User-Agent":pt}}async function G(t,e,o){const n=await fetch(t,{headers:{...mt(),...o}});if(n.status===403&&n.headers.get("x-ratelimit-remaining")==="0"){const i=n.headers.get("x-ratelimit-reset"),s=i?new Date(Number(i)*1e3).toLocaleTimeString():"soon";throw new Error(`GitHub API rate limit exceeded. Resets at ${s}.`)}if(n.status===404){const a=t.match(/\/users\/([^/?]+)/);throw a!=null&&a[1]?new Error(`GitHub user '${decodeURIComponent(a[1])}' not found. Check the spelling?`):new Error(`Not found: ${t}`)}if(!n.ok)throw new Error(`GitHub API error: ${n.status} ${n.statusText}`);return n}async function ht(t,e){const n=await(await G(`${M}/users/${encodeURIComponent(t)}`)).json();return{login:n.login,id:n.id,name:n.name??null,createdAt:n.created_at}}async function gt(t,e){const o=await wt(t,e);return o!=null&&o.repo&&(o.repoCreatedAt=await yt(o.repo,e)),o}async function yt(t,e){try{return(await(await G(`${M}/repos/${t}`,e)).json()).created_at??void 0}catch{return}}async function _(t,e,o="asc"){try{const n=`${M}/search/commits?q=${encodeURIComponent(t)}&sort=committer-date&order=${o}&per_page=1`,s=(await(await G(n,e,{Accept:"application/vnd.github.cloak-preview+json"})).json()).items,u=s==null?void 0:s[0];if(!u)return null;const l=u.commit,c=l.author,d=l.committer,f=u.repository;return{date:c.date,repo:f.full_name??"",sha:u.sha,message:(l.message??"").split(`
|
|
2
|
+
`)[0]??"",committerDate:(d==null?void 0:d.date)??void 0}}catch{return null}}async function wt(t,e){const o=V.slice(0,10);return await _(`author:${t} user:${t} committer-date:<${o}`,e,"desc")??await _(`author:${t} committer-date:<${o}`,e,"desc")??await _(`author:${t}`,e)}async function Ct(t,e,o){const n=`${M}/repos/${t}/commits/${e}`,i=await(await G(n)).json(),s=i.commit,u=s.author,l=s.committer,c=s.verification,d=i.author,f=i.committer,h=i.parents;return{sha:i.sha,authorLogin:(d==null?void 0:d.login)??null,committerLogin:(f==null?void 0:f.login)??null,authorEmail:u.email??null,authorDate:u.date??null,committerDate:(l==null?void 0:l.date)??null,authorId:(d==null?void 0:d.id)??null,verificationReason:(c==null?void 0:c.reason)??null,isRootCommit:Array.isArray(h)&&h.length===0,message:(s.message??"").split(`
|
|
3
|
+
`)[0]??"",verified:!!(c!=null&&c.verified)}}function Lt(t){const e=new Date(V).getTime();return new Date(t).getTime()<e?"LAST_GEN":"AI_NATIVE"}function ot(t,e){if(!e)return new Date().toISOString();const o=Tt(e),n=new Date(o).getTime(),a=new Date(t.createdAt).getTime(),i=e.repoCreatedAt?new Date(e.repoCreatedAt).getTime():1/0;return n<i?e.repoCreatedAt??t.createdAt:n<a?o:t.createdAt}function Tt(t){if(!t.committerDate)return t.date;const e=new Date(t.date).getTime();return new Date(t.committerDate).getTime()-e>nt?t.committerDate:t.date}async function at(t,e,o,n,a){const i=JSON.stringify({username:e,githubId:o,proofDate:n,era:a,salt:et});return t(i)}function bt(t){const e=t.slice(0,4).toUpperCase(),o=parseInt(t.slice(4,12),16)%1e6,n=String(o).padStart(6,"0");return`LGC-${e}-${n}`}async function Dt(t,e,o){const n=ot(e,o),a=Lt(n),i=await at(t,e.login,e.id,n,a),s=bt(i);return{version:ft,type:"LASTGEN_CERTIFICATE",identity:{username:e.login,githubId:e.id,name:e.name},proof:{accountCreated:e.createdAt,firstCommit:o??{date:e.createdAt,repo:"",sha:"",message:"(no public commits found - using account creation date)"},proofDate:n},era:a,verification:{hash:`sha256:${i}`,salt:et},certificateNumber:s,issuedAt:new Date().toISOString()}}const it=async t=>{const e=new TextEncoder().encode(t),o=await crypto.subtle.digest("SHA-256",e);return Array.from(new Uint8Array(o)).map(a=>a.toString(16).padStart(2,"0")).join("")},$t=300*1e3,j=new Map;function vt(t){const e=t.toLowerCase(),o=j.get(e);return o?Date.now()-o.timestamp>$t?(j.delete(e),null):o.certificate:null}function Et(t,e){j.set(t.toLowerCase(),{certificate:e,timestamp:Date.now()})}async function At(t,e){const o=vt(t);if(o)return e("Using cached result..."),o;e("Fetching GitHub profile...");const n=await ht(t);e("Searching for earliest commit...");const a=await gt(n.login);e("Generating certificate...");const i=await Dt(it,n,a);return Et(t,i),i}const m=50;function w(t){return t.replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")}function L(t){return`<span class="dim">${w(t)}</span>`}function F(t){return`<span class="bold">${w(t)}</span>`}function S(t,e){return`<span class="${t}">${w(e)}</span>`}function b(){return L("+"+"-".repeat(m+2)+"+")}function C(t,e){const o=m-e;return L("|")+" "+t+" ".repeat(Math.max(o,0))+" "+L("|")}function P(){return C("",0)}const k=13;function g(t,e,o){const n=m-k;if(e.length<=n)return[C(L(t)+o,k+e.length)];const a=" ".repeat(k),i=[];let s=e,u=!0;for(;s.length>0;){const l=s.slice(0,n);s=s.slice(n);const c=u?L(t):a;i.push(C(c+w(l),k+l.length)),u=!1}return i}function It(t){const e=t.era==="LAST_GEN",o=lt[t.era],n=[];n.push(b());const a="LASTGEN CERTIFICATE",i=Math.floor((m-a.length)/2),s=m-a.length-i;n.push(C(" ".repeat(i)+F(a)+" ".repeat(s),m)),n.push(b()),n.push(...g("Certificate ",t.certificateNumber,w(t.certificateNumber)));const u=new Date(t.issuedAt).toISOString().slice(0,10);n.push(...g("Issued ",u,w(u))),n.push(P());const l=t.identity.name?`${t.identity.username} (${t.identity.name})`:t.identity.username;n.push(...g("Developer ",l,w(l)));const c=e?"era-lastgen":"era-ainative";if(n.push(...g("Era ",o.title,S(c,o.title))),n.push(...g(" ",o.description,L(o.description))),t.proof.firstCommit.sha){n.push(P());const f=t.proof.firstCommit.repo;n.push(...g("Proof Commit ",f,w(f)));const E=`${t.proof.firstCommit.sha.slice(0,7)} ${t.proof.firstCommit.message.replace(/\n/g," ")}`;n.push(...g(" ",E,L(E)));const r=new Date(t.proof.firstCommit.date).toISOString().slice(0,10);n.push(...g("Commit Date ",r,w(r)))}n.push(P());const d=t.verification.hash;return n.push(...g("Hash ",d,L(d))),n.push(b()),`<pre class="certificate ${c}">${n.join(`
|
|
4
|
+
`)}</pre>`}function St(t,e){const o=[];o.push(b());const n="VERIFICATION",a=Math.floor((m-n.length)/2),i=m-n.length-a;o.push(C(" ".repeat(a)+F(n)+" ".repeat(i),m)),o.push(b());for(const s of t){const l=`${s.passed?S("pass","PASS"):S("fail","FAIL")} ${F(s.check)}`;o.push(C(l,6+s.check.length));const c=6,d=m-c;let f=s.detail;for(;f.length>0;){const h=f.slice(0,d);f=f.slice(d);const E=" ".repeat(c)+L(h);o.push(C(E,c+h.length))}}if(o.push(b()),e){const s=S("pass","Certificate is valid.");o.push(C(s,21))}else{const s=S("fail","Certificate verification failed.");o.push(C(s,32))}return o.push(b()),`<pre class="verification">${o.join(`
|
|
5
|
+
`)}</pre>`}function Nt(t){const e=t.era==="LAST_GEN"?"Last%20Gen":"AI%20Native",o=t.era==="LAST_GEN"?"blue":"brightgreen";return`[](https://github.com/pgagnidze/lastgen)`}async function Rt(t){const e=Nt(t);await navigator.clipboard.writeText(e)}function xt(t){const e=JSON.stringify(t,null,2),o=new Blob([e],{type:"application/json"}),n=URL.createObjectURL(o),a=document.createElement("a");a.href=n,a.download=`lastgen-${t.identity.username}.json`,a.click(),URL.revokeObjectURL(n)}function kt(t){if(typeof t!="object"||t===null)return!1;const e=t;return e.type==="LASTGEN_CERTIFICATE"&&typeof e.version=="string"&&typeof e.identity=="object"&&typeof e.proof=="object"&&typeof e.verification=="object"&&typeof e.certificateNumber=="string"}function Mt(t,e){const o=t.toLowerCase(),n=e.toLowerCase();return new RegExp(`^(\\d+\\+)?${Gt(n)}@users\\.noreply\\.github\\.com$`).test(o)}function Gt(t){return t.replace(/[.*+?^${}()|[\]\\]/g,"\\$&")}async function Ht(t,e,o){const n=[],a=await at(e,t.identity.username,t.identity.githubId,t.proof.proofDate,t.era),i=t.verification.hash.replace("sha256:","");n.push({check:"Hash integrity",passed:a===i,detail:a===i?"Certificate hash is valid":"Certificate hash does not match - data may have been tampered with"});const s=new Date(t.proof.proofDate),u=new Date(V),c=s.getTime()<u.getTime()?"LAST_GEN":"AI_NATIVE";n.push({check:"Era classification",passed:t.era===c,detail:t.era===c?`Era ${t.era} is correct for proof date ${t.proof.proofDate}`:`Era should be ${c} but certificate claims ${t.era}`});const d={login:t.identity.username,id:t.identity.githubId,name:t.identity.name,createdAt:t.proof.accountCreated},f=ot(d,t.proof.firstCommit.sha?t.proof.firstCommit:null),h=Math.abs(new Date(f).getTime()-s.getTime())<6e4;if(n.push({check:"Proof date",passed:h,detail:h?`Proof date ${t.proof.proofDate} is consistent with commit and account data`:`Proof date should be ${f} but certificate claims ${t.proof.proofDate}`}),t.proof.firstCommit.sha)try{const r=await Ct(t.proof.firstCommit.repo,t.proof.firstCommit.sha,o),R=t.identity.username.toLowerCase(),J=(r.authorLogin??"").toLowerCase()===R,q=(r.committerLogin??"").toLowerCase()===R,W=r.authorEmail?Mt(r.authorEmail,t.identity.username):!1,z=J||q||W,x=[];J&&x.push("author login"),q&&x.push("committer login"),W&&x.push("noreply email"),n.push({check:"Identity",passed:z,detail:z?`Matched via: ${x.join(", ")}`:`Commit author (${r.authorLogin}) does not match ${t.identity.username}`});const ct=(t.proof.firstCommit.repo.split("/")[0]??"").toLowerCase()===t.identity.username.toLowerCase();if(n.push({check:"Repo ownership",passed:!0,detail:ct?`Commit is in a repo owned by ${t.identity.username}`:`Commit is in a third-party repo (${t.proof.firstCommit.repo})`}),r.authorId!==null){const T=r.authorId===t.identity.githubId;n.push({check:"GitHub ID",passed:T,detail:T?`GitHub ID ${r.authorId} matches certificate`:`Commit author ID ${r.authorId} does not match certificate ID ${t.identity.githubId}`})}const dt=new Date(r.authorDate??""),ut=new Date(t.proof.firstCommit.date),Y=Math.abs(dt.getTime()-ut.getTime())<6e4;if(n.push({check:"Commit date",passed:Y,detail:Y?`Commit date matches certificate (${r.authorDate})`:`Commit date ${r.authorDate} differs from certificate ${t.proof.firstCommit.date}`}),r.authorDate&&r.committerDate){const T=new Date(r.authorDate).getTime(),H=new Date(r.committerDate).getTime(),Z=Math.abs(H-T),K=Math.round(Z/(1440*60*1e3)),Q=Z<=nt;n.push({check:"Date consistency",passed:Q,detail:Q?`Author/committer date drift: ${K}d (within 30d threshold)`:`Author/committer date drift: ${K}d exceeds 30d - author date may be forged`})}if(r.isRootCommit&&n.push({check:"Root commit",passed:!0,detail:"Commit has no parents (first commit in repo - higher trust)"}),r.verified){const T=r.verificationReason,H=T&&T!=="valid"?` (${T})`:"";n.push({check:"GPG signature",passed:!0,detail:`Commit is GPG-signed${H}`})}}catch(r){const R=r instanceof Error?r.message:String(r);n.push({check:"Commit verification",passed:!1,detail:`Could not fetch commit from GitHub: ${R}`})}return{valid:n.every(r=>r.passed),results:n,certificateNumber:t.certificateNumber,username:t.identity.username}}const p=t=>document.querySelector(t),_t=p("#lookup-form"),N=p("#username-input"),D=p("#status"),O=p("#certificate-output"),X=p("#share-buttons"),A=p("#copy-link"),I=p("#copy-badge"),Pt=p("#download-json"),$=p("#drop-zone"),B=p("#file-input"),y=p("#verify-output");let v=null;function Ot(t){D.textContent=t,D.hidden=!1,D.className="status"}function Ut(t){D.textContent=t,D.hidden=!1,D.className="status error"}function jt(){D.hidden=!0}function tt(t){N.disabled=t,t?N.classList.add("loading"):N.classList.remove("loading")}async function st(t){const e=t.trim();if(e){tt(!0),O.hidden=!0,X.hidden=!0,v=null;try{const o=await At(e,Ot);v=o,O.innerHTML=It(o),O.hidden=!1,X.hidden=!1,jt();const n=new URL(window.location.href);n.searchParams.set("u",e),history.replaceState(null,"",n)}catch(o){const n=o instanceof Error?o.message:String(o);Ut(n)}finally{tt(!1)}}}_t.addEventListener("submit",t=>{t.preventDefault(),st(N.value)});A.addEventListener("click",async()=>{if(v)try{await navigator.clipboard.writeText(window.location.href),A.textContent="copied!",setTimeout(()=>{A.textContent="copy link"},2e3)}catch{A.textContent="failed",setTimeout(()=>{A.textContent="copy link"},2e3)}});I.addEventListener("click",async()=>{if(v)try{await Rt(v),I.textContent="copied!",setTimeout(()=>{I.textContent="copy badge"},2e3)}catch{I.textContent="failed",setTimeout(()=>{I.textContent="copy badge"},2e3)}});Pt.addEventListener("click",()=>{v&&xt(v)});async function rt(t){y.hidden=!0;let e;try{const o=await t.text();e=JSON.parse(o)}catch{y.innerHTML='<p class="error">Invalid JSON file.</p>',y.hidden=!1;return}if(!kt(e)){y.innerHTML='<p class="error">File is not a valid lastgen certificate.</p>',y.hidden=!1;return}y.innerHTML='<p class="status">Verifying...</p>',y.hidden=!1;try{const o=await Ht(e,it);y.innerHTML=St(o.results,o.valid)}catch(o){const n=o instanceof Error?o.message:String(o);y.innerHTML=`<p class="error">${Ft(n)}</p>`}}function Ft(t){const e=document.createElement("div");return e.textContent=t,e.innerHTML}$.addEventListener("click",()=>{B.click()});B.addEventListener("change",()=>{var e;const t=(e=B.files)==null?void 0:e[0];t&&rt(t)});$.addEventListener("dragover",t=>{t.preventDefault(),$.classList.add("dragover")});$.addEventListener("dragleave",()=>{$.classList.remove("dragover")});$.addEventListener("drop",t=>{var o;t.preventDefault(),$.classList.remove("dragover");const e=(o=t.dataTransfer)==null?void 0:o.files[0];e&&rt(e)});const Bt=new URLSearchParams(window.location.search),U=Bt.get("u");U&&(N.value=U,st(U));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}:root{--bg: #0a0a0a;--fg: #e0e0e0;--dim: #666;--blue: #3b82f6;--green: #22c55e;--red: #ef4444;--border: #333;--surface: #111}html{font-size:16px}body{background:var(--bg);color:var(--fg);font-family:JetBrains Mono,monospace;line-height:1.6;min-height:100vh}#app{max-width:640px;margin:0 auto;padding:3rem 1.5rem}header{text-align:center;margin-bottom:3rem}h1{font-size:2rem;font-weight:700;letter-spacing:-.02em;color:var(--fg)}.subtitle{color:var(--dim);font-size:.875rem;margin-top:.25rem}.terminal-input{display:flex;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:.75rem 1rem;font-family:inherit;font-size:1rem;transition:border-color .15s}.terminal-input:focus-within{border-color:var(--blue)}.prompt{color:var(--green);font-weight:700;-webkit-user-select:none;user-select:none;flex-shrink:0;margin-right:.5rem}.terminal-input input{flex:1;background:transparent;border:none;outline:none;color:var(--fg);font-family:inherit;font-size:inherit;caret-color:var(--green)}.terminal-input input::placeholder{color:var(--dim)}.terminal-input input.loading{opacity:.5}.lookup-btn{flex-shrink:0;background:transparent;border:none;color:var(--dim);font-family:inherit;font-size:1.25rem;cursor:pointer;padding:0 .25rem;line-height:1;transition:color .15s}.lookup-btn:hover{color:var(--fg)}.status{text-align:center;color:var(--dim);font-size:.875rem;margin-top:1rem}.status.error{color:var(--red)}#certificate-output{margin-top:2rem}pre.certificate,pre.verification{font-family:JetBrains Mono,monospace;font-size:.8rem;line-height:1.4;overflow-x:auto;padding:0;margin:0 auto;width:fit-content;background:transparent;color:var(--fg)}pre.certificate .dim,pre.verification .dim{color:var(--dim)}pre.certificate .bold,pre.verification .bold{font-weight:700}pre.certificate .era-lastgen,.era-lastgen{color:var(--blue)}pre.certificate .era-ainative,.era-ainative{color:var(--green)}pre.verification .pass{color:var(--green);font-weight:700}pre.verification .fail{color:var(--red);font-weight:700}#share-buttons{display:flex;align-items:center;gap:.5rem;margin-top:1rem;justify-content:center}#share-buttons button{background:none;border:none;color:var(--dim);font-family:inherit;font-size:.75rem;padding:0;cursor:pointer;transition:color .15s}#share-buttons button:hover{color:var(--fg)}.share-sep{color:var(--border);font-size:.75rem}#verify-section{margin-top:3rem}.drop-zone{border:1px dashed var(--border);border-radius:6px;padding:1.25rem;text-align:center;color:var(--dim);font-size:.75rem;cursor:pointer;transition:border-color .15s,color .15s}.drop-zone:hover,.drop-zone.dragover{border-color:var(--blue);color:var(--fg)}.drop-zone-link{color:var(--blue)}.drop-zone input[type=file]{display:none}#verify-output{margin-top:1rem}footer{margin-top:3rem;text-align:center;color:var(--dim);font-size:.75rem}footer a{color:var(--blue);text-decoration:none}footer a:hover{text-decoration:underline}footer code{color:var(--dim);font-family:inherit}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
|
+
<title>lastgen — Are you Last Gen or AI Native?</title>
|
|
7
|
+
<meta name="description" content="Check if you started coding before or after AI agents. Enter a GitHub username to find out." />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
9
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
10
|
+
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
11
|
+
<script type="module" crossorigin src="/lastgen/assets/index-B5YmfSOd.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/lastgen/assets/index-xuvA0IWh.css">
|
|
13
|
+
</head>
|
|
14
|
+
<body>
|
|
15
|
+
<div id="app">
|
|
16
|
+
<header>
|
|
17
|
+
<h1>lastgen</h1>
|
|
18
|
+
<p class="subtitle">Check if you started coding before or after AI agents.</p>
|
|
19
|
+
</header>
|
|
20
|
+
|
|
21
|
+
<main>
|
|
22
|
+
<form id="lookup-form" autocomplete="off">
|
|
23
|
+
<div class="terminal-input">
|
|
24
|
+
<span class="prompt">@ </span>
|
|
25
|
+
<input
|
|
26
|
+
id="username-input"
|
|
27
|
+
type="text"
|
|
28
|
+
placeholder="enter github username"
|
|
29
|
+
spellcheck="false"
|
|
30
|
+
autocomplete="off"
|
|
31
|
+
/>
|
|
32
|
+
<button type="submit" class="lookup-btn" aria-label="Look up">→</button>
|
|
33
|
+
</div>
|
|
34
|
+
</form>
|
|
35
|
+
|
|
36
|
+
<div id="status" class="status" hidden></div>
|
|
37
|
+
<div id="certificate-output" hidden></div>
|
|
38
|
+
<div id="share-buttons" hidden>
|
|
39
|
+
<button id="copy-link" type="button">copy link</button>
|
|
40
|
+
<span class="share-sep">·</span>
|
|
41
|
+
<button id="copy-badge" type="button">copy badge</button>
|
|
42
|
+
<span class="share-sep">·</span>
|
|
43
|
+
<button id="download-json" type="button">download json</button>
|
|
44
|
+
</div>
|
|
45
|
+
|
|
46
|
+
<div id="verify-section">
|
|
47
|
+
<div id="drop-zone" class="drop-zone">
|
|
48
|
+
<p>Drop a certificate JSON here to verify, or <span class="drop-zone-link">browse</span></p>
|
|
49
|
+
<input id="file-input" type="file" accept=".json" />
|
|
50
|
+
</div>
|
|
51
|
+
<div id="verify-output" hidden></div>
|
|
52
|
+
</div>
|
|
53
|
+
</main>
|
|
54
|
+
|
|
55
|
+
<footer>
|
|
56
|
+
<p>
|
|
57
|
+
<a href="https://github.com/pgagnidze/lastgen" target="_blank" rel="noopener">GitHub</a>
|
|
58
|
+
· <code>npx lastgen <username></code>
|
|
59
|
+
</p>
|
|
60
|
+
</footer>
|
|
61
|
+
</div>
|
|
62
|
+
|
|
63
|
+
</body>
|
|
64
|
+
</html>
|