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 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.2",
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-cli';
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 { createHash } from 'node:crypto';
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 createHash('sha256').update(payload).digest('hex');
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(user: GitHubUser, firstCommit: FirstCommit | null): Certificate {
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 {
@@ -84,3 +84,6 @@ export interface VerifyResult {
84
84
  passed: boolean;
85
85
  detail: string;
86
86
  }
87
+
88
+ /** Platform-agnostic SHA-256 hash function. */
89
+ export type HashFn = (data: string) => Promise<string>;
@@ -1,16 +1,14 @@
1
1
  /**
2
- * @fileoverview Certificate verification - re-checks saved proofs against GitHub API.
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 { readFileSync } from 'node:fs';
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 async function verifyCertificate(filePath: string, token?: string): Promise<boolean> {
40
- let raw: string;
41
- try {
42
- raw = readFileSync(filePath, 'utf-8');
43
- } catch {
44
- error(`Cannot read file: ${filePath}`);
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
- matchMethods.push('author login');
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 <= thirtyDaysMs;
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 out = process.stdout;
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
- let allPassed = true;
242
- for (const result of results) {
243
- const icon = result.passed ? style('green', 'PASS') : style('red', 'FAIL');
244
- const checkLine = `${icon} ${style('bold', result.check)}`;
245
- lines.push(boxLine(checkLine, 4 + 2 + result.check.length));
246
- const indent = 6;
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;")}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`[![Last Gen Coder](${`https://img.shields.io/badge/lastgen-${e}-${o}?style=for-the-badge`})](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 &lt;username&gt;</code>
59
+ </p>
60
+ </footer>
61
+ </div>
62
+
63
+ </body>
64
+ </html>