lastgen 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/LICENSE +21 -0
- package/README.md +136 -0
- package/package.json +53 -0
- package/src/cli.ts +166 -0
- package/src/display.ts +142 -0
- package/src/github.ts +179 -0
- package/src/index.ts +12 -0
- package/src/proof.ts +109 -0
- package/src/types.ts +85 -0
- package/src/verify.ts +274 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Papuna Gagnidze
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# lastgen
|
|
4
|
+
|
|
5
|
+
Check if you started coding before or after AI agents.
|
|
6
|
+
|
|
7
|
+
[](LICENSE)
|
|
8
|
+
[](https://nodejs.org)
|
|
9
|
+
[]()
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
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**.
|
|
14
|
+
|
|
15
|
+
> [!IMPORTANT]
|
|
16
|
+
> This is a novelty tool for fun. It is not a measure of skill or credibility.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npx lastgen <username>
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
> [!TIP]
|
|
25
|
+
> Requires Node.js 22.18.0+ (uses native TypeScript execution). No build step needed.
|
|
26
|
+
|
|
27
|
+
For authenticated requests (5,000 req/hour instead of 60):
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
export GITHUB_TOKEN=ghp_your_token_here
|
|
31
|
+
npx lastgen <username>
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Usage
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
# Classify a GitHub user
|
|
38
|
+
npx lastgen torvalds
|
|
39
|
+
|
|
40
|
+
# Save certificate to file
|
|
41
|
+
npx lastgen --json torvalds > proof.json
|
|
42
|
+
|
|
43
|
+
# Verify a saved certificate
|
|
44
|
+
npx lastgen verify proof.json
|
|
45
|
+
|
|
46
|
+
# Get a README badge
|
|
47
|
+
npx lastgen --badge torvalds
|
|
48
|
+
|
|
49
|
+
# JSON output
|
|
50
|
+
npx lastgen --json torvalds
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Options
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
--token <token> GitHub personal access token
|
|
57
|
+
--json Output as JSON
|
|
58
|
+
--badge Output as README badge markdown
|
|
59
|
+
--no-color Disable colors
|
|
60
|
+
-h, --help Show help
|
|
61
|
+
-v, --version Show version
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
### Environment
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
GITHUB_TOKEN GitHub token (alternative to --token)
|
|
68
|
+
NO_COLOR Disable colors (any value)
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Certificate
|
|
72
|
+
|
|
73
|
+
Running `lastgen <username>` generates a certificate like this:
|
|
74
|
+
|
|
75
|
+
```
|
|
76
|
+
+----------------------------------------------------+
|
|
77
|
+
| LASTGEN CERTIFICATE |
|
|
78
|
+
+----------------------------------------------------+
|
|
79
|
+
| Certificate LGC-3476-525342 |
|
|
80
|
+
| Issued 2026-02-19 |
|
|
81
|
+
| |
|
|
82
|
+
| Developer torvalds (Linus Torvalds) |
|
|
83
|
+
| Era Last Generation Coder |
|
|
84
|
+
| Wrote code before AI agents shipped |
|
|
85
|
+
| |
|
|
86
|
+
| Proof Commit torvalds/linux |
|
|
87
|
+
| 319fc77 Merge tag 'bpf-fixes' of git: |
|
|
88
|
+
| //git.kernel.org/pub/scm/linux/kernel |
|
|
89
|
+
| /git/bpf/bpf |
|
|
90
|
+
| Commit Date 2025-02-21 |
|
|
91
|
+
| |
|
|
92
|
+
| Hash sha256:347605d01e5e38124b829c59efc116 |
|
|
93
|
+
| 6bbf97f888429ff65a3cb0d567e3440b61 |
|
|
94
|
+
+----------------------------------------------------+
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Certificates are deterministic - same username always produces the same hash and certificate number.
|
|
98
|
+
|
|
99
|
+
## README Badge
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
npx lastgen --badge <username>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Outputs shields.io markdown you can paste into your README:
|
|
106
|
+
|
|
107
|
+
[](https://github.com/pgagnidze/lastgen)
|
|
108
|
+
|
|
109
|
+
## Verification
|
|
110
|
+
|
|
111
|
+
Saved certificates can be verified against the live GitHub API:
|
|
112
|
+
|
|
113
|
+
```bash
|
|
114
|
+
npx lastgen verify proof.json
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Checks include:
|
|
118
|
+
|
|
119
|
+
| Check | What it does |
|
|
120
|
+
| ---------------------- | --------------------------------------------------------- |
|
|
121
|
+
| **Hash integrity** | Recomputes SHA-256 hash to detect tampering |
|
|
122
|
+
| **Era classification** | Confirms era matches the proof date |
|
|
123
|
+
| **Identity** | 3-way match: author login, committer login, noreply email |
|
|
124
|
+
| **Repo ownership** | Reports whether commit is in a self-owned or third-party repo |
|
|
125
|
+
| **GitHub ID** | Matches commit author ID against certificate |
|
|
126
|
+
| **Commit date** | Fetches the commit from GitHub and compares dates |
|
|
127
|
+
| **Date consistency** | Detects forged author dates via author/committer drift |
|
|
128
|
+
|
|
129
|
+
## License
|
|
130
|
+
|
|
131
|
+
[MIT](LICENSE)
|
|
132
|
+
|
|
133
|
+
---
|
|
134
|
+
|
|
135
|
+
> [!NOTE]
|
|
136
|
+
> This project was built with assistance from LLMs. Human review and guidance provided throughout.
|
package/package.json
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "lastgen",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Check if you started coding before or after AI agents",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lastgen": "./src/index.ts"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=22.18.0"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"typecheck": "tsc --noEmit",
|
|
18
|
+
"lint": "eslint src/",
|
|
19
|
+
"lint:fix": "eslint src/ --fix",
|
|
20
|
+
"format": "prettier --write src/",
|
|
21
|
+
"format:check": "prettier --check src/",
|
|
22
|
+
"test": "node --test src/**/*.test.ts",
|
|
23
|
+
"start": "node src/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"keywords": [
|
|
26
|
+
"developer",
|
|
27
|
+
"credential",
|
|
28
|
+
"github",
|
|
29
|
+
"proof",
|
|
30
|
+
"ai",
|
|
31
|
+
"coder",
|
|
32
|
+
"cli"
|
|
33
|
+
],
|
|
34
|
+
"license": "MIT",
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "https://github.com/pgagnidze/lastgen"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@eslint/js": "^10.0.1",
|
|
41
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
42
|
+
"@semantic-release/git": "^10.0.1",
|
|
43
|
+
"@types/node": "^25.3.0",
|
|
44
|
+
"@typescript-eslint/eslint-plugin": "^8.56.0",
|
|
45
|
+
"@typescript-eslint/parser": "^8.56.0",
|
|
46
|
+
"eslint": "^10.0.0",
|
|
47
|
+
"eslint-config-prettier": "^10.1.8",
|
|
48
|
+
"eslint-plugin-prettier": "^5.5.5",
|
|
49
|
+
"prettier": "^3.8.1",
|
|
50
|
+
"semantic-release": "^25.0.3",
|
|
51
|
+
"typescript": "^5.8.0"
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview CLI argument parsing and command routing using built-in parseArgs.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { parseArgs } from 'node:util';
|
|
6
|
+
import { readFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join } from 'node:path';
|
|
8
|
+
import { fileURLToPath } from 'node:url';
|
|
9
|
+
|
|
10
|
+
import { fetchFirstCommit, fetchUser } from './github.ts';
|
|
11
|
+
import { createCertificate } from './proof.ts';
|
|
12
|
+
import { displayBadgeMarkdown, displayCertificate, displayJson, error, info } from './display.ts';
|
|
13
|
+
import { verifyCertificate } from './verify.ts';
|
|
14
|
+
|
|
15
|
+
function getVersion(): string {
|
|
16
|
+
try {
|
|
17
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
18
|
+
const __dirname = dirname(__filename);
|
|
19
|
+
const packagePath = join(__dirname, '..', 'package.json');
|
|
20
|
+
const pkg = JSON.parse(readFileSync(packagePath, 'utf-8'));
|
|
21
|
+
return pkg.version ?? 'unknown';
|
|
22
|
+
} catch {
|
|
23
|
+
return 'unknown';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const HELP_BRIEF = `
|
|
28
|
+
_ _
|
|
29
|
+
| | __ _ ___ | |_ __ _ ___ _ __
|
|
30
|
+
| |/ _\` / __|| __/ _\` |/ _ \\ '_ \\
|
|
31
|
+
| | (_| \\__ \\| || (_| | __/ | | |
|
|
32
|
+
|_|\\__,_|___/ \\__\\__, |\\___|_| |_|
|
|
33
|
+
|___/
|
|
34
|
+
Check if you started coding before or after AI agents.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
lastgen <username> Classify a GitHub user
|
|
38
|
+
lastgen verify <file.json> Verify a saved certificate
|
|
39
|
+
|
|
40
|
+
Options:
|
|
41
|
+
--token <token> GitHub personal access token
|
|
42
|
+
--json Output as JSON
|
|
43
|
+
--badge Output as README badge markdown
|
|
44
|
+
--no-color Disable colors
|
|
45
|
+
-h, --help Show this help
|
|
46
|
+
-v, --version Show version
|
|
47
|
+
|
|
48
|
+
Environment:
|
|
49
|
+
GITHUB_TOKEN GitHub token (alternative to --token)
|
|
50
|
+
NO_COLOR Disable colors (any value)
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
npx lastgen torvalds
|
|
54
|
+
npx lastgen --json torvalds > proof.json
|
|
55
|
+
npx lastgen verify proof.json
|
|
56
|
+
npx lastgen --badge torvalds
|
|
57
|
+
`;
|
|
58
|
+
|
|
59
|
+
interface CliOptions {
|
|
60
|
+
command: string;
|
|
61
|
+
target: string;
|
|
62
|
+
token?: string;
|
|
63
|
+
json: boolean;
|
|
64
|
+
badge: boolean;
|
|
65
|
+
help: boolean;
|
|
66
|
+
version: boolean;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parseCli(argv: string[]): CliOptions {
|
|
70
|
+
const { values, positionals } = parseArgs({
|
|
71
|
+
args: argv,
|
|
72
|
+
options: {
|
|
73
|
+
token: { type: 'string' },
|
|
74
|
+
json: { type: 'boolean', default: false },
|
|
75
|
+
badge: { type: 'boolean', default: false },
|
|
76
|
+
help: { type: 'boolean', short: 'h', default: false },
|
|
77
|
+
version: { type: 'boolean', short: 'v', default: false },
|
|
78
|
+
'no-color': { type: 'boolean', default: false },
|
|
79
|
+
},
|
|
80
|
+
allowPositionals: true,
|
|
81
|
+
strict: false,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const first = positionals[0] ?? '';
|
|
85
|
+
const isVerify = first === 'verify';
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
command: isVerify ? 'verify' : first ? 'lookup' : '',
|
|
89
|
+
target: isVerify ? (positionals[1] ?? '') : first,
|
|
90
|
+
token: (values.token as string | undefined) ?? process.env['GITHUB_TOKEN'],
|
|
91
|
+
json: Boolean(values.json),
|
|
92
|
+
badge: Boolean(values.badge),
|
|
93
|
+
help: Boolean(values.help),
|
|
94
|
+
version: Boolean(values.version),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export async function run(argv: string[]): Promise<void> {
|
|
99
|
+
const opts = parseCli(argv);
|
|
100
|
+
|
|
101
|
+
if (opts.version) {
|
|
102
|
+
process.stdout.write(`lastgen ${getVersion()}\n`);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (opts.help || !opts.command) {
|
|
107
|
+
process.stdout.write(HELP_BRIEF);
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
switch (opts.command) {
|
|
112
|
+
case 'lookup': {
|
|
113
|
+
await handleLookup(opts);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
case 'verify': {
|
|
117
|
+
await handleVerify(opts);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
default: {
|
|
121
|
+
error(`Unknown command: ${opts.command}`);
|
|
122
|
+
process.stdout.write(HELP_BRIEF);
|
|
123
|
+
process.exitCode = 2;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function handleLookup(opts: CliOptions): Promise<void> {
|
|
129
|
+
if (!opts.target) {
|
|
130
|
+
error('Username required. Usage: lastgen <username>');
|
|
131
|
+
process.exitCode = 2;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (!opts.json && !opts.badge) {
|
|
136
|
+
info(`Looking up ${opts.target} on GitHub...`);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const [user, firstCommit] = await Promise.all([
|
|
140
|
+
fetchUser(opts.target, opts.token),
|
|
141
|
+
fetchFirstCommit(opts.target, opts.token),
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
const cert = createCertificate(user, firstCommit);
|
|
145
|
+
|
|
146
|
+
if (opts.badge) {
|
|
147
|
+
displayBadgeMarkdown(cert);
|
|
148
|
+
} else if (opts.json) {
|
|
149
|
+
displayJson(cert);
|
|
150
|
+
} else {
|
|
151
|
+
displayCertificate(cert);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function handleVerify(opts: CliOptions): Promise<void> {
|
|
156
|
+
if (!opts.target) {
|
|
157
|
+
error('Certificate file required. Usage: lastgen verify <file.json>');
|
|
158
|
+
process.exitCode = 2;
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const valid = await verifyCertificate(opts.target, opts.token);
|
|
163
|
+
if (!valid) {
|
|
164
|
+
process.exitCode = 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
package/src/display.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Terminal output formatting with ASCII box-drawing for certificates and verification.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { styleText } from 'node:util';
|
|
6
|
+
|
|
7
|
+
import type { Certificate } from './types.ts';
|
|
8
|
+
import { ERAS } from './types.ts';
|
|
9
|
+
|
|
10
|
+
function shouldUseColor(): boolean {
|
|
11
|
+
if (process.env['NO_COLOR'] !== undefined) {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
if (process.env['TERM'] === 'dumb') {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
if (process.argv.includes('--no-color')) {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
return process.stdout.isTTY === true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function style(format: string | string[], text: string): string {
|
|
24
|
+
if (!shouldUseColor()) {
|
|
25
|
+
return text;
|
|
26
|
+
}
|
|
27
|
+
return styleText(format as Parameters<typeof styleText>[0], text);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const BOX_WIDTH = 50;
|
|
31
|
+
|
|
32
|
+
export function boxRule(): string {
|
|
33
|
+
return style('dim', '+' + '-'.repeat(BOX_WIDTH + 2) + '+');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function boxLine(content: string, rawLength: number): string {
|
|
37
|
+
const pad = BOX_WIDTH - rawLength;
|
|
38
|
+
return style('dim', '|') + ' ' + content + ' '.repeat(Math.max(pad, 0)) + ' ' + style('dim', '|');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function boxEmpty(): string {
|
|
42
|
+
return boxLine('', 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const LABEL_WIDTH = 13;
|
|
46
|
+
|
|
47
|
+
function labelLine(label: string, value: string, styledValue: string, lines: string[]): void {
|
|
48
|
+
const max = BOX_WIDTH - LABEL_WIDTH;
|
|
49
|
+
if (value.length <= max) {
|
|
50
|
+
lines.push(boxLine(style('dim', label) + styledValue, LABEL_WIDTH + value.length));
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
const indent = ' '.repeat(LABEL_WIDTH);
|
|
54
|
+
let remaining = value;
|
|
55
|
+
let isFirst = true;
|
|
56
|
+
while (remaining.length > 0) {
|
|
57
|
+
const chunk = remaining.slice(0, max);
|
|
58
|
+
remaining = remaining.slice(max);
|
|
59
|
+
const prefix = isFirst ? style('dim', label) : indent;
|
|
60
|
+
lines.push(boxLine(prefix + chunk, LABEL_WIDTH + chunk.length));
|
|
61
|
+
isFirst = false;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function displayCertificate(cert: Certificate): void {
|
|
66
|
+
const out = process.stdout;
|
|
67
|
+
const isLastGen = cert.era === 'LAST_GEN';
|
|
68
|
+
const eraInfo = ERAS[cert.era];
|
|
69
|
+
const lines: string[] = [];
|
|
70
|
+
|
|
71
|
+
lines.push(boxRule());
|
|
72
|
+
|
|
73
|
+
const title = style('bold', 'LASTGEN CERTIFICATE');
|
|
74
|
+
const titleRaw = 'LASTGEN CERTIFICATE';
|
|
75
|
+
const titlePadL = Math.floor((BOX_WIDTH - titleRaw.length) / 2);
|
|
76
|
+
const titlePadR = BOX_WIDTH - titleRaw.length - titlePadL;
|
|
77
|
+
lines.push(boxLine(' '.repeat(titlePadL) + title + ' '.repeat(titlePadR), BOX_WIDTH));
|
|
78
|
+
|
|
79
|
+
lines.push(boxRule());
|
|
80
|
+
|
|
81
|
+
labelLine('Certificate ', cert.certificateNumber, cert.certificateNumber, lines);
|
|
82
|
+
|
|
83
|
+
const issuedDate = new Date(cert.issuedAt).toISOString().slice(0, 10);
|
|
84
|
+
labelLine('Issued ', issuedDate, issuedDate, lines);
|
|
85
|
+
|
|
86
|
+
lines.push(boxEmpty());
|
|
87
|
+
|
|
88
|
+
const devValue = cert.identity.name
|
|
89
|
+
? `${cert.identity.username} (${cert.identity.name})`
|
|
90
|
+
: cert.identity.username;
|
|
91
|
+
labelLine('Developer ', devValue, devValue, lines);
|
|
92
|
+
|
|
93
|
+
const eraColor = isLastGen ? 'green' : 'cyan';
|
|
94
|
+
labelLine('Era ', eraInfo.title, style(eraColor, eraInfo.title), lines);
|
|
95
|
+
labelLine(' ', eraInfo.description, style('dim', eraInfo.description), lines);
|
|
96
|
+
|
|
97
|
+
if (cert.proof.firstCommit.sha) {
|
|
98
|
+
lines.push(boxEmpty());
|
|
99
|
+
|
|
100
|
+
const commitDate = new Date(cert.proof.firstCommit.date).toISOString().slice(0, 10);
|
|
101
|
+
const repo = cert.proof.firstCommit.repo;
|
|
102
|
+
labelLine('Proof Commit ', repo, repo, lines);
|
|
103
|
+
const shaShort = cert.proof.firstCommit.sha.slice(0, 7);
|
|
104
|
+
const commitMsg = `${shaShort} ${cert.proof.firstCommit.message.replace(/\n/g, ' ')}`;
|
|
105
|
+
labelLine(' ', commitMsg, style('dim', commitMsg), lines);
|
|
106
|
+
labelLine('Commit Date ', commitDate, commitDate, lines);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
lines.push(boxEmpty());
|
|
110
|
+
|
|
111
|
+
const hash = cert.verification.hash;
|
|
112
|
+
labelLine('Hash ', hash, style('dim', hash), lines);
|
|
113
|
+
|
|
114
|
+
lines.push(boxRule());
|
|
115
|
+
|
|
116
|
+
out.write('\n' + lines.join('\n') + '\n\n');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function displayBadgeMarkdown(cert: Certificate): void {
|
|
120
|
+
const out = process.stdout;
|
|
121
|
+
const eraLabel = cert.era === 'LAST_GEN' ? 'Last%20Gen' : 'AI%20Native';
|
|
122
|
+
const color = cert.era === 'LAST_GEN' ? 'blue' : 'brightgreen';
|
|
123
|
+
const badgeUrl = `https://img.shields.io/badge/lastgen-${eraLabel}-${color}?style=for-the-badge`;
|
|
124
|
+
|
|
125
|
+
out.write('\n');
|
|
126
|
+
out.write(style('bold', ' Add to your GitHub README:') + '\n');
|
|
127
|
+
out.write('\n');
|
|
128
|
+
out.write(` [](https://github.com/pgagnidze/lastgen)\n`);
|
|
129
|
+
out.write('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function displayJson(cert: Certificate): void {
|
|
133
|
+
process.stdout.write(JSON.stringify(cert, null, 2) + '\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function info(message: string): void {
|
|
137
|
+
process.stderr.write(style('dim', ` ${message}`) + '\n');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function error(message: string): void {
|
|
141
|
+
process.stderr.write(style('red', ` ${message}`) + '\n');
|
|
142
|
+
}
|
package/src/github.ts
ADDED
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview GitHub API client using built-in fetch. Zero dependencies.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { GitHubUser, FirstCommit, CommitDetail } from './types.ts';
|
|
6
|
+
import { CUTOFF_DATE } from './types.ts';
|
|
7
|
+
|
|
8
|
+
const GITHUB_API = 'https://api.github.com';
|
|
9
|
+
const USER_AGENT = 'lastgen-cli';
|
|
10
|
+
|
|
11
|
+
function buildHeaders(token?: string): Record<string, string> {
|
|
12
|
+
const headers: Record<string, string> = {
|
|
13
|
+
Accept: 'application/vnd.github.v3+json',
|
|
14
|
+
'User-Agent': USER_AGENT,
|
|
15
|
+
};
|
|
16
|
+
if (token) {
|
|
17
|
+
headers['Authorization'] = `token ${token}`;
|
|
18
|
+
}
|
|
19
|
+
return headers;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
async function githubFetch(
|
|
23
|
+
url: string,
|
|
24
|
+
token?: string,
|
|
25
|
+
extraHeaders?: Record<string, string>,
|
|
26
|
+
): Promise<Response> {
|
|
27
|
+
const response = await fetch(url, { headers: { ...buildHeaders(token), ...extraHeaders } });
|
|
28
|
+
|
|
29
|
+
if (response.status === 403) {
|
|
30
|
+
const remaining = response.headers.get('x-ratelimit-remaining');
|
|
31
|
+
if (remaining === '0') {
|
|
32
|
+
const resetTimestamp = response.headers.get('x-ratelimit-reset');
|
|
33
|
+
const resetDate = resetTimestamp
|
|
34
|
+
? new Date(Number(resetTimestamp) * 1000).toLocaleTimeString()
|
|
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
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (response.status === 404) {
|
|
44
|
+
const userMatch = url.match(/\/users\/([^/?]+)/);
|
|
45
|
+
if (userMatch?.[1]) {
|
|
46
|
+
throw new Error(
|
|
47
|
+
`GitHub user '${decodeURIComponent(userMatch[1])}' not found. Check the spelling?`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
throw new Error(`Not found: ${url}`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!response.ok) {
|
|
54
|
+
throw new Error(`GitHub API error: ${response.status} ${response.statusText}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return response;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function fetchUser(username: string, token?: string): Promise<GitHubUser> {
|
|
61
|
+
const response = await githubFetch(`${GITHUB_API}/users/${encodeURIComponent(username)}`, token);
|
|
62
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
login: data.login as string,
|
|
66
|
+
id: data.id as number,
|
|
67
|
+
name: (data.name as string | null) ?? null,
|
|
68
|
+
createdAt: data.created_at as string,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function fetchFirstCommit(
|
|
73
|
+
username: string,
|
|
74
|
+
token?: string,
|
|
75
|
+
): Promise<FirstCommit | null> {
|
|
76
|
+
const commit = await searchFirstCommit(username, token);
|
|
77
|
+
|
|
78
|
+
if (commit?.repo) {
|
|
79
|
+
commit.repoCreatedAt = await fetchRepoCreatedAt(commit.repo, token);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return commit;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function fetchRepoCreatedAt(
|
|
86
|
+
repoFullName: string,
|
|
87
|
+
token?: string,
|
|
88
|
+
): Promise<string | undefined> {
|
|
89
|
+
try {
|
|
90
|
+
const response = await githubFetch(`${GITHUB_API}/repos/${repoFullName}`, token);
|
|
91
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
92
|
+
return (data.created_at as string | undefined) ?? undefined;
|
|
93
|
+
} catch {
|
|
94
|
+
return undefined;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function searchFirstCommitByQuery(
|
|
99
|
+
query: string,
|
|
100
|
+
token?: string,
|
|
101
|
+
order: 'asc' | 'desc' = 'asc',
|
|
102
|
+
): Promise<FirstCommit | null> {
|
|
103
|
+
try {
|
|
104
|
+
const url = `${GITHUB_API}/search/commits?q=${encodeURIComponent(query)}&sort=committer-date&order=${order}&per_page=1`;
|
|
105
|
+
const response = await githubFetch(url, token, {
|
|
106
|
+
Accept: 'application/vnd.github.cloak-preview+json',
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
110
|
+
const items = data.items as Array<Record<string, unknown>> | undefined;
|
|
111
|
+
const item = items?.[0];
|
|
112
|
+
if (!item) return null;
|
|
113
|
+
|
|
114
|
+
const commit = item.commit as Record<string, unknown>;
|
|
115
|
+
const author = commit.author as Record<string, unknown>;
|
|
116
|
+
const commitCommitter = commit.committer as Record<string, unknown> | undefined;
|
|
117
|
+
const repo = item.repository as Record<string, unknown>;
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
date: author.date as string,
|
|
121
|
+
repo: (repo.full_name as string) ?? '',
|
|
122
|
+
sha: item.sha as string,
|
|
123
|
+
message: ((commit.message as string) ?? '').split('\n')[0] ?? '',
|
|
124
|
+
committerDate: (commitCommitter?.date as string | undefined) ?? undefined,
|
|
125
|
+
};
|
|
126
|
+
} catch {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function searchFirstCommit(username: string, token?: string): Promise<FirstCommit | null> {
|
|
132
|
+
const cutoffDate = CUTOFF_DATE.slice(0, 10);
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
(await searchFirstCommitByQuery(
|
|
136
|
+
`author:${username} user:${username} committer-date:<${cutoffDate}`,
|
|
137
|
+
token,
|
|
138
|
+
'desc',
|
|
139
|
+
)) ??
|
|
140
|
+
(await searchFirstCommitByQuery(
|
|
141
|
+
`author:${username} committer-date:<${cutoffDate}`,
|
|
142
|
+
token,
|
|
143
|
+
'desc',
|
|
144
|
+
)) ??
|
|
145
|
+
(await searchFirstCommitByQuery(`author:${username}`, token))
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export async function fetchCommit(
|
|
150
|
+
repoFullName: string,
|
|
151
|
+
sha: string,
|
|
152
|
+
token?: string,
|
|
153
|
+
): Promise<CommitDetail> {
|
|
154
|
+
const url = `${GITHUB_API}/repos/${repoFullName}/commits/${sha}`;
|
|
155
|
+
const response = await githubFetch(url, token);
|
|
156
|
+
const data = (await response.json()) as Record<string, unknown>;
|
|
157
|
+
|
|
158
|
+
const commit = data.commit as Record<string, unknown>;
|
|
159
|
+
const commitAuthor = commit.author as Record<string, unknown>;
|
|
160
|
+
const commitCommitter = commit.committer as Record<string, unknown> | undefined;
|
|
161
|
+
const verification = commit.verification as Record<string, unknown> | undefined;
|
|
162
|
+
const author = data.author as Record<string, unknown> | null;
|
|
163
|
+
const committer = data.committer as Record<string, unknown> | null;
|
|
164
|
+
const parents = data.parents as Array<unknown> | undefined;
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
sha: data.sha as string,
|
|
168
|
+
authorLogin: (author?.login as string | undefined) ?? null,
|
|
169
|
+
committerLogin: (committer?.login as string | undefined) ?? null,
|
|
170
|
+
authorEmail: (commitAuthor.email as string | undefined) ?? null,
|
|
171
|
+
authorDate: (commitAuthor.date as string | undefined) ?? null,
|
|
172
|
+
committerDate: (commitCommitter?.date as string | undefined) ?? null,
|
|
173
|
+
authorId: (author?.id as number | undefined) ?? null,
|
|
174
|
+
verificationReason: (verification?.reason as string | undefined) ?? null,
|
|
175
|
+
isRootCommit: Array.isArray(parents) && parents.length === 0,
|
|
176
|
+
message: ((commit.message as string) ?? '').split('\n')[0] ?? '',
|
|
177
|
+
verified: Boolean(verification?.verified),
|
|
178
|
+
};
|
|
179
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { run } from './cli.ts';
|
|
4
|
+
import { error } from './display.ts';
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
await run(process.argv.slice(2));
|
|
8
|
+
} catch (err) {
|
|
9
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
10
|
+
error(message);
|
|
11
|
+
process.exitCode = 1;
|
|
12
|
+
}
|
package/src/proof.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Era classification, hashing, and certificate generation.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { createHash } from 'node:crypto';
|
|
6
|
+
|
|
7
|
+
import type { Certificate, EraKey, FirstCommit, GitHubUser } from './types.ts';
|
|
8
|
+
|
|
9
|
+
import { CERTIFICATE_SALT, CERTIFICATE_VERSION, CUTOFF_DATE } from './types.ts';
|
|
10
|
+
|
|
11
|
+
export function classifyEra(proofDate: string): EraKey {
|
|
12
|
+
const cutoff = new Date(CUTOFF_DATE).getTime();
|
|
13
|
+
const date = new Date(proofDate).getTime();
|
|
14
|
+
return date < cutoff ? 'LAST_GEN' : 'AI_NATIVE';
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
18
|
+
|
|
19
|
+
export function resolveProofDate(user: GitHubUser, firstCommit: FirstCommit | null): string {
|
|
20
|
+
if (!firstCommit) {
|
|
21
|
+
return new Date().toISOString();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const effectiveDate = getEffectiveCommitDate(firstCommit);
|
|
25
|
+
const commitTime = new Date(effectiveDate).getTime();
|
|
26
|
+
const accountTime = new Date(user.createdAt).getTime();
|
|
27
|
+
const repoTime = firstCommit.repoCreatedAt
|
|
28
|
+
? new Date(firstCommit.repoCreatedAt).getTime()
|
|
29
|
+
: Infinity;
|
|
30
|
+
|
|
31
|
+
if (commitTime < repoTime) {
|
|
32
|
+
return firstCommit.repoCreatedAt ?? user.createdAt;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return commitTime < accountTime ? effectiveDate : user.createdAt;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function getEffectiveCommitDate(commit: FirstCommit): string {
|
|
39
|
+
if (!commit.committerDate) {
|
|
40
|
+
return commit.date;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const authorTime = new Date(commit.date).getTime();
|
|
44
|
+
const committerTime = new Date(commit.committerDate).getTime();
|
|
45
|
+
|
|
46
|
+
if (committerTime - authorTime > THIRTY_DAYS_MS) {
|
|
47
|
+
return commit.committerDate;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return commit.date;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function generateCertificateHash(
|
|
54
|
+
username: string,
|
|
55
|
+
githubId: number,
|
|
56
|
+
proofDate: string,
|
|
57
|
+
era: EraKey,
|
|
58
|
+
): string {
|
|
59
|
+
const payload = JSON.stringify({
|
|
60
|
+
username,
|
|
61
|
+
githubId,
|
|
62
|
+
proofDate,
|
|
63
|
+
era,
|
|
64
|
+
salt: CERTIFICATE_SALT,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
return createHash('sha256').update(payload).digest('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function generateCertificateNumber(hash: string): string {
|
|
71
|
+
const prefix = hash.slice(0, 4).toUpperCase();
|
|
72
|
+
const numericPart = parseInt(hash.slice(4, 12), 16) % 1000000;
|
|
73
|
+
const padded = String(numericPart).padStart(6, '0');
|
|
74
|
+
return `LGC-${prefix}-${padded}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function createCertificate(user: GitHubUser, firstCommit: FirstCommit | null): Certificate {
|
|
78
|
+
const proofDate = resolveProofDate(user, firstCommit);
|
|
79
|
+
const era = classifyEra(proofDate);
|
|
80
|
+
const hash = generateCertificateHash(user.login, user.id, proofDate, era);
|
|
81
|
+
const certificateNumber = generateCertificateNumber(hash);
|
|
82
|
+
|
|
83
|
+
return {
|
|
84
|
+
version: CERTIFICATE_VERSION,
|
|
85
|
+
type: 'LASTGEN_CERTIFICATE',
|
|
86
|
+
identity: {
|
|
87
|
+
username: user.login,
|
|
88
|
+
githubId: user.id,
|
|
89
|
+
name: user.name,
|
|
90
|
+
},
|
|
91
|
+
proof: {
|
|
92
|
+
accountCreated: user.createdAt,
|
|
93
|
+
firstCommit: firstCommit ?? {
|
|
94
|
+
date: user.createdAt,
|
|
95
|
+
repo: '',
|
|
96
|
+
sha: '',
|
|
97
|
+
message: '(no public commits found - using account creation date)',
|
|
98
|
+
},
|
|
99
|
+
proofDate,
|
|
100
|
+
},
|
|
101
|
+
era,
|
|
102
|
+
verification: {
|
|
103
|
+
hash: `sha256:${hash}`,
|
|
104
|
+
salt: CERTIFICATE_SALT,
|
|
105
|
+
},
|
|
106
|
+
certificateNumber,
|
|
107
|
+
issuedAt: new Date().toISOString(),
|
|
108
|
+
};
|
|
109
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Shared interfaces, constants, and type definitions for lastgen.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const CUTOFF_DATE = '2025-02-21T00:00:00Z';
|
|
6
|
+
|
|
7
|
+
export const ERAS = {
|
|
8
|
+
LAST_GEN: {
|
|
9
|
+
title: 'Last Generation Coder',
|
|
10
|
+
description: 'Wrote code before AI agents shipped',
|
|
11
|
+
},
|
|
12
|
+
AI_NATIVE: {
|
|
13
|
+
title: 'AI Native Coder',
|
|
14
|
+
description: 'First verifiable commit after AI agents shipped',
|
|
15
|
+
},
|
|
16
|
+
} as const;
|
|
17
|
+
|
|
18
|
+
export type EraKey = keyof typeof ERAS;
|
|
19
|
+
|
|
20
|
+
export const CERTIFICATE_VERSION = '1.0';
|
|
21
|
+
export const CERTIFICATE_SALT = 'lastgen_v1';
|
|
22
|
+
|
|
23
|
+
export interface GitHubUser {
|
|
24
|
+
login: string;
|
|
25
|
+
id: number;
|
|
26
|
+
name: string | null;
|
|
27
|
+
createdAt: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface FirstCommit {
|
|
31
|
+
date: string;
|
|
32
|
+
repo: string;
|
|
33
|
+
sha: string;
|
|
34
|
+
message: string;
|
|
35
|
+
repoCreatedAt?: string;
|
|
36
|
+
committerDate?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface CertificateIdentity {
|
|
40
|
+
username: string;
|
|
41
|
+
githubId: number;
|
|
42
|
+
name: string | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface CertificateProof {
|
|
46
|
+
accountCreated: string;
|
|
47
|
+
firstCommit: FirstCommit;
|
|
48
|
+
proofDate: string;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface CertificateVerification {
|
|
52
|
+
hash: string;
|
|
53
|
+
salt: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface Certificate {
|
|
57
|
+
version: string;
|
|
58
|
+
type: 'LASTGEN_CERTIFICATE';
|
|
59
|
+
identity: CertificateIdentity;
|
|
60
|
+
proof: CertificateProof;
|
|
61
|
+
era: EraKey;
|
|
62
|
+
verification: CertificateVerification;
|
|
63
|
+
certificateNumber: string;
|
|
64
|
+
issuedAt: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface CommitDetail {
|
|
68
|
+
sha: string;
|
|
69
|
+
authorLogin: string | null;
|
|
70
|
+
committerLogin: string | null;
|
|
71
|
+
authorEmail: string | null;
|
|
72
|
+
authorDate: string | null;
|
|
73
|
+
committerDate: string | null;
|
|
74
|
+
authorId: number | null;
|
|
75
|
+
verificationReason: string | null;
|
|
76
|
+
isRootCommit: boolean;
|
|
77
|
+
message: string;
|
|
78
|
+
verified: boolean;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface VerifyResult {
|
|
82
|
+
check: string;
|
|
83
|
+
passed: boolean;
|
|
84
|
+
detail: string;
|
|
85
|
+
}
|
package/src/verify.ts
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Certificate verification - re-checks saved proofs against GitHub API.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { readFileSync } from 'node:fs';
|
|
6
|
+
|
|
7
|
+
import type { Certificate, VerifyResult } from './types.ts';
|
|
8
|
+
import { CUTOFF_DATE } from './types.ts';
|
|
9
|
+
import { fetchCommit } from './github.ts';
|
|
10
|
+
import { generateCertificateHash, resolveProofDate } from './proof.ts';
|
|
11
|
+
import { BOX_WIDTH, boxLine, boxRule, error, info, style } from './display.ts';
|
|
12
|
+
|
|
13
|
+
function isValidCertificate(data: unknown): data is Certificate {
|
|
14
|
+
if (typeof data !== 'object' || data === null) {
|
|
15
|
+
return false;
|
|
16
|
+
}
|
|
17
|
+
const cert = data as Record<string, unknown>;
|
|
18
|
+
return (
|
|
19
|
+
cert.type === 'LASTGEN_CERTIFICATE' &&
|
|
20
|
+
typeof cert.version === 'string' &&
|
|
21
|
+
typeof cert.identity === 'object' &&
|
|
22
|
+
typeof cert.proof === 'object' &&
|
|
23
|
+
typeof cert.verification === 'object' &&
|
|
24
|
+
typeof cert.certificateNumber === 'string'
|
|
25
|
+
);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function matchesNoreplyEmail(email: string, username: string): boolean {
|
|
29
|
+
const lower = email.toLowerCase();
|
|
30
|
+
const user = username.toLowerCase();
|
|
31
|
+
const pattern = new RegExp(`^(\\d+\\+)?${escapeRegex(user)}@users\\.noreply\\.github\\.com$`);
|
|
32
|
+
return pattern.test(lower);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function escapeRegex(str: string): string {
|
|
36
|
+
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
37
|
+
}
|
|
38
|
+
|
|
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('');
|
|
64
|
+
|
|
65
|
+
const results: VerifyResult[] = [];
|
|
66
|
+
|
|
67
|
+
const expectedHash = generateCertificateHash(
|
|
68
|
+
cert.identity.username,
|
|
69
|
+
cert.identity.githubId,
|
|
70
|
+
cert.proof.proofDate,
|
|
71
|
+
cert.era,
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
const actualHash = cert.verification.hash.replace('sha256:', '');
|
|
75
|
+
results.push({
|
|
76
|
+
check: 'Hash integrity',
|
|
77
|
+
passed: expectedHash === actualHash,
|
|
78
|
+
detail:
|
|
79
|
+
expectedHash === actualHash
|
|
80
|
+
? 'Certificate hash is valid'
|
|
81
|
+
: 'Certificate hash does not match - data may have been tampered with',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
const proofDate = new Date(cert.proof.proofDate);
|
|
85
|
+
const cutoff = new Date(CUTOFF_DATE);
|
|
86
|
+
const isBeforeCutoff = proofDate.getTime() < cutoff.getTime();
|
|
87
|
+
const expectedEra = isBeforeCutoff ? 'LAST_GEN' : 'AI_NATIVE';
|
|
88
|
+
results.push({
|
|
89
|
+
check: 'Era classification',
|
|
90
|
+
passed: cert.era === expectedEra,
|
|
91
|
+
detail:
|
|
92
|
+
cert.era === expectedEra
|
|
93
|
+
? `Era ${cert.era} is correct for proof date ${cert.proof.proofDate}`
|
|
94
|
+
: `Era should be ${expectedEra} but certificate claims ${cert.era}`,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
const reconstructedUser = {
|
|
98
|
+
login: cert.identity.username,
|
|
99
|
+
id: cert.identity.githubId,
|
|
100
|
+
name: cert.identity.name,
|
|
101
|
+
createdAt: cert.proof.accountCreated,
|
|
102
|
+
};
|
|
103
|
+
const expectedProofDate = resolveProofDate(
|
|
104
|
+
reconstructedUser,
|
|
105
|
+
cert.proof.firstCommit.sha ? cert.proof.firstCommit : null,
|
|
106
|
+
);
|
|
107
|
+
const proofDateMatch =
|
|
108
|
+
Math.abs(new Date(expectedProofDate).getTime() - proofDate.getTime()) < 60000;
|
|
109
|
+
results.push({
|
|
110
|
+
check: 'Proof date',
|
|
111
|
+
passed: proofDateMatch,
|
|
112
|
+
detail: proofDateMatch
|
|
113
|
+
? `Proof date ${cert.proof.proofDate} is consistent with commit and account data`
|
|
114
|
+
: `Proof date should be ${expectedProofDate} but certificate claims ${cert.proof.proofDate}`,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
if (cert.proof.firstCommit.sha) {
|
|
118
|
+
try {
|
|
119
|
+
info(`Fetching commit ${cert.proof.firstCommit.sha.slice(0, 7)} from GitHub...`);
|
|
120
|
+
const commitDetail = await fetchCommit(
|
|
121
|
+
cert.proof.firstCommit.repo,
|
|
122
|
+
cert.proof.firstCommit.sha,
|
|
123
|
+
token,
|
|
124
|
+
);
|
|
125
|
+
|
|
126
|
+
const username = cert.identity.username.toLowerCase();
|
|
127
|
+
const authorMatch = (commitDetail.authorLogin ?? '').toLowerCase() === username;
|
|
128
|
+
const committerMatch = (commitDetail.committerLogin ?? '').toLowerCase() === username;
|
|
129
|
+
const emailMatch = commitDetail.authorEmail
|
|
130
|
+
? matchesNoreplyEmail(commitDetail.authorEmail, cert.identity.username)
|
|
131
|
+
: false;
|
|
132
|
+
|
|
133
|
+
const identityMatch = authorMatch || committerMatch || emailMatch;
|
|
134
|
+
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
|
+
}
|
|
144
|
+
|
|
145
|
+
results.push({
|
|
146
|
+
check: 'Identity',
|
|
147
|
+
passed: identityMatch,
|
|
148
|
+
detail: identityMatch
|
|
149
|
+
? `Matched via: ${matchMethods.join(', ')}`
|
|
150
|
+
: `Commit author (${commitDetail.authorLogin}) does not match ${cert.identity.username}`,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
const repoOwner = cert.proof.firstCommit.repo.split('/')[0] ?? '';
|
|
154
|
+
const isSelfOwned = repoOwner.toLowerCase() === cert.identity.username.toLowerCase();
|
|
155
|
+
results.push({
|
|
156
|
+
check: 'Repo ownership',
|
|
157
|
+
passed: true,
|
|
158
|
+
detail: isSelfOwned
|
|
159
|
+
? `Commit is in a repo owned by ${cert.identity.username}`
|
|
160
|
+
: `Commit is in a third-party repo (${cert.proof.firstCommit.repo})`,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
if (commitDetail.authorId !== null) {
|
|
164
|
+
const idMatch = commitDetail.authorId === cert.identity.githubId;
|
|
165
|
+
results.push({
|
|
166
|
+
check: 'GitHub ID',
|
|
167
|
+
passed: idMatch,
|
|
168
|
+
detail: idMatch
|
|
169
|
+
? `GitHub ID ${commitDetail.authorId} matches certificate`
|
|
170
|
+
: `Commit author ID ${commitDetail.authorId} does not match certificate ID ${cert.identity.githubId}`,
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const commitDate = new Date(commitDetail.authorDate ?? '');
|
|
175
|
+
const certDate = new Date(cert.proof.firstCommit.date);
|
|
176
|
+
const datesClose = Math.abs(commitDate.getTime() - certDate.getTime()) < 60000;
|
|
177
|
+
results.push({
|
|
178
|
+
check: 'Commit date',
|
|
179
|
+
passed: datesClose,
|
|
180
|
+
detail: datesClose
|
|
181
|
+
? `Commit date matches certificate (${commitDetail.authorDate})`
|
|
182
|
+
: `Commit date ${commitDetail.authorDate} differs from certificate ${cert.proof.firstCommit.date}`,
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
if (commitDetail.authorDate && commitDetail.committerDate) {
|
|
186
|
+
const authorTime = new Date(commitDetail.authorDate).getTime();
|
|
187
|
+
const committerTime = new Date(commitDetail.committerDate).getTime();
|
|
188
|
+
const driftMs = Math.abs(committerTime - authorTime);
|
|
189
|
+
const thirtyDaysMs = 30 * 24 * 60 * 60 * 1000;
|
|
190
|
+
const driftDays = Math.round(driftMs / (24 * 60 * 60 * 1000));
|
|
191
|
+
const consistent = driftMs <= thirtyDaysMs;
|
|
192
|
+
results.push({
|
|
193
|
+
check: 'Date consistency',
|
|
194
|
+
passed: consistent,
|
|
195
|
+
detail: consistent
|
|
196
|
+
? `Author/committer date drift: ${driftDays}d (within 30d threshold)`
|
|
197
|
+
: `Author/committer date drift: ${driftDays}d exceeds 30d - author date may be forged`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (commitDetail.isRootCommit) {
|
|
202
|
+
results.push({
|
|
203
|
+
check: 'Root commit',
|
|
204
|
+
passed: true,
|
|
205
|
+
detail: 'Commit has no parents (first commit in repo - higher trust)',
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (commitDetail.verified) {
|
|
210
|
+
const reason = commitDetail.verificationReason;
|
|
211
|
+
const reasonDetail = reason && reason !== 'valid' ? ` (${reason})` : '';
|
|
212
|
+
results.push({
|
|
213
|
+
check: 'GPG signature',
|
|
214
|
+
passed: true,
|
|
215
|
+
detail: `Commit is GPG-signed${reasonDetail}`,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
} catch (fetchError) {
|
|
219
|
+
const message = fetchError instanceof Error ? fetchError.message : String(fetchError);
|
|
220
|
+
results.push({
|
|
221
|
+
check: 'Commit verification',
|
|
222
|
+
passed: false,
|
|
223
|
+
detail: `Could not fetch commit from GitHub: ${message}`,
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
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());
|
|
240
|
+
|
|
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;
|
|
274
|
+
}
|