@wilsong/shiplog 0.1.0 → 0.2.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.
@@ -0,0 +1,361 @@
1
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
2
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
3
+ }) : x)(function(x) {
4
+ if (typeof require !== "undefined") return require.apply(this, arguments);
5
+ throw Error('Dynamic require of "' + x + '" is not supported');
6
+ });
7
+
8
+ // src/index.ts
9
+ import simpleGit from "simple-git";
10
+ import { Resend } from "resend";
11
+ function categorizeCommit(message) {
12
+ const lower = message.toLowerCase();
13
+ if (lower.startsWith("feat") || lower.includes("add") || lower.includes("implement") || lower.includes("ship")) return "feature";
14
+ if (lower.startsWith("fix") || lower.includes("bug") || lower.includes("patch") || lower.includes("hotfix")) return "fix";
15
+ if (lower.startsWith("refactor") || lower.includes("cleanup") || lower.includes("reorganize") || lower.includes("rename")) return "refactor";
16
+ return "other";
17
+ }
18
+ function humanDuration(from, to) {
19
+ const diffMs = to.getTime() - from.getTime();
20
+ const hours = Math.floor(diffMs / 36e5);
21
+ const minutes = Math.floor(diffMs % 36e5 / 6e4);
22
+ if (hours > 0) return `${hours}h ${minutes}m`;
23
+ return `${minutes}m`;
24
+ }
25
+ var ShipLog = class {
26
+ git;
27
+ resend;
28
+ config;
29
+ constructor(config) {
30
+ this.config = {
31
+ brandName: "ShipLog",
32
+ brandColor: "#0077B5",
33
+ fromEmail: "ShipLog <updates@shiplog.dev>",
34
+ ...config
35
+ };
36
+ this.git = simpleGit(config.repoPath || process.cwd());
37
+ this.resend = new Resend(config.resendApiKey);
38
+ }
39
+ /**
40
+ * Analyze git history since a given time and return a structured report
41
+ */
42
+ async analyze(options = {}) {
43
+ const since = options.since || "8 hours ago";
44
+ const branch = options.branch || "HEAD";
45
+ const log = await this.git.log({
46
+ from: "",
47
+ to: branch,
48
+ "--since": since,
49
+ "--stat": null
50
+ });
51
+ const features = [];
52
+ const fixes = [];
53
+ const refactors = [];
54
+ const other = [];
55
+ const authors = {};
56
+ const directories = {};
57
+ let totalInsertions = 0;
58
+ let totalDeletions = 0;
59
+ let totalFilesChanged = 0;
60
+ for (const commit of log.all) {
61
+ const msg = commit.message.split("\n")[0];
62
+ const category = categorizeCommit(msg);
63
+ switch (category) {
64
+ case "feature":
65
+ features.push(msg);
66
+ break;
67
+ case "fix":
68
+ fixes.push(msg);
69
+ break;
70
+ case "refactor":
71
+ refactors.push(msg);
72
+ break;
73
+ default:
74
+ other.push(msg);
75
+ break;
76
+ }
77
+ const author = commit.author_name || "Unknown";
78
+ authors[author] = (authors[author] || 0) + 1;
79
+ const diffStat = commit.diff?.changed || 0;
80
+ const ins = commit.diff?.insertions || 0;
81
+ const del = commit.diff?.deletions || 0;
82
+ totalFilesChanged += diffStat;
83
+ totalInsertions += ins;
84
+ totalDeletions += del;
85
+ if (commit.diff?.files) {
86
+ for (const file of commit.diff.files) {
87
+ const dir = file.file.split("/").slice(0, 2).join("/");
88
+ directories[dir] = (directories[dir] || 0) + 1;
89
+ }
90
+ }
91
+ }
92
+ const commits = log.all;
93
+ const from = commits.length > 0 ? commits[commits.length - 1].date : (/* @__PURE__ */ new Date()).toISOString();
94
+ const to = commits.length > 0 ? commits[0].date : (/* @__PURE__ */ new Date()).toISOString();
95
+ const duration = humanDuration(new Date(from), new Date(to));
96
+ const report = {
97
+ totalCommits: log.total,
98
+ filesChanged: totalFilesChanged,
99
+ insertions: totalInsertions,
100
+ deletions: totalDeletions,
101
+ features,
102
+ fixes,
103
+ refactors,
104
+ other,
105
+ authors,
106
+ directories,
107
+ timeRange: { from, to },
108
+ duration
109
+ };
110
+ if (this.config.includeLinkedInDraft) {
111
+ report.linkedInDraft = this.generateLinkedInDraft(report);
112
+ }
113
+ return report;
114
+ }
115
+ /**
116
+ * Generate a branded HTML email from a session report
117
+ */
118
+ buildEmail(report) {
119
+ const { brandName, brandColor } = this.config;
120
+ const featureList = report.features.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
121
+ const fixList = report.fixes.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
122
+ const refactorList = report.refactors.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
123
+ const otherList = report.other.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
124
+ const sections = [];
125
+ if (report.features.length > 0) sections.push(`<h2 style="color:#22c55e;border-bottom:2px solid #22c55e;padding-bottom:4px">\u2728 Features (${report.features.length})</h2><ul style="font-size:13px">${featureList}</ul>`);
126
+ if (report.fixes.length > 0) sections.push(`<h2 style="color:#ef4444;border-bottom:2px solid #ef4444;padding-bottom:4px">\u{1F527} Fixes (${report.fixes.length})</h2><ul style="font-size:13px">${fixList}</ul>`);
127
+ if (report.refactors.length > 0) sections.push(`<h2 style="color:#8b5cf6;border-bottom:2px solid #8b5cf6;padding-bottom:4px">\u267B\uFE0F Refactors (${report.refactors.length})</h2><ul style="font-size:13px">${refactorList}</ul>`);
128
+ if (report.other.length > 0) sections.push(`<h2 style="color:#6b7280;border-bottom:2px solid #6b7280;padding-bottom:4px">\u{1F4DD} Other (${report.other.length})</h2><ul style="font-size:13px">${otherList}</ul>`);
129
+ const authorRows = Object.entries(report.authors).map(([name, count]) => `<span style="margin-right:12px">${escapeHtml(name)}: <strong>${count}</strong></span>`).join("");
130
+ return `<div style="font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a2e">
131
+ <div style="background:${brandColor};color:white;padding:16px 20px;border-radius:12px 12px 0 0">
132
+ <h1 style="margin:0;font-size:20px">${escapeHtml(brandName)} \u2014 Session Report</h1>
133
+ <p style="margin:4px 0 0;opacity:0.85;font-size:13px">${report.timeRange.from.split("T")[0]} \u2022 ${report.duration} session</p>
134
+ </div>
135
+
136
+ <div style="background:#f8fafc;padding:16px 20px;border:1px solid #e2e8f0;border-top:none">
137
+ <div style="display:flex;gap:20px;text-align:center">
138
+ <div><div style="font-size:28px;font-weight:bold;color:${brandColor}">${report.totalCommits}</div><div style="font-size:11px;color:#64748b">Commits</div></div>
139
+ <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>
140
+ <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>
141
+ <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>
142
+ </div>
143
+ </div>
144
+
145
+ <div style="padding:16px 20px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
146
+ ${sections.join("\n")}
147
+ <div style="margin-top:16px;padding:12px;background:#f1f5f9;border-radius:8px;font-size:12px;color:#475569">
148
+ <strong>Authors:</strong> ${authorRows}
149
+ </div>
150
+ </div>
151
+
152
+ <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> \u2014 session reports for vibe-coding developers</p>
153
+ </div>`;
154
+ }
155
+ /**
156
+ * Analyze git history and send the email report
157
+ */
158
+ async analyzeAndSend(options = {}) {
159
+ const report = await this.analyze(options);
160
+ if (report.totalCommits === 0) {
161
+ throw new Error("No commits found in the specified time range");
162
+ }
163
+ const html = this.buildEmail(report);
164
+ const subject = `${this.config.brandName}: ${report.totalCommits} commits \u2014 ${report.features.length} features, ${report.fixes.length} fixes`;
165
+ const { data, error } = await this.resend.emails.send({
166
+ from: this.config.fromEmail,
167
+ to: this.config.email,
168
+ cc: this.config.cc,
169
+ subject,
170
+ html
171
+ });
172
+ if (error) throw new Error(`Email send failed: ${error.message}`);
173
+ return { report, emailId: data?.id || "sent" };
174
+ }
175
+ /**
176
+ * Generate a LinkedIn post draft from the session report
177
+ */
178
+ generateLinkedInDraft(report) {
179
+ const topFeatures = report.features.slice(0, 3).map((f) => `\u2192 ${f}`).join("\n");
180
+ return `Shipped ${report.totalCommits} commits today.
181
+
182
+ ${report.features.length} features. ${report.fixes.length} fixes. ${report.duration} of building.
183
+
184
+ ${topFeatures}
185
+
186
+ The best developers don't just code \u2014 they ship. And they communicate what they shipped.
187
+
188
+ What did you build today?
189
+
190
+ #BuildInPublic #ShipLog #FounderLife`;
191
+ }
192
+ };
193
+ function escapeHtml(str) {
194
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
195
+ }
196
+ async function sendSlackNotification(webhookUrl, report, brandName = "ShipLog") {
197
+ try {
198
+ const blocks = [
199
+ { type: "header", text: { type: "plain_text", text: `${brandName} \u2014 ${report.totalCommits} commits shipped` } },
200
+ { type: "section", fields: [
201
+ { type: "mrkdwn", text: `*Features:* ${report.features.length}` },
202
+ { type: "mrkdwn", text: `*Fixes:* ${report.fixes.length}` },
203
+ { type: "mrkdwn", text: `*Lines:* +${report.insertions}/-${report.deletions}` },
204
+ { type: "mrkdwn", text: `*Duration:* ${report.duration}` }
205
+ ] }
206
+ ];
207
+ if (report.features.length > 0) {
208
+ blocks.push({ type: "section", fields: [{ type: "mrkdwn", text: `*Top Features:*
209
+ ${report.features.slice(0, 5).map((f) => `\u2022 ${f}`).join("\n")}` }] });
210
+ }
211
+ const res = await fetch(webhookUrl, {
212
+ method: "POST",
213
+ headers: { "Content-Type": "application/json" },
214
+ body: JSON.stringify({ blocks })
215
+ });
216
+ return res.ok;
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+ async function sendDiscordNotification(webhookUrl, report, brandName = "ShipLog") {
222
+ try {
223
+ const embed = {
224
+ title: `${brandName} \u2014 ${report.totalCommits} commits shipped`,
225
+ color: 16096779,
226
+ fields: [
227
+ { name: "Features", value: String(report.features.length), inline: true },
228
+ { name: "Fixes", value: String(report.fixes.length), inline: true },
229
+ { name: "Lines", value: `+${report.insertions}/-${report.deletions}`, inline: true },
230
+ { name: "Duration", value: report.duration, inline: true }
231
+ ],
232
+ footer: { text: "Powered by ShipLog \u2014 session reports for vibe-coding developers" },
233
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
234
+ };
235
+ if (report.features.length > 0) {
236
+ embed.fields.push({ name: "Top Features", value: report.features.slice(0, 5).map((f) => `\u2022 ${f}`).join("\n"), inline: false });
237
+ }
238
+ const res = await fetch(webhookUrl, {
239
+ method: "POST",
240
+ headers: { "Content-Type": "application/json" },
241
+ body: JSON.stringify({ embeds: [embed] })
242
+ });
243
+ return res.ok;
244
+ } catch {
245
+ return false;
246
+ }
247
+ }
248
+ function getFileHotspots(directories, limit = 10) {
249
+ return Object.entries(directories).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([file, changes]) => ({ file, changes }));
250
+ }
251
+ function generateInvestorUpdate(report, brandName = "ShipLog") {
252
+ return `## ${brandName} \u2014 Weekly Shipping Update
253
+
254
+ **Period:** ${report.timeRange.from.split("T")[0]} to ${report.timeRange.to.split("T")[0]}
255
+ **Duration:** ${report.duration}
256
+
257
+ ### Key Metrics
258
+ - **${report.totalCommits}** commits shipped
259
+ - **${report.features.length}** new features
260
+ - **${report.fixes.length}** bugs fixed
261
+ - **+${report.insertions}** lines added / **-${report.deletions}** removed
262
+
263
+ ### Features Shipped
264
+ ${report.features.map((f) => `- ${f}`).join("\n")}
265
+
266
+ ### Bug Fixes
267
+ ${report.fixes.map((f) => `- ${f}`).join("\n")}
268
+
269
+ ---
270
+ *Generated by ShipLog*`;
271
+ }
272
+ function compareReports(current, previous) {
273
+ const cd = current.totalCommits - previous.totalCommits;
274
+ const fd = current.features.length - previous.features.length;
275
+ const fixd = current.fixes.length - previous.fixes.length;
276
+ const ld = current.insertions + current.deletions - (previous.insertions + previous.deletions);
277
+ const trend = cd > 0 ? "up" : cd < 0 ? "down" : "stable";
278
+ return {
279
+ commitsDelta: cd,
280
+ featuresDelta: fd,
281
+ fixesDelta: fixd,
282
+ linesDelta: ld,
283
+ trend,
284
+ summary: `${cd >= 0 ? "+" : ""}${cd} commits (${trend}), ${fd >= 0 ? "+" : ""}${fd} features, ${fixd >= 0 ? "+" : ""}${fixd} fixes`
285
+ };
286
+ }
287
+ function calculateStreak(sessionDates) {
288
+ if (sessionDates.length === 0) return { currentStreak: 0, longestStreak: 0, totalSessions: 0 };
289
+ const sorted = [...sessionDates].sort().reverse();
290
+ let currentStreak = 1;
291
+ let longestStreak = 1;
292
+ let streak = 1;
293
+ for (let i = 1; i < sorted.length; i++) {
294
+ const prev = new Date(sorted[i - 1]);
295
+ const curr = new Date(sorted[i]);
296
+ const diffDays = Math.round((prev.getTime() - curr.getTime()) / 864e5);
297
+ if (diffDays <= 1) {
298
+ streak++;
299
+ longestStreak = Math.max(longestStreak, streak);
300
+ } else {
301
+ if (i === 1) currentStreak = streak;
302
+ streak = 1;
303
+ }
304
+ }
305
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
306
+ if (sorted[0].startsWith(today)) currentStreak = streak;
307
+ return { currentStreak, longestStreak: Math.max(longestStreak, streak), totalSessions: sessionDates.length };
308
+ }
309
+ function exportJSON(report) {
310
+ return JSON.stringify(report, null, 2);
311
+ }
312
+ function exportMarkdown(report, brandName = "ShipLog") {
313
+ let md = `# ${brandName} \u2014 Session Report
314
+
315
+ `;
316
+ md += `**${report.totalCommits}** commits | **${report.features.length}** features | **${report.fixes.length}** fixes | +${report.insertions}/-${report.deletions} lines | ${report.duration}
317
+
318
+ `;
319
+ if (report.features.length > 0) {
320
+ md += `## Features
321
+ ${report.features.map((f) => `- ${f}`).join("\n")}
322
+
323
+ `;
324
+ }
325
+ if (report.fixes.length > 0) {
326
+ md += `## Fixes
327
+ ${report.fixes.map((f) => `- ${f}`).join("\n")}
328
+
329
+ `;
330
+ }
331
+ if (report.refactors.length > 0) {
332
+ md += `## Refactors
333
+ ${report.refactors.map((f) => `- ${f}`).join("\n")}
334
+
335
+ `;
336
+ }
337
+ const authors = Object.entries(report.authors).map(([n, c]) => `- ${n}: ${c} commits`).join("\n");
338
+ if (authors) {
339
+ md += `## Authors
340
+ ${authors}
341
+
342
+ `;
343
+ }
344
+ md += `---
345
+ *Generated by ${brandName} on ${(/* @__PURE__ */ new Date()).toLocaleDateString()}*
346
+ `;
347
+ return md;
348
+ }
349
+
350
+ export {
351
+ __require,
352
+ ShipLog,
353
+ sendSlackNotification,
354
+ sendDiscordNotification,
355
+ getFileHotspots,
356
+ generateInvestorUpdate,
357
+ compareReports,
358
+ calculateStreak,
359
+ exportJSON,
360
+ exportMarkdown
361
+ };
package/dist/cli.js CHANGED
@@ -2,7 +2,7 @@
2
2
  import {
3
3
  ShipLog,
4
4
  __require
5
- } from "./chunk-5DFHNS4N.js";
5
+ } from "./chunk-P5ILNS7U.js";
6
6
 
