@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/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# ShipLog
|
|
2
|
+
|
|
3
|
+
**What did your AI build while you were at the gym?**
|
|
4
|
+
|
|
5
|
+
Session reports for vibe-coding developers. One beautiful branded email summarizing everything your AI coding assistant shipped — commits, features, fixes, deployments, and what's still pending.
|
|
6
|
+
|
|
7
|
+
## The Problem
|
|
8
|
+
|
|
9
|
+
You close your laptop. You go to the gym. You come back and:
|
|
10
|
+
- 47 individual GitHub commit notifications (noise)
|
|
11
|
+
- No idea what state your codebase is in
|
|
12
|
+
- Can't tell your team/investors what shipped today
|
|
13
|
+
- The AI coded for 3 hours and you have no summary
|
|
14
|
+
|
|
15
|
+
## The Solution
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx shiplog --email you@example.com
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
ShipLog watches your git activity, analyzes what changed, and sends you ONE branded HTML email with:
|
|
22
|
+
- Total commits and files changed
|
|
23
|
+
- Features shipped vs bugs fixed vs refactors
|
|
24
|
+
- Database migrations applied
|
|
25
|
+
- Edge functions deployed
|
|
26
|
+
- Build status (pass/fail)
|
|
27
|
+
- What's still pending
|
|
28
|
+
- Shipping velocity over time
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
npm install -g @wilsong/shiplog
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Option 1: CLI (run after a session)
|
|
37
|
+
```bash
|
|
38
|
+
shiplog send --email you@example.com --since "4 hours ago"
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
### Option 2: Watch mode (auto-send when idle)
|
|
42
|
+
```bash
|
|
43
|
+
shiplog watch --email you@example.com --idle-timeout 30m
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
### Option 3: Programmatic (in your AI workflow)
|
|
47
|
+
```typescript
|
|
48
|
+
import { ShipLog } from '@wilsong/shiplog';
|
|
49
|
+
|
|
50
|
+
const log = new ShipLog({
|
|
51
|
+
email: 'you@example.com',
|
|
52
|
+
resendApiKey: process.env.RESEND_API_KEY,
|
|
53
|
+
brandName: 'Your Company',
|
|
54
|
+
brandColor: '#0077B5',
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
// Analyze recent git activity and send report
|
|
58
|
+
await log.analyzeAndSend({ since: '4 hours ago' });
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## What Gets Analyzed
|
|
62
|
+
|
|
63
|
+
| Signal | Source |
|
|
64
|
+
|--------|--------|
|
|
65
|
+
| Commits | `git log` with diff stats |
|
|
66
|
+
| Features vs Fixes | Commit message convention detection (feat/fix/refactor) |
|
|
67
|
+
| Files Changed | Grouped by directory (components, API routes, DB, etc.) |
|
|
68
|
+
| Build Status | Detects `next build` / `npm run build` exit codes |
|
|
69
|
+
| Migrations | Supabase migration files detected |
|
|
70
|
+
| Edge Functions | Supabase edge function deploys detected |
|
|
71
|
+
| Test Results | Playwright / Vitest / Jest results if available |
|
|
72
|
+
| Dependencies | `package.json` changes (new deps added) |
|
|
73
|
+
|
|
74
|
+
## Email Template
|
|
75
|
+
|
|
76
|
+
The email is a branded HTML report that looks professional enough to forward to:
|
|
77
|
+
- Your team (daily standup replacement)
|
|
78
|
+
- Your investors (weekly shipping updates)
|
|
79
|
+
- Your clients (progress reports)
|
|
80
|
+
- Yourself (session memory for ADHD developers)
|
|
81
|
+
|
|
82
|
+
## Configuration
|
|
83
|
+
|
|
84
|
+
Create a `.shiplog.json` in your project root:
|
|
85
|
+
|
|
86
|
+
```json
|
|
87
|
+
{
|
|
88
|
+
"email": "you@example.com",
|
|
89
|
+
"cc": ["team@example.com", "investor@example.com"],
|
|
90
|
+
"resendApiKey": "re_...",
|
|
91
|
+
"brandName": "Drivia",
|
|
92
|
+
"brandColor": "#0077B5",
|
|
93
|
+
"idleTimeout": "30m",
|
|
94
|
+
"includeLinkedInDraft": true,
|
|
95
|
+
"includeSlackWebhook": "https://hooks.slack.com/...",
|
|
96
|
+
"commitConventions": true,
|
|
97
|
+
"groupByDirectory": true
|
|
98
|
+
}
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Integrations (Roadmap)
|
|
102
|
+
|
|
103
|
+
- [x] Email via Resend
|
|
104
|
+
- [ ] Slack webhook
|
|
105
|
+
- [ ] Discord webhook
|
|
106
|
+
- [ ] Linear ticket updates
|
|
107
|
+
- [ ] Notion page creation
|
|
108
|
+
- [ ] LinkedIn post draft generation
|
|
109
|
+
- [ ] GitHub Release auto-creation
|
|
110
|
+
- [ ] Investor update template (monthly)
|
|
111
|
+
|
|
112
|
+
## Why This Exists
|
|
113
|
+
|
|
114
|
+
Built by [Wilson Guenther](https://drivia.consulting) — a founder with ADHD who codes with AI for 6+ hours a day and needed a way to remember what shipped.
|
|
115
|
+
|
|
116
|
+
The vibe-coding revolution means developers ship more in a day than ever before. But if you can't communicate what you shipped, it doesn't count. ShipLog bridges the gap between building and communicating.
|
|
117
|
+
|
|
118
|
+
## License
|
|
119
|
+
|
|
120
|
+
MIT
|
|
@@ -0,0 +1,200 @@
|
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export {
|
|
198
|
+
__require,
|
|
199
|
+
ShipLog
|
|
200
|
+
};
|
package/dist/cli.cjs
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __create = Object.create;
|
|
4
|
+
var __defProp = Object.defineProperty;
|
|
5
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
6
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
7
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
8
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
9
|
+
var __copyProps = (to, from, except, desc) => {
|
|
10
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
11
|
+
for (let key of __getOwnPropNames(from))
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
13
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
14
|
+
}
|
|
15
|
+
return to;
|
|
16
|
+
};
|
|
17
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
18
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
19
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
20
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
21
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
22
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
23
|
+
mod
|
|
24
|
+
));
|
|
25
|
+
|
|
26
|
+
// src/cli.ts
|
|
27
|
+
var import_commander = require("commander");
|
|
28
|
+
var import_chalk = __toESM(require("chalk"), 1);
|
|
29
|
+
var import_ora = __toESM(require("ora"), 1);
|
|
30
|
+
|
|
31
|
+
// src/index.ts
|
|
32
|
+
var import_simple_git = __toESM(require("simple-git"), 1);
|
|
33
|
+
var import_resend = require("resend");
|
|
34
|
+
function categorizeCommit(message) {
|
|
35
|
+
const lower = message.toLowerCase();
|
|
36
|
+
if (lower.startsWith("feat") || lower.includes("add") || lower.includes("implement") || lower.includes("ship")) return "feature";
|
|
37
|
+
if (lower.startsWith("fix") || lower.includes("bug") || lower.includes("patch") || lower.includes("hotfix")) return "fix";
|
|
38
|
+
if (lower.startsWith("refactor") || lower.includes("cleanup") || lower.includes("reorganize") || lower.includes("rename")) return "refactor";
|
|
39
|
+
return "other";
|
|
40
|
+
}
|
|
41
|
+
function humanDuration(from, to) {
|
|
42
|
+
const diffMs = to.getTime() - from.getTime();
|
|
43
|
+
const hours = Math.floor(diffMs / 36e5);
|
|
44
|
+
const minutes = Math.floor(diffMs % 36e5 / 6e4);
|
|
45
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
46
|
+
return `${minutes}m`;
|
|
47
|
+
}
|
|
48
|
+
var ShipLog = class {
|
|
49
|
+
git;
|
|
50
|
+
resend;
|
|
51
|
+
config;
|
|
52
|
+
constructor(config) {
|
|
53
|
+
this.config = {
|
|
54
|
+
brandName: "ShipLog",
|
|
55
|
+
brandColor: "#0077B5",
|
|
56
|
+
fromEmail: "ShipLog <updates@shiplog.dev>",
|
|
57
|
+
...config
|
|
58
|
+
};
|
|
59
|
+
this.git = (0, import_simple_git.default)(config.repoPath || process.cwd());
|
|
60
|
+
this.resend = new import_resend.Resend(config.resendApiKey);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Analyze git history since a given time and return a structured report
|
|
64
|
+
*/
|
|
65
|
+
async analyze(options = {}) {
|
|
66
|
+
const since = options.since || "8 hours ago";
|
|
67
|
+
const branch = options.branch || "HEAD";
|
|
68
|
+
const log = await this.git.log({
|
|
69
|
+
from: "",
|
|
70
|
+
to: branch,
|
|
71
|
+
"--since": since,
|
|
72
|
+
"--stat": null
|
|
73
|
+
});
|
|
74
|
+
const features = [];
|
|
75
|
+
const fixes = [];
|
|
76
|
+
const refactors = [];
|
|
77
|
+
const other = [];
|
|
78
|
+
const authors = {};
|
|
79
|
+
const directories = {};
|
|
80
|
+
let totalInsertions = 0;
|
|
81
|
+
let totalDeletions = 0;
|
|
82
|
+
let totalFilesChanged = 0;
|
|
83
|
+
for (const commit of log.all) {
|
|
84
|
+
const msg = commit.message.split("\n")[0];
|
|
85
|
+
const category = categorizeCommit(msg);
|
|
86
|
+
switch (category) {
|
|
87
|
+
case "feature":
|
|
88
|
+
features.push(msg);
|
|
89
|
+
break;
|
|
90
|
+
case "fix":
|
|
91
|
+
fixes.push(msg);
|
|
92
|
+
break;
|
|
93
|
+
case "refactor":
|
|
94
|
+
refactors.push(msg);
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
other.push(msg);
|
|
98
|
+
break;
|
|
99
|
+
}
|
|
100
|
+
const author = commit.author_name || "Unknown";
|
|
101
|
+
authors[author] = (authors[author] || 0) + 1;
|
|
102
|
+
const diffStat = commit.diff?.changed || 0;
|
|
103
|
+
const ins = commit.diff?.insertions || 0;
|
|
104
|
+
const del = commit.diff?.deletions || 0;
|
|
105
|
+
totalFilesChanged += diffStat;
|
|
106
|
+
totalInsertions += ins;
|
|
107
|
+
totalDeletions += del;
|
|
108
|
+
if (commit.diff?.files) {
|
|
109
|
+
for (const file of commit.diff.files) {
|
|
110
|
+
const dir = file.file.split("/").slice(0, 2).join("/");
|
|
111
|
+
directories[dir] = (directories[dir] || 0) + 1;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
const commits = log.all;
|
|
116
|
+
const from = commits.length > 0 ? commits[commits.length - 1].date : (/* @__PURE__ */ new Date()).toISOString();
|
|
117
|
+
const to = commits.length > 0 ? commits[0].date : (/* @__PURE__ */ new Date()).toISOString();
|
|
118
|
+
const duration = humanDuration(new Date(from), new Date(to));
|
|
119
|
+
const report = {
|
|
120
|
+
totalCommits: log.total,
|
|
121
|
+
filesChanged: totalFilesChanged,
|
|
122
|
+
insertions: totalInsertions,
|
|
123
|
+
deletions: totalDeletions,
|
|
124
|
+
features,
|
|
125
|
+
fixes,
|
|
126
|
+
refactors,
|
|
127
|
+
other,
|
|
128
|
+
authors,
|
|
129
|
+
directories,
|
|
130
|
+
timeRange: { from, to },
|
|
131
|
+
duration
|
|
132
|
+
};
|
|
133
|
+
if (this.config.includeLinkedInDraft) {
|
|
134
|
+
report.linkedInDraft = this.generateLinkedInDraft(report);
|
|
135
|
+
}
|
|
136
|
+
return report;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Generate a branded HTML email from a session report
|
|
140
|
+
*/
|
|
141
|
+
buildEmail(report) {
|
|
142
|
+
const { brandName, brandColor } = this.config;
|
|
143
|
+
const featureList = report.features.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
144
|
+
const fixList = report.fixes.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
145
|
+
const refactorList = report.refactors.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
146
|
+
const otherList = report.other.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
147
|
+
const sections = [];
|
|
148
|
+
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>`);
|
|
149
|
+
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>`);
|
|
150
|
+
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>`);
|
|
151
|
+
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>`);
|
|
152
|
+
const authorRows = Object.entries(report.authors).map(([name, count]) => `<span style="margin-right:12px">${escapeHtml(name)}: <strong>${count}</strong></span>`).join("");
|
|
153
|
+
return `<div style="font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a2e">
|
|
154
|
+
<div style="background:${brandColor};color:white;padding:16px 20px;border-radius:12px 12px 0 0">
|
|
155
|
+
<h1 style="margin:0;font-size:20px">${escapeHtml(brandName)} \u2014 Session Report</h1>
|
|
156
|
+
<p style="margin:4px 0 0;opacity:0.85;font-size:13px">${report.timeRange.from.split("T")[0]} \u2022 ${report.duration} session</p>
|
|
157
|
+
</div>
|
|
158
|
+
|
|
159
|
+
<div style="background:#f8fafc;padding:16px 20px;border:1px solid #e2e8f0;border-top:none">
|
|
160
|
+
<div style="display:flex;gap:20px;text-align:center">
|
|
161
|
+
<div><div style="font-size:28px;font-weight:bold;color:${brandColor}">${report.totalCommits}</div><div style="font-size:11px;color:#64748b">Commits</div></div>
|
|
162
|
+
<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>
|
|
163
|
+
<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>
|
|
164
|
+
<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>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
<div style="padding:16px 20px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
169
|
+
${sections.join("\n")}
|
|
170
|
+
<div style="margin-top:16px;padding:12px;background:#f1f5f9;border-radius:8px;font-size:12px;color:#475569">
|
|
171
|
+
<strong>Authors:</strong> ${authorRows}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
|
|
175
|
+
<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>
|
|
176
|
+
</div>`;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Analyze git history and send the email report
|
|
180
|
+
*/
|
|
181
|
+
async analyzeAndSend(options = {}) {
|
|
182
|
+
const report = await this.analyze(options);
|
|
183
|
+
if (report.totalCommits === 0) {
|
|
184
|
+
throw new Error("No commits found in the specified time range");
|
|
185
|
+
}
|
|
186
|
+
const html = this.buildEmail(report);
|
|
187
|
+
const subject = `${this.config.brandName}: ${report.totalCommits} commits \u2014 ${report.features.length} features, ${report.fixes.length} fixes`;
|
|
188
|
+
const { data, error } = await this.resend.emails.send({
|
|
189
|
+
from: this.config.fromEmail,
|
|
190
|
+
to: this.config.email,
|
|
191
|
+
cc: this.config.cc,
|
|
192
|
+
subject,
|
|
193
|
+
html
|
|
194
|
+
});
|
|
195
|
+
if (error) throw new Error(`Email send failed: ${error.message}`);
|
|
196
|
+
return { report, emailId: data?.id || "sent" };
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Generate a LinkedIn post draft from the session report
|
|
200
|
+
*/
|
|
201
|
+
generateLinkedInDraft(report) {
|
|
202
|
+
const topFeatures = report.features.slice(0, 3).map((f) => `\u2192 ${f}`).join("\n");
|
|
203
|
+
return `Shipped ${report.totalCommits} commits today.
|
|
204
|
+
|
|
205
|
+
${report.features.length} features. ${report.fixes.length} fixes. ${report.duration} of building.
|
|
206
|
+
|
|
207
|
+
${topFeatures}
|
|
208
|
+
|
|
209
|
+
The best developers don't just code \u2014 they ship. And they communicate what they shipped.
|
|
210
|
+
|
|
211
|
+
What did you build today?
|
|
212
|
+
|
|
213
|
+
#BuildInPublic #ShipLog #FounderLife`;
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
function escapeHtml(str) {
|
|
217
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// src/cli.ts
|
|
221
|
+
var import_fs = require("fs");
|
|
222
|
+
var import_path = require("path");
|
|
223
|
+
var program = new import_commander.Command();
|
|
224
|
+
function loadConfig() {
|
|
225
|
+
const configPath = (0, import_path.join)(process.cwd(), ".shiplog.json");
|
|
226
|
+
if ((0, import_fs.existsSync)(configPath)) {
|
|
227
|
+
try {
|
|
228
|
+
return JSON.parse((0, import_fs.readFileSync)(configPath, "utf8"));
|
|
229
|
+
} catch {
|
|
230
|
+
return {};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return {};
|
|
234
|
+
}
|
|
235
|
+
program.name("shiplog").description("Session reports for vibe-coding developers").version("0.1.0");
|
|
236
|
+
program.command("send").description("Analyze recent git commits and send an email report").option("-e, --email <email>", "Email address to send report to").option("-s, --since <time>", "Time range (e.g. '4 hours ago', '1 day ago')", "8 hours ago").option("-b, --branch <branch>", "Git branch to analyze", "HEAD").option("-k, --key <apiKey>", "Resend API key").option("--brand <name>", "Brand name for email header").option("--color <hex>", "Brand color (hex)").option("--cc <emails>", "CC recipients (comma-separated)").option("--linkedin", "Include LinkedIn post draft").option("--dry-run", "Analyze only, don't send email").action(async (options) => {
|
|
237
|
+
const config = loadConfig();
|
|
238
|
+
const email = options.email || config.email || process.env.SHIPLOG_EMAIL;
|
|
239
|
+
const apiKey = options.key || config.resendApiKey || process.env.RESEND_API_KEY;
|
|
240
|
+
if (!email) {
|
|
241
|
+
console.error(import_chalk.default.red("Error: --email is required (or set in .shiplog.json / SHIPLOG_EMAIL env)"));
|
|
242
|
+
process.exit(1);
|
|
243
|
+
}
|
|
244
|
+
if (!apiKey && !options.dryRun) {
|
|
245
|
+
console.error(import_chalk.default.red("Error: --key is required (or set in .shiplog.json / RESEND_API_KEY env)"));
|
|
246
|
+
process.exit(1);
|
|
247
|
+
}
|
|
248
|
+
const spinner = (0, import_ora.default)("Analyzing git history...").start();
|
|
249
|
+
try {
|
|
250
|
+
const shiplog = new ShipLog({
|
|
251
|
+
email,
|
|
252
|
+
resendApiKey: apiKey || "",
|
|
253
|
+
brandName: options.brand || config.brandName || "ShipLog",
|
|
254
|
+
brandColor: options.color || config.brandColor || "#0077B5",
|
|
255
|
+
fromEmail: config.fromEmail,
|
|
256
|
+
cc: options.cc ? options.cc.split(",").map((e) => e.trim()) : config.cc,
|
|
257
|
+
includeLinkedInDraft: options.linkedin || config.includeLinkedInDraft
|
|
258
|
+
});
|
|
259
|
+
const report = await shiplog.analyze({ since: options.since, branch: options.branch });
|
|
260
|
+
spinner.succeed(import_chalk.default.green(`Found ${report.totalCommits} commits in ${report.duration}`));
|
|
261
|
+
console.log("");
|
|
262
|
+
console.log(import_chalk.default.bold("\u{1F4CA} Session Summary"));
|
|
263
|
+
console.log(import_chalk.default.gray("\u2500".repeat(40)));
|
|
264
|
+
console.log(` ${import_chalk.default.green("\u2728 Features:")} ${report.features.length}`);
|
|
265
|
+
report.features.forEach((f) => console.log(` ${import_chalk.default.green("\u2192")} ${f}`));
|
|
266
|
+
console.log(` ${import_chalk.default.red("\u{1F527} Fixes:")} ${report.fixes.length}`);
|
|
267
|
+
report.fixes.forEach((f) => console.log(` ${import_chalk.default.red("\u2192")} ${f}`));
|
|
268
|
+
console.log(` ${import_chalk.default.blue("\u267B\uFE0F Refactors:")} ${report.refactors.length}`);
|
|
269
|
+
console.log(` ${import_chalk.default.gray("\u{1F4DD} Other:")} ${report.other.length}`);
|
|
270
|
+
console.log(` ${import_chalk.default.cyan("\u{1F4C4} Lines:")} +${report.insertions} / -${report.deletions}`);
|
|
271
|
+
console.log("");
|
|
272
|
+
if (report.linkedInDraft) {
|
|
273
|
+
console.log(import_chalk.default.bold("\u{1F4BC} LinkedIn Draft:"));
|
|
274
|
+
console.log(import_chalk.default.gray("\u2500".repeat(40)));
|
|
275
|
+
console.log(import_chalk.default.dim(report.linkedInDraft));
|
|
276
|
+
console.log("");
|
|
277
|
+
}
|
|
278
|
+
if (options.dryRun) {
|
|
279
|
+
console.log(import_chalk.default.yellow("Dry run \u2014 email not sent."));
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
const sendSpinner = (0, import_ora.default)(`Sending report to ${email}...`).start();
|
|
283
|
+
const result = await shiplog.analyzeAndSend({ since: options.since, branch: options.branch });
|
|
284
|
+
sendSpinner.succeed(import_chalk.default.green(`Email sent! (${result.emailId})`));
|
|
285
|
+
} catch (err) {
|
|
286
|
+
spinner.fail(import_chalk.default.red(err.message));
|
|
287
|
+
process.exit(1);
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
program.command("init").description("Create a .shiplog.json config file").action(() => {
|
|
291
|
+
const configPath = (0, import_path.join)(process.cwd(), ".shiplog.json");
|
|
292
|
+
if ((0, import_fs.existsSync)(configPath)) {
|
|
293
|
+
console.log(import_chalk.default.yellow(".shiplog.json already exists"));
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
const template = JSON.stringify({
|
|
297
|
+
email: "you@example.com",
|
|
298
|
+
resendApiKey: "re_...",
|
|
299
|
+
brandName: "Your Project",
|
|
300
|
+
brandColor: "#0077B5",
|
|
301
|
+
includeLinkedInDraft: true
|
|
302
|
+
}, null, 2);
|
|
303
|
+
require("fs").writeFileSync(configPath, template);
|
|
304
|
+
console.log(import_chalk.default.green("Created .shiplog.json \u2014 edit it with your settings"));
|
|
305
|
+
});
|
|
306
|
+
program.parse();
|
package/dist/cli.d.cts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|