@wilsong/shiplog 0.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 +120 -0
- package/dist/chunk-5DFHNS4N.js +200 -0
- package/dist/cli.cjs +306 -0
- package/dist/cli.d.cts +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +96 -0
- package/dist/index.cjs +226 -0
- package/dist/index.d.cts +76 -0
- package/dist/index.d.ts +76 -0
- package/dist/index.js +6 -0
- package/package.json +45 -0
- package/src/cli.ts +144 -0
- package/src/index.ts +266 -0
- package/tsconfig.json +17 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ShipLog — Session reports for vibe-coding developers.
|
|
3
|
+
* "What did your AI build while you were at the gym?"
|
|
4
|
+
*
|
|
5
|
+
* Analyzes git commits, generates branded HTML email summaries,
|
|
6
|
+
* and sends them via Resend. Works with any AI coding tool.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import simpleGit, { SimpleGit, LogResult } from "simple-git";
|
|
10
|
+
import { Resend } from "resend";
|
|
11
|
+
|
|
12
|
+
export interface ShipLogConfig {
|
|
13
|
+
/** Email address to send the report to */
|
|
14
|
+
email: string;
|
|
15
|
+
/** CC recipients (team, investors, clients) */
|
|
16
|
+
cc?: string[];
|
|
17
|
+
/** Resend API key for email delivery */
|
|
18
|
+
resendApiKey: string;
|
|
19
|
+
/** Brand name shown in email header */
|
|
20
|
+
brandName?: string;
|
|
21
|
+
/** Brand color (hex) for email accent */
|
|
22
|
+
brandColor?: string;
|
|
23
|
+
/** From email address (must be verified in Resend) */
|
|
24
|
+
fromEmail?: string;
|
|
25
|
+
/** Git repo path (defaults to cwd) */
|
|
26
|
+
repoPath?: string;
|
|
27
|
+
/** Include LinkedIn post draft in email */
|
|
28
|
+
includeLinkedInDraft?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface SessionReport {
|
|
32
|
+
totalCommits: number;
|
|
33
|
+
filesChanged: number;
|
|
34
|
+
insertions: number;
|
|
35
|
+
deletions: number;
|
|
36
|
+
features: string[];
|
|
37
|
+
fixes: string[];
|
|
38
|
+
refactors: string[];
|
|
39
|
+
other: string[];
|
|
40
|
+
authors: Record<string, number>;
|
|
41
|
+
directories: Record<string, number>;
|
|
42
|
+
timeRange: { from: string; to: string };
|
|
43
|
+
duration: string;
|
|
44
|
+
linkedInDraft?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Categorize a commit message into feature/fix/refactor/other
|
|
49
|
+
*/
|
|
50
|
+
function categorizeCommit(message: string): "feature" | "fix" | "refactor" | "other" {
|
|
51
|
+
const lower = message.toLowerCase();
|
|
52
|
+
if (lower.startsWith("feat") || lower.includes("add") || lower.includes("implement") || lower.includes("ship")) return "feature";
|
|
53
|
+
if (lower.startsWith("fix") || lower.includes("bug") || lower.includes("patch") || lower.includes("hotfix")) return "fix";
|
|
54
|
+
if (lower.startsWith("refactor") || lower.includes("cleanup") || lower.includes("reorganize") || lower.includes("rename")) return "refactor";
|
|
55
|
+
return "other";
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Calculate human-readable duration between two dates
|
|
60
|
+
*/
|
|
61
|
+
function humanDuration(from: Date, to: Date): string {
|
|
62
|
+
const diffMs = to.getTime() - from.getTime();
|
|
63
|
+
const hours = Math.floor(diffMs / 3600000);
|
|
64
|
+
const minutes = Math.floor((diffMs % 3600000) / 60000);
|
|
65
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
66
|
+
return `${minutes}m`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export class ShipLog {
|
|
70
|
+
private git: SimpleGit;
|
|
71
|
+
private resend: Resend;
|
|
72
|
+
private config: Required<
|
|
73
|
+
Pick<ShipLogConfig, "email" | "resendApiKey" | "brandName" | "brandColor" | "fromEmail">
|
|
74
|
+
> & ShipLogConfig;
|
|
75
|
+
|
|
76
|
+
constructor(config: ShipLogConfig) {
|
|
77
|
+
this.config = {
|
|
78
|
+
brandName: "ShipLog",
|
|
79
|
+
brandColor: "#0077B5",
|
|
80
|
+
fromEmail: "ShipLog <updates@shiplog.dev>",
|
|
81
|
+
...config,
|
|
82
|
+
};
|
|
83
|
+
this.git = simpleGit(config.repoPath || process.cwd());
|
|
84
|
+
this.resend = new Resend(config.resendApiKey);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Analyze git history since a given time and return a structured report
|
|
89
|
+
*/
|
|
90
|
+
async analyze(options: { since?: string; branch?: string } = {}): Promise<SessionReport> {
|
|
91
|
+
const since = options.since || "8 hours ago";
|
|
92
|
+
const branch = options.branch || "HEAD";
|
|
93
|
+
|
|
94
|
+
// Get commits since the given time
|
|
95
|
+
const log: LogResult = await this.git.log({
|
|
96
|
+
from: "",
|
|
97
|
+
to: branch,
|
|
98
|
+
"--since": since,
|
|
99
|
+
"--stat": null,
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
const features: string[] = [];
|
|
103
|
+
const fixes: string[] = [];
|
|
104
|
+
const refactors: string[] = [];
|
|
105
|
+
const other: string[] = [];
|
|
106
|
+
const authors: Record<string, number> = {};
|
|
107
|
+
const directories: Record<string, number> = {};
|
|
108
|
+
let totalInsertions = 0;
|
|
109
|
+
let totalDeletions = 0;
|
|
110
|
+
let totalFilesChanged = 0;
|
|
111
|
+
|
|
112
|
+
for (const commit of log.all) {
|
|
113
|
+
const msg = commit.message.split("\n")[0]; // First line only
|
|
114
|
+
const category = categorizeCommit(msg);
|
|
115
|
+
|
|
116
|
+
switch (category) {
|
|
117
|
+
case "feature": features.push(msg); break;
|
|
118
|
+
case "fix": fixes.push(msg); break;
|
|
119
|
+
case "refactor": refactors.push(msg); break;
|
|
120
|
+
default: other.push(msg); break;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Track authors
|
|
124
|
+
const author = commit.author_name || "Unknown";
|
|
125
|
+
authors[author] = (authors[author] || 0) + 1;
|
|
126
|
+
|
|
127
|
+
// Parse diff stat from commit body
|
|
128
|
+
const diffStat = commit.diff?.changed || 0;
|
|
129
|
+
const ins = commit.diff?.insertions || 0;
|
|
130
|
+
const del = commit.diff?.deletions || 0;
|
|
131
|
+
totalFilesChanged += diffStat;
|
|
132
|
+
totalInsertions += ins;
|
|
133
|
+
totalDeletions += del;
|
|
134
|
+
|
|
135
|
+
// Track directories from changed files
|
|
136
|
+
if (commit.diff?.files) {
|
|
137
|
+
for (const file of commit.diff.files) {
|
|
138
|
+
const dir = file.file.split("/").slice(0, 2).join("/");
|
|
139
|
+
directories[dir] = (directories[dir] || 0) + 1;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const commits = log.all;
|
|
145
|
+
const from = commits.length > 0 ? commits[commits.length - 1].date : new Date().toISOString();
|
|
146
|
+
const to = commits.length > 0 ? commits[0].date : new Date().toISOString();
|
|
147
|
+
const duration = humanDuration(new Date(from), new Date(to));
|
|
148
|
+
|
|
149
|
+
const report: SessionReport = {
|
|
150
|
+
totalCommits: log.total,
|
|
151
|
+
filesChanged: totalFilesChanged,
|
|
152
|
+
insertions: totalInsertions,
|
|
153
|
+
deletions: totalDeletions,
|
|
154
|
+
features,
|
|
155
|
+
fixes,
|
|
156
|
+
refactors,
|
|
157
|
+
other,
|
|
158
|
+
authors,
|
|
159
|
+
directories,
|
|
160
|
+
timeRange: { from, to },
|
|
161
|
+
duration,
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Generate LinkedIn draft if requested
|
|
165
|
+
if (this.config.includeLinkedInDraft) {
|
|
166
|
+
report.linkedInDraft = this.generateLinkedInDraft(report);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return report;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Generate a branded HTML email from a session report
|
|
174
|
+
*/
|
|
175
|
+
buildEmail(report: SessionReport): string {
|
|
176
|
+
const { brandName, brandColor } = this.config;
|
|
177
|
+
const featureList = report.features.map(f => `<li>${escapeHtml(f)}</li>`).join("");
|
|
178
|
+
const fixList = report.fixes.map(f => `<li>${escapeHtml(f)}</li>`).join("");
|
|
179
|
+
const refactorList = report.refactors.map(f => `<li>${escapeHtml(f)}</li>`).join("");
|
|
180
|
+
const otherList = report.other.map(f => `<li>${escapeHtml(f)}</li>`).join("");
|
|
181
|
+
|
|
182
|
+
const sections: string[] = [];
|
|
183
|
+
if (report.features.length > 0) sections.push(`<h2 style="color:#22c55e;border-bottom:2px solid #22c55e;padding-bottom:4px">✨ Features (${report.features.length})</h2><ul style="font-size:13px">${featureList}</ul>`);
|
|
184
|
+
if (report.fixes.length > 0) sections.push(`<h2 style="color:#ef4444;border-bottom:2px solid #ef4444;padding-bottom:4px">🔧 Fixes (${report.fixes.length})</h2><ul style="font-size:13px">${fixList}</ul>`);
|
|
185
|
+
if (report.refactors.length > 0) sections.push(`<h2 style="color:#8b5cf6;border-bottom:2px solid #8b5cf6;padding-bottom:4px">♻️ Refactors (${report.refactors.length})</h2><ul style="font-size:13px">${refactorList}</ul>`);
|
|
186
|
+
if (report.other.length > 0) sections.push(`<h2 style="color:#6b7280;border-bottom:2px solid #6b7280;padding-bottom:4px">📝 Other (${report.other.length})</h2><ul style="font-size:13px">${otherList}</ul>`);
|
|
187
|
+
|
|
188
|
+
const authorRows = Object.entries(report.authors).map(([name, count]) => `<span style="margin-right:12px">${escapeHtml(name)}: <strong>${count}</strong></span>`).join("");
|
|
189
|
+
|
|
190
|
+
return `<div style="font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a2e">
|
|
191
|
+
<div style="background:${brandColor};color:white;padding:16px 20px;border-radius:12px 12px 0 0">
|
|
192
|
+
<h1 style="margin:0;font-size:20px">${escapeHtml(brandName)} — Session Report</h1>
|
|
193
|
+
<p style="margin:4px 0 0;opacity:0.85;font-size:13px">${report.timeRange.from.split("T")[0]} • ${report.duration} session</p>
|
|
194
|
+
</div>
|
|
195
|
+
|
|
196
|
+
<div style="background:#f8fafc;padding:16px 20px;border:1px solid #e2e8f0;border-top:none">
|
|
197
|
+
<div style="display:flex;gap:20px;text-align:center">
|
|
198
|
+
<div><div style="font-size:28px;font-weight:bold;color:${brandColor}">${report.totalCommits}</div><div style="font-size:11px;color:#64748b">Commits</div></div>
|
|
199
|
+
<div><div style="font-size:28px;font-weight:bold;color:#22c55e">${report.features.length}</div><div style="font-size:11px;color:#64748b">Features</div></div>
|
|
200
|
+
<div><div style="font-size:28px;font-weight:bold;color:#ef4444">${report.fixes.length}</div><div style="font-size:11px;color:#64748b">Fixes</div></div>
|
|
201
|
+
<div><div style="font-size:28px;font-weight:bold;color:#64748b">+${report.insertions}/-${report.deletions}</div><div style="font-size:11px;color:#64748b">Lines</div></div>
|
|
202
|
+
</div>
|
|
203
|
+
</div>
|
|
204
|
+
|
|
205
|
+
<div style="padding:16px 20px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
206
|
+
${sections.join("\n")}
|
|
207
|
+
<div style="margin-top:16px;padding:12px;background:#f1f5f9;border-radius:8px;font-size:12px;color:#475569">
|
|
208
|
+
<strong>Authors:</strong> ${authorRows}
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
|
|
212
|
+
<p style="color:#94a3b8;font-size:10px;text-align:center;margin-top:16px">Powered by <a href="https://shiplog.dev" style="color:${brandColor}">ShipLog</a> — session reports for vibe-coding developers</p>
|
|
213
|
+
</div>`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Analyze git history and send the email report
|
|
218
|
+
*/
|
|
219
|
+
async analyzeAndSend(options: { since?: string; branch?: string } = {}): Promise<{
|
|
220
|
+
report: SessionReport;
|
|
221
|
+
emailId: string;
|
|
222
|
+
}> {
|
|
223
|
+
const report = await this.analyze(options);
|
|
224
|
+
|
|
225
|
+
if (report.totalCommits === 0) {
|
|
226
|
+
throw new Error("No commits found in the specified time range");
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const html = this.buildEmail(report);
|
|
230
|
+
const subject = `${this.config.brandName}: ${report.totalCommits} commits — ${report.features.length} features, ${report.fixes.length} fixes`;
|
|
231
|
+
|
|
232
|
+
const { data, error } = await this.resend.emails.send({
|
|
233
|
+
from: this.config.fromEmail,
|
|
234
|
+
to: this.config.email,
|
|
235
|
+
cc: this.config.cc,
|
|
236
|
+
subject,
|
|
237
|
+
html,
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
if (error) throw new Error(`Email send failed: ${error.message}`);
|
|
241
|
+
|
|
242
|
+
return { report, emailId: data?.id || "sent" };
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Generate a LinkedIn post draft from the session report
|
|
247
|
+
*/
|
|
248
|
+
private generateLinkedInDraft(report: SessionReport): string {
|
|
249
|
+
const topFeatures = report.features.slice(0, 3).map(f => `→ ${f}`).join("\n");
|
|
250
|
+
return `Shipped ${report.totalCommits} commits today.
|
|
251
|
+
|
|
252
|
+
${report.features.length} features. ${report.fixes.length} fixes. ${report.duration} of building.
|
|
253
|
+
|
|
254
|
+
${topFeatures}
|
|
255
|
+
|
|
256
|
+
The best developers don't just code — they ship. And they communicate what they shipped.
|
|
257
|
+
|
|
258
|
+
What did you build today?
|
|
259
|
+
|
|
260
|
+
#BuildInPublic #ShipLog #FounderLife`;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function escapeHtml(str: string): string {
|
|
265
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
266
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true,
|
|
12
|
+
"declarationMap": true,
|
|
13
|
+
"sourceMap": true
|
|
14
|
+
},
|
|
15
|
+
"include": ["src"],
|
|
16
|
+
"exclude": ["node_modules", "dist"]
|
|
17
|
+
}
|