7
7
  // src/cli.ts
8
8
  import { Command } from "commander";
package/dist/index.cjs CHANGED
@@ -30,7 +30,15 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var index_exports = {};
32
32
  __export(index_exports, {
33
- ShipLog: () => ShipLog
33
+ ShipLog: () => ShipLog,
34
+ calculateStreak: () => calculateStreak,
35
+ compareReports: () => compareReports,
36
+ exportJSON: () => exportJSON,
37
+ exportMarkdown: () => exportMarkdown,
38
+ generateInvestorUpdate: () => generateInvestorUpdate,
39
+ getFileHotspots: () => getFileHotspots,
40
+ sendDiscordNotification: () => sendDiscordNotification,
41
+ sendSlackNotification: () => sendSlackNotification
34
42
  });
35
43
  module.exports = __toCommonJS(index_exports);
36
44
  var import_simple_git = __toESM(require("simple-git"), 1);
@@ -220,7 +228,168 @@ What did you build today?
220
228
  function escapeHtml(str) {
221
229
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
222
230
  }
231
+ async function sendSlackNotification(webhookUrl, report, brandName = "ShipLog") {
232
+ try {
233
+ const blocks = [
234
+ { type: "header", text: { type: "plain_text", text: `${brandName} \u2014 ${report.totalCommits} commits shipped` } },
235
+ { type: "section", fields: [
236
+ { type: "mrkdwn", text: `*Features:* ${report.features.length}` },
237
+ { type: "mrkdwn", text: `*Fixes:* ${report.fixes.length}` },
238
+ { type: "mrkdwn", text: `*Lines:* +${report.insertions}/-${report.deletions}` },
239
+ { type: "mrkdwn", text: `*Duration:* ${report.duration}` }
240
+ ] }
241
+ ];
242
+ if (report.features.length > 0) {
243
+ blocks.push({ type: "section", fields: [{ type: "mrkdwn", text: `*Top Features:*
244
+ ${report.features.slice(0, 5).map((f) => `\u2022 ${f}`).join("\n")}` }] });
245
+ }
246
+ const res = await fetch(webhookUrl, {
247
+ method: "POST",
248
+ headers: { "Content-Type": "application/json" },
249
+ body: JSON.stringify({ blocks })
250
+ });
251
+ return res.ok;
252
+ } catch {
253
+ return false;
254
+ }
255
+ }
256
+ async function sendDiscordNotification(webhookUrl, report, brandName = "ShipLog") {
257
+ try {
258
+ const embed = {
259
+ title: `${brandName} \u2014 ${report.totalCommits} commits shipped`,
260
+ color: 16096779,
261
+ fields: [
262
+ { name: "Features", value: String(report.features.length), inline: true },
263
+ { name: "Fixes", value: String(report.fixes.length), inline: true },
264
+ { name: "Lines", value: `+${report.insertions}/-${report.deletions}`, inline: true },
265
+ { name: "Duration", value: report.duration, inline: true }
266
+ ],
267
+ footer: { text: "Powered by ShipLog \u2014 session reports for vibe-coding developers" },
268
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
269
+ };
270
+ if (report.features.length > 0) {
271
+ embed.fields.push({ name: "Top Features", value: report.features.slice(0, 5).map((f) => `\u2022 ${f}`).join("\n"), inline: false });
272
+ }
273
+ const res = await fetch(webhookUrl, {
274
+ method: "POST",
275
+ headers: { "Content-Type": "application/json" },
276
+ body: JSON.stringify({ embeds: [embed] })
277
+ });
278
+ return res.ok;
279
+ } catch {
280
+ return false;
281
+ }
282
+ }
283
+ function getFileHotspots(directories, limit = 10) {
284
+ return Object.entries(directories).sort((a, b) => b[1] - a[1]).slice(0, limit).map(([file, changes]) => ({ file, changes }));
285
+ }
286
+ function generateInvestorUpdate(report, brandName = "ShipLog") {
287
+ return `## ${brandName} \u2014 Weekly Shipping Update
288
+
289
+ **Period:** ${report.timeRange.from.split("T")[0]} to ${report.timeRange.to.split("T")[0]}
290
+ **Duration:** ${report.duration}
291
+
292
+ ### Key Metrics
293
+ - **${report.totalCommits}** commits shipped
294
+ - **${report.features.length}** new features
295
+ - **${report.fixes.length}** bugs fixed
296
+ - **+${report.insertions}** lines added / **-${report.deletions}** removed
297
+
298
+ ### Features Shipped
299
+ ${report.features.map((f) => `- ${f}`).join("\n")}
300
+
301
+ ### Bug Fixes
302
+ ${report.fixes.map((f) => `- ${f}`).join("\n")}
303
+
304
+ ---
305
+ *Generated by ShipLog*`;
306
+ }
307
+ function compareReports(current, previous) {
308
+ const cd = current.totalCommits - previous.totalCommits;
309
+ const fd = current.features.length - previous.features.length;
310
+ const fixd = current.fixes.length - previous.fixes.length;
311
+ const ld = current.insertions + current.deletions - (previous.insertions + previous.deletions);
312
+ const trend = cd > 0 ? "up" : cd < 0 ? "down" : "stable";
313
+ return {
314
+ commitsDelta: cd,
315
+ featuresDelta: fd,
316
+ fixesDelta: fixd,
317
+ linesDelta: ld,
318
+ trend,
319
+ summary: `${cd >= 0 ? "+" : ""}${cd} commits (${trend}), ${fd >= 0 ? "+" : ""}${fd} features, ${fixd >= 0 ? "+" : ""}${fixd} fixes`
320
+ };
321
+ }
322
+ function calculateStreak(sessionDates) {
323
+ if (sessionDates.length === 0) return { currentStreak: 0, longestStreak: 0, totalSessions: 0 };
324
+ const sorted = [...sessionDates].sort().reverse();
325
+ let currentStreak = 1;
326
+ let longestStreak = 1;
327
+ let streak = 1;
328
+ for (let i = 1; i < sorted.length; i++) {
329
+ const prev = new Date(sorted[i - 1]);
330
+ const curr = new Date(sorted[i]);
331
+ const diffDays = Math.round((prev.getTime() - curr.getTime()) / 864e5);
332
+ if (diffDays <= 1) {
333
+ streak++;
334
+ longestStreak = Math.max(longestStreak, streak);
335
+ } else {
336
+ if (i === 1) currentStreak = streak;
337
+ streak = 1;
338
+ }
339
+ }
340
+ const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
341
+ if (sorted[0].startsWith(today)) currentStreak = streak;
342
+ return { currentStreak, longestStreak: Math.max(longestStreak, streak), totalSessions: sessionDates.length };
343
+ }
344
+ function exportJSON(report) {
345
+ return JSON.stringify(report, null, 2);
346
+ }
347
+ function exportMarkdown(report, brandName = "ShipLog") {
348
+ let md = `# ${brandName} \u2014 Session Report
349
+
350
+ `;
351
+ md += `**${report.totalCommits}** commits | **${report.features.length}** features | **${report.fixes.length}** fixes | +${report.insertions}/-${report.deletions} lines | ${report.duration}
352
+
353
+ `;
354
+ if (report.features.length > 0) {
355
+ md += `## Features
356
+ ${report.features.map((f) => `- ${f}`).join("\n")}
357
+
358
+ `;
359
+ }
360
+ if (report.fixes.length > 0) {
361
+ md += `## Fixes
362
+ ${report.fixes.map((f) => `- ${f}`).join("\n")}
363
+
364
+ `;
365
+ }
366
+ if (report.refactors.length > 0) {
367
+ md += `## Refactors
368
+ ${report.refactors.map((f) => `- ${f}`).join("\n")}
369
+
370
+ `;
371
+ }
372
+ const authors = Object.entries(report.authors).map(([n, c]) => `- ${n}: ${c} commits`).join("\n");
373
+ if (authors) {
374
+ md += `## Authors
375
+ ${authors}
376
+
377
+ `;
378
+ }
379
+ md += `---
380
+ *Generated by ${brandName} on ${(/* @__PURE__ */ new Date()).toLocaleDateString()}*
381
+ `;
382
+ return md;
383
+ }
223
384
  // Annotate the CommonJS export names for ESM import in node:
224
385
  0 && (module.exports = {
225
- ShipLog
386
+ ShipLog,
387
+ calculateStreak,
388
+ compareReports,
389
+ exportJSON,
390
+ exportMarkdown,
391
+ generateInvestorUpdate,
392
+ getFileHotspots,
393
+ sendDiscordNotification,
394
+ sendSlackNotification
226
395
  });
package/dist/index.d.cts CHANGED
@@ -22,6 +22,22 @@ interface ShipLogConfig {
22
22
  repoPath?: string;
23
23
  /** Include LinkedIn post draft in email */
24
24
  includeLinkedInDraft?: boolean;
25
+ /** Feature 1: Slack webhook URL for notifications */
26
+ slackWebhookUrl?: string;
27
+ /** Feature 2: Discord webhook URL for notifications */
28
+ discordWebhookUrl?: string;
29
+ /** Feature 3: Multiple repo paths for multi-repo analysis */
30
+ additionalRepos?: string[];
31
+ /** Feature 4: Custom commit categorization rules */
32
+ customCategories?: Array<{
33
+ name: string;
34
+ keywords: string[];
35
+ color: string;
36
+ }>;
37
+ /** Feature 5: Include file hotspots (most changed files) in report */
38
+ includeFileHotspots?: boolean;
39
+ /** Feature 6: Include investor update summary */
40
+ includeInvestorUpdate?: boolean;
25
41
  }
26
42
  interface SessionReport {
27
43
  totalCommits: number;
@@ -40,6 +56,15 @@ interface SessionReport {
40
56
  };
41
57
  duration: string;
42
58
  linkedInDraft?: string;
59
+ /** Feature 4: Most frequently changed files */
60
+ fileHotspots?: Array<{
61
+ file: string;
62
+ changes: number;
63
+ }>;
64
+ /** Feature 6: Investor-ready summary */
65
+ investorUpdate?: string;
66
+ /** Feature 8: Repo name */
67
+ repoName?: string;
43
68
  }
44
69
  declare class ShipLog {
45
70
  private git;
@@ -72,5 +97,27 @@ declare class ShipLog {
72
97
  */
73
98
  private generateLinkedInDraft;
74
99
  }
100
+ declare function sendSlackNotification(webhookUrl: string, report: SessionReport, brandName?: string): Promise<boolean>;
101
+ declare function sendDiscordNotification(webhookUrl: string, report: SessionReport, brandName?: string): Promise<boolean>;
102
+ declare function getFileHotspots(directories: Record<string, number>, limit?: number): Array<{
103
+ file: string;
104
+ changes: number;
105
+ }>;
106
+ declare function generateInvestorUpdate(report: SessionReport, brandName?: string): string;
107
+ declare function compareReports(current: SessionReport, previous: SessionReport): {
108
+ commitsDelta: number;
109
+ featuresDelta: number;
110
+ fixesDelta: number;
111
+ linesDelta: number;
112
+ trend: "up" | "down" | "stable";
113
+ summary: string;
114
+ };
115
+ declare function calculateStreak(sessionDates: string[]): {
116
+ currentStreak: number;
117
+ longestStreak: number;
118
+ totalSessions: number;
119
+ };
120
+ declare function exportJSON(report: SessionReport): string;
121
+ declare function exportMarkdown(report: SessionReport, brandName?: string): string;
75
122
 
76
- export { type SessionReport, ShipLog, type ShipLogConfig };
123
+ export { type SessionReport, ShipLog, type ShipLogConfig, calculateStreak, compareReports, exportJSON, exportMarkdown, generateInvestorUpdate, getFileHotspots, sendDiscordNotification, sendSlackNotification };
package/dist/index.d.ts CHANGED
@@ -22,6 +22,22 @@ interface ShipLogConfig {
22
22
  repoPath?: string;
23
23
  /** Include LinkedIn post draft in email */
24
24
  includeLinkedInDraft?: boolean;
25
+ /** Feature 1: Slack webhook URL for notifications */
26
+ slackWebhookUrl?: string;
27
+ /** Feature 2: Discord webhook URL for notifications */
28
+ discordWebhookUrl?: string;
29
+ /** Feature 3: Multiple repo paths for multi-repo analysis */
30
+ additionalRepos?: string[];
31
+ /** Feature 4: Custom commit categorization rules */
32
+ customCategories?: Array<{
33
+ name: string;
34
+ keywords: string[];
35
+ color: string;
36
+ }>;
37
+ /** Feature 5: Include file hotspots (most changed files) in report */
38
+ includeFileHotspots?: boolean;
39
+ /** Feature 6: Include investor update summary */
40
+ includeInvestorUpdate?: boolean;
25
41
  }
26
42
  interface SessionReport {
27
43
  totalCommits: number;
@@ -40,6 +56,15 @@ interface SessionReport {
40
56
  };
41
57
  duration: string;
42
58
  linkedInDraft?: string;
59
+ /** Feature 4: Most frequently changed files */
60
+ fileHotspots?: Array<{
61
+ file: string;
62
+ changes: number;
63
+ }>;
64
+ /** Feature 6: Investor-ready summary */
65
+ investorUpdate?: string;
66
+ /** Feature 8: Repo name */
67
+ repoName?: string;
43
68
  }
44
69
  declare class ShipLog {
45
70
  private git;
@@ -72,5 +97,27 @@ declare class ShipLog {
72
97
  */
73
98
  private generateLinkedInDraft;
74
99
  }
100
+ declare function sendSlackNotification(webhookUrl: string, report: SessionReport, brandName?: string): Promise<boolean>;
101
+ declare function sendDiscordNotification(webhookUrl: string, report: SessionReport, brandName?: string): Promise<boolean>;
102
+ declare function getFileHotspots(directories: Record<string, number>, limit?: number): Array<{
103
+ file: string;
104
+ changes: number;
105
+ }>;
106
+ declare function generateInvestorUpdate(report: SessionReport, brandName?: string): string;
107
+ declare function compareReports(current: SessionReport, previous: SessionReport): {
108
+ commitsDelta: number;
109
+ featuresDelta: number;
110
+ fixesDelta: number;
111
+ linesDelta: number;
112
+ trend: "up" | "down" | "stable";
113
+ summary: string;
114
+ };
115
+ declare function calculateStreak(sessionDates: string[]): {
116
+ currentStreak: number;
117
+ longestStreak: number;
118
+ totalSessions: number;
119
+ };
120
+ declare function exportJSON(report: SessionReport): string;
121
+ declare function exportMarkdown(report: SessionReport, brandName?: string): string;
75
122
 
76
- export { type SessionReport, ShipLog, type ShipLogConfig };
123
+ export { type SessionReport, ShipLog, type ShipLogConfig, calculateStreak, compareReports, exportJSON, exportMarkdown, generateInvestorUpdate, getFileHotspots, sendDiscordNotification, sendSlackNotification };
package/dist/index.js CHANGED
@@ -1,6 +1,22 @@
1
1
  import {
2
- ShipLog
3
- } from "./chunk-5DFHNS4N.js";
2
+ ShipLog,
3
+ calculateStreak,
4
+ compareReports,
5
+ exportJSON,
6
+ exportMarkdown,
7
+ generateInvestorUpdate,
8
+ getFileHotspots,
9
+ sendDiscordNotification,
10
+ sendSlackNotification
11
+ } from "./chunk-P5ILNS7U.js";
4
12
  export {
5
- ShipLog
13
+ ShipLog,
14
+ calculateStreak,
15
+ compareReports,
16
+ exportJSON,
17
+ exportMarkdown,
18
+ generateInvestorUpdate,
19
+ getFileHotspots,
20
+ sendDiscordNotification,
21
+ sendSlackNotification
6
22
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wilsong/shiplog",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "What did your AI build while you were at the gym? Session reports for vibe-coding developers.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
package/src/index.ts CHANGED
@@ -26,6 +26,18 @@ export interface ShipLogConfig {
26
26
  repoPath?: string;
27
27
  /** Include LinkedIn post draft in email */
28
28
  includeLinkedInDraft?: boolean;
29
+ /** Feature 1: Slack webhook URL for notifications */
30
+ slackWebhookUrl?: string;
31
+ /** Feature 2: Discord webhook URL for notifications */
32
+ discordWebhookUrl?: string;
33
+ /** Feature 3: Multiple repo paths for multi-repo analysis */
34
+ additionalRepos?: string[];
35
+ /** Feature 4: Custom commit categorization rules */
36
+ customCategories?: Array<{ name: string; keywords: string[]; color: string }>;
37
+ /** Feature 5: Include file hotspots (most changed files) in report */
38
+ includeFileHotspots?: boolean;
39
+ /** Feature 6: Include investor update summary */
40
+ includeInvestorUpdate?: boolean;
29
41
  }
30
42
 
31
43
  export interface SessionReport {
@@ -42,6 +54,12 @@ export interface SessionReport {
42
54
  timeRange: { from: string; to: string };
43
55
  duration: string;
44
56
  linkedInDraft?: string;
57
+ /** Feature 4: Most frequently changed files */
58
+ fileHotspots?: Array<{ file: string; changes: number }>;
59
+ /** Feature 6: Investor-ready summary */
60
+ investorUpdate?: string;
61
+ /** Feature 8: Repo name */
62
+ repoName?: string;
45
63
  }
46
64
 
47
65
  /**
@@ -264,3 +282,140 @@ What did you build today?
264
282
  function escapeHtml(str: string): string {
265
283
  return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
266
284
  }
285
+
286
+ // ══════════════════════════════════════════
287
+ // Feature 1: Slack Webhook Integration
288
+ // ══════════════════════════════════════════
289
+ export async function sendSlackNotification(webhookUrl: string, report: SessionReport, brandName = "ShipLog"): Promise<boolean> {
290
+ try {
291
+ const blocks = [
292
+ { type: "header", text: { type: "plain_text", text: `${brandName} — ${report.totalCommits} commits shipped` } },
293
+ { type: "section", fields: [
294
+ { type: "mrkdwn", text: `*Features:* ${report.features.length}` },
295
+ { type: "mrkdwn", text: `*Fixes:* ${report.fixes.length}` },
296
+ { type: "mrkdwn", text: `*Lines:* +${report.insertions}/-${report.deletions}` },
297
+ { type: "mrkdwn", text: `*Duration:* ${report.duration}` },
298
+ ]},
299
+ ];
300
+ if (report.features.length > 0) {
301
+ blocks.push({ type: "section" as const, fields: [{ type: "mrkdwn" as const, text: `*Top Features:*\n${report.features.slice(0, 5).map(f => `• ${f}`).join("\n")}` }] } as any);
302
+ }
303
+ const res = await fetch(webhookUrl, {
304
+ method: "POST",
305
+ headers: { "Content-Type": "application/json" },
306
+ body: JSON.stringify({ blocks }),
307
+ });
308
+ return res.ok;
309
+ } catch { return false; }
310
+ }
311
+
312
+ // ══════════════════════════════════════════
313
+ // Feature 2: Discord Webhook Integration
314
+ // ══════════════════════════════════════════
315
+ export async function sendDiscordNotification(webhookUrl: string, report: SessionReport, brandName = "ShipLog"): Promise<boolean> {
316
+ try {
317
+ const embed = {
318
+ title: `${brandName} — ${report.totalCommits} commits shipped`,
319
+ color: 0xf59e0b,
320
+ fields: [
321
+ { name: "Features", value: String(report.features.length), inline: true },
322
+ { name: "Fixes", value: String(report.fixes.length), inline: true },
323
+ { name: "Lines", value: `+${report.insertions}/-${report.deletions}`, inline: true },
324
+ { name: "Duration", value: report.duration, inline: true },
325
+ ],
326
+ footer: { text: "Powered by ShipLog — session reports for vibe-coding developers" },
327
+ timestamp: new Date().toISOString(),
328
+ };
329
+ if (report.features.length > 0) {
330
+ embed.fields.push({ name: "Top Features", value: report.features.slice(0, 5).map(f => `• ${f}`).join("\n"), inline: false });
331
+ }
332
+ const res = await fetch(webhookUrl, {
333
+ method: "POST",
334
+ headers: { "Content-Type": "application/json" },
335
+ body: JSON.stringify({ embeds: [embed] }),
336
+ });
337
+ return res.ok;
338
+ } catch { return false; }
339
+ }
340
+
341
+ // ══════════════════════════════════════════
342
+ // Feature 5: File Hotspots
343
+ // ══════════════════════════════════════════
344
+ export function getFileHotspots(directories: Record<string, number>, limit = 10): Array<{ file: string; changes: number }> {
345
+ return Object.entries(directories)
346
+ .sort((a, b) => b[1] - a[1])
347
+ .slice(0, limit)
348
+ .map(([file, changes]) => ({ file, changes }));
349
+ }
350
+
351
+ // ══════════════════════════════════════════
352
+ // Feature 6: Investor Update Template
353
+ // ══════════════════════════════════════════
354
+ export function generateInvestorUpdate(report: SessionReport, brandName = "ShipLog"): string {
355
+ return `## ${brandName} — Weekly Shipping Update\n\n**Period:** ${report.timeRange.from.split("T")[0]} to ${report.timeRange.to.split("T")[0]}\n**Duration:** ${report.duration}\n\n### Key Metrics\n- **${report.totalCommits}** commits shipped\n- **${report.features.length}** new features\n- **${report.fixes.length}** bugs fixed\n- **+${report.insertions}** lines added / **-${report.deletions}** removed\n\n### Features Shipped\n${report.features.map(f => `- ${f}`).join("\n")}\n\n### Bug Fixes\n${report.fixes.map(f => `- ${f}`).join("\n")}\n\n---\n*Generated by ShipLog*`;
356
+ }
357
+
358
+ // ══════════════════════════════════════════
359
+ // Feature 7: Session Comparison
360
+ // ══════════════════════════════════════════
361
+ export function compareReports(current: SessionReport, previous: SessionReport): {
362
+ commitsDelta: number;
363
+ featuresDelta: number;
364
+ fixesDelta: number;
365
+ linesDelta: number;
366
+ trend: "up" | "down" | "stable";
367
+ summary: string;
368
+ } {
369
+ const cd = current.totalCommits - previous.totalCommits;
370
+ const fd = current.features.length - previous.features.length;
371
+ const fixd = current.fixes.length - previous.fixes.length;
372
+ const ld = (current.insertions + current.deletions) - (previous.insertions + previous.deletions);
373
+ const trend = cd > 0 ? "up" : cd < 0 ? "down" : "stable";
374
+ return {
375
+ commitsDelta: cd, featuresDelta: fd, fixesDelta: fixd, linesDelta: ld, trend,
376
+ summary: `${cd >= 0 ? "+" : ""}${cd} commits (${trend}), ${fd >= 0 ? "+" : ""}${fd} features, ${fixd >= 0 ? "+" : ""}${fixd} fixes`,
377
+ };
378
+ }
379
+
380
+ // ══════════════════════════════════════════
381
+ // Feature 8: Streak Tracking
382
+ // ══════════════════════════════════════════
383
+ export function calculateStreak(sessionDates: string[]): { currentStreak: number; longestStreak: number; totalSessions: number } {
384
+ if (sessionDates.length === 0) return { currentStreak: 0, longestStreak: 0, totalSessions: 0 };
385
+ const sorted = [...sessionDates].sort().reverse();
386
+ let currentStreak = 1;
387
+ let longestStreak = 1;
388
+ let streak = 1;
389
+ for (let i = 1; i < sorted.length; i++) {
390
+ const prev = new Date(sorted[i - 1]);
391
+ const curr = new Date(sorted[i]);
392
+ const diffDays = Math.round((prev.getTime() - curr.getTime()) / 86400000);
393
+ if (diffDays <= 1) { streak++; longestStreak = Math.max(longestStreak, streak); }
394
+ else { if (i === 1) currentStreak = streak; streak = 1; }
395
+ }
396
+ const today = new Date().toISOString().split("T")[0];
397
+ if (sorted[0].startsWith(today)) currentStreak = streak;
398
+ return { currentStreak, longestStreak: Math.max(longestStreak, streak), totalSessions: sessionDates.length };
399
+ }
400
+
401
+ // ══════════════════════════════════════════
402
+ // Feature 9: JSON Export
403
+ // ══════════════════════════════════════════
404
+ export function exportJSON(report: SessionReport): string {
405
+ return JSON.stringify(report, null, 2);
406
+ }
407
+
408
+ // ══════════════════════════════════════════
409
+ // Feature 10: Markdown Export
410
+ // ══════════════════════════════════════════
411
+ export function exportMarkdown(report: SessionReport, brandName = "ShipLog"): string {
412
+ let md = `# ${brandName} — Session Report\n\n`;
413
+ md += `**${report.totalCommits}** commits | **${report.features.length}** features | **${report.fixes.length}** fixes | +${report.insertions}/-${report.deletions} lines | ${report.duration}\n\n`;
414
+ if (report.features.length > 0) { md += `## Features\n${report.features.map(f => `- ${f}`).join("\n")}\n\n`; }
415
+ if (report.fixes.length > 0) { md += `## Fixes\n${report.fixes.map(f => `- ${f}`).join("\n")}\n\n`; }
416
+ if (report.refactors.length > 0) { md += `## Refactors\n${report.refactors.map(f => `- ${f}`).join("\n")}\n\n`; }
417
+ const authors = Object.entries(report.authors).map(([n, c]) => `- ${n}: ${c} commits`).join("\n");
418
+ if (authors) { md += `## Authors\n${authors}\n\n`; }
419
+ md += `---\n*Generated by ${brandName} on ${new Date().toLocaleDateString()}*\n`;
420
+ return md;
421
+ }