@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/dist/cli.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import {
|
|
3
|
+
ShipLog,
|
|
4
|
+
__require
|
|
5
|
+
} from "./chunk-5DFHNS4N.js";
|
|
6
|
+
|
|
7
|
+
// src/cli.ts
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
import chalk from "chalk";
|
|
10
|
+
import ora from "ora";
|
|
11
|
+
import { readFileSync, existsSync } from "fs";
|
|
12
|
+
import { join } from "path";
|
|
13
|
+
var program = new Command();
|
|
14
|
+
function loadConfig() {
|
|
15
|
+
const configPath = join(process.cwd(), ".shiplog.json");
|
|
16
|
+
if (existsSync(configPath)) {
|
|
17
|
+
try {
|
|
18
|
+
return JSON.parse(readFileSync(configPath, "utf8"));
|
|
19
|
+
} catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {};
|
|
24
|
+
}
|
|
25
|
+
program.name("shiplog").description("Session reports for vibe-coding developers").version("0.1.0");
|
|
26
|
+
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) => {
|
|
27
|
+
const config = loadConfig();
|
|
28
|
+
const email = options.email || config.email || process.env.SHIPLOG_EMAIL;
|
|
29
|
+
const apiKey = options.key || config.resendApiKey || process.env.RESEND_API_KEY;
|
|
30
|
+
if (!email) {
|
|
31
|
+
console.error(chalk.red("Error: --email is required (or set in .shiplog.json / SHIPLOG_EMAIL env)"));
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
if (!apiKey && !options.dryRun) {
|
|
35
|
+
console.error(chalk.red("Error: --key is required (or set in .shiplog.json / RESEND_API_KEY env)"));
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
const spinner = ora("Analyzing git history...").start();
|
|
39
|
+
try {
|
|
40
|
+
const shiplog = new ShipLog({
|
|
41
|
+
email,
|
|
42
|
+
resendApiKey: apiKey || "",
|
|
43
|
+
brandName: options.brand || config.brandName || "ShipLog",
|
|
44
|
+
brandColor: options.color || config.brandColor || "#0077B5",
|
|
45
|
+
fromEmail: config.fromEmail,
|
|
46
|
+
cc: options.cc ? options.cc.split(",").map((e) => e.trim()) : config.cc,
|
|
47
|
+
includeLinkedInDraft: options.linkedin || config.includeLinkedInDraft
|
|
48
|
+
});
|
|
49
|
+
const report = await shiplog.analyze({ since: options.since, branch: options.branch });
|
|
50
|
+
spinner.succeed(chalk.green(`Found ${report.totalCommits} commits in ${report.duration}`));
|
|
51
|
+
console.log("");
|
|
52
|
+
console.log(chalk.bold("\u{1F4CA} Session Summary"));
|
|
53
|
+
console.log(chalk.gray("\u2500".repeat(40)));
|
|
54
|
+
console.log(` ${chalk.green("\u2728 Features:")} ${report.features.length}`);
|
|
55
|
+
report.features.forEach((f) => console.log(` ${chalk.green("\u2192")} ${f}`));
|
|
56
|
+
console.log(` ${chalk.red("\u{1F527} Fixes:")} ${report.fixes.length}`);
|
|
57
|
+
report.fixes.forEach((f) => console.log(` ${chalk.red("\u2192")} ${f}`));
|
|
58
|
+
console.log(` ${chalk.blue("\u267B\uFE0F Refactors:")} ${report.refactors.length}`);
|
|
59
|
+
console.log(` ${chalk.gray("\u{1F4DD} Other:")} ${report.other.length}`);
|
|
60
|
+
console.log(` ${chalk.cyan("\u{1F4C4} Lines:")} +${report.insertions} / -${report.deletions}`);
|
|
61
|
+
console.log("");
|
|
62
|
+
if (report.linkedInDraft) {
|
|
63
|
+
console.log(chalk.bold("\u{1F4BC} LinkedIn Draft:"));
|
|
64
|
+
console.log(chalk.gray("\u2500".repeat(40)));
|
|
65
|
+
console.log(chalk.dim(report.linkedInDraft));
|
|
66
|
+
console.log("");
|
|
67
|
+
}
|
|
68
|
+
if (options.dryRun) {
|
|
69
|
+
console.log(chalk.yellow("Dry run \u2014 email not sent."));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const sendSpinner = ora(`Sending report to ${email}...`).start();
|
|
73
|
+
const result = await shiplog.analyzeAndSend({ since: options.since, branch: options.branch });
|
|
74
|
+
sendSpinner.succeed(chalk.green(`Email sent! (${result.emailId})`));
|
|
75
|
+
} catch (err) {
|
|
76
|
+
spinner.fail(chalk.red(err.message));
|
|
77
|
+
process.exit(1);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
program.command("init").description("Create a .shiplog.json config file").action(() => {
|
|
81
|
+
const configPath = join(process.cwd(), ".shiplog.json");
|
|
82
|
+
if (existsSync(configPath)) {
|
|
83
|
+
console.log(chalk.yellow(".shiplog.json already exists"));
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const template = JSON.stringify({
|
|
87
|
+
email: "you@example.com",
|
|
88
|
+
resendApiKey: "re_...",
|
|
89
|
+
brandName: "Your Project",
|
|
90
|
+
brandColor: "#0077B5",
|
|
91
|
+
includeLinkedInDraft: true
|
|
92
|
+
}, null, 2);
|
|
93
|
+
__require("fs").writeFileSync(configPath, template);
|
|
94
|
+
console.log(chalk.green("Created .shiplog.json \u2014 edit it with your settings"));
|
|
95
|
+
});
|
|
96
|
+
program.parse();
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
ShipLog: () => ShipLog
|
|
34
|
+
});
|
|
35
|
+
module.exports = __toCommonJS(index_exports);
|
|
36
|
+
var import_simple_git = __toESM(require("simple-git"), 1);
|
|
37
|
+
var import_resend = require("resend");
|
|
38
|
+
function categorizeCommit(message) {
|
|
39
|
+
const lower = message.toLowerCase();
|
|
40
|
+
if (lower.startsWith("feat") || lower.includes("add") || lower.includes("implement") || lower.includes("ship")) return "feature";
|
|
41
|
+
if (lower.startsWith("fix") || lower.includes("bug") || lower.includes("patch") || lower.includes("hotfix")) return "fix";
|
|
42
|
+
if (lower.startsWith("refactor") || lower.includes("cleanup") || lower.includes("reorganize") || lower.includes("rename")) return "refactor";
|
|
43
|
+
return "other";
|
|
44
|
+
}
|
|
45
|
+
function humanDuration(from, to) {
|
|
46
|
+
const diffMs = to.getTime() - from.getTime();
|
|
47
|
+
const hours = Math.floor(diffMs / 36e5);
|
|
48
|
+
const minutes = Math.floor(diffMs % 36e5 / 6e4);
|
|
49
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
50
|
+
return `${minutes}m`;
|
|
51
|
+
}
|
|
52
|
+
var ShipLog = class {
|
|
53
|
+
git;
|
|
54
|
+
resend;
|
|
55
|
+
config;
|
|
56
|
+
constructor(config) {
|
|
57
|
+
this.config = {
|
|
58
|
+
brandName: "ShipLog",
|
|
59
|
+
brandColor: "#0077B5",
|
|
60
|
+
fromEmail: "ShipLog <updates@shiplog.dev>",
|
|
61
|
+
...config
|
|
62
|
+
};
|
|
63
|
+
this.git = (0, import_simple_git.default)(config.repoPath || process.cwd());
|
|
64
|
+
this.resend = new import_resend.Resend(config.resendApiKey);
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Analyze git history since a given time and return a structured report
|
|
68
|
+
*/
|
|
69
|
+
async analyze(options = {}) {
|
|
70
|
+
const since = options.since || "8 hours ago";
|
|
71
|
+
const branch = options.branch || "HEAD";
|
|
72
|
+
const log = await this.git.log({
|
|
73
|
+
from: "",
|
|
74
|
+
to: branch,
|
|
75
|
+
"--since": since,
|
|
76
|
+
"--stat": null
|
|
77
|
+
});
|
|
78
|
+
const features = [];
|
|
79
|
+
const fixes = [];
|
|
80
|
+
const refactors = [];
|
|
81
|
+
const other = [];
|
|
82
|
+
const authors = {};
|
|
83
|
+
const directories = {};
|
|
84
|
+
let totalInsertions = 0;
|
|
85
|
+
let totalDeletions = 0;
|
|
86
|
+
let totalFilesChanged = 0;
|
|
87
|
+
for (const commit of log.all) {
|
|
88
|
+
const msg = commit.message.split("\n")[0];
|
|
89
|
+
const category = categorizeCommit(msg);
|
|
90
|
+
switch (category) {
|
|
91
|
+
case "feature":
|
|
92
|
+
features.push(msg);
|
|
93
|
+
break;
|
|
94
|
+
case "fix":
|
|
95
|
+
fixes.push(msg);
|
|
96
|
+
break;
|
|
97
|
+
case "refactor":
|
|
98
|
+
refactors.push(msg);
|
|
99
|
+
break;
|
|
100
|
+
default:
|
|
101
|
+
other.push(msg);
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
const author = commit.author_name || "Unknown";
|
|
105
|
+
authors[author] = (authors[author] || 0) + 1;
|
|
106
|
+
const diffStat = commit.diff?.changed || 0;
|
|
107
|
+
const ins = commit.diff?.insertions || 0;
|
|
108
|
+
const del = commit.diff?.deletions || 0;
|
|
109
|
+
totalFilesChanged += diffStat;
|
|
110
|
+
totalInsertions += ins;
|
|
111
|
+
totalDeletions += del;
|
|
112
|
+
if (commit.diff?.files) {
|
|
113
|
+
for (const file of commit.diff.files) {
|
|
114
|
+
const dir = file.file.split("/").slice(0, 2).join("/");
|
|
115
|
+
directories[dir] = (directories[dir] || 0) + 1;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const commits = log.all;
|
|
120
|
+
const from = commits.length > 0 ? commits[commits.length - 1].date : (/* @__PURE__ */ new Date()).toISOString();
|
|
121
|
+
const to = commits.length > 0 ? commits[0].date : (/* @__PURE__ */ new Date()).toISOString();
|
|
122
|
+
const duration = humanDuration(new Date(from), new Date(to));
|
|
123
|
+
const report = {
|
|
124
|
+
totalCommits: log.total,
|
|
125
|
+
filesChanged: totalFilesChanged,
|
|
126
|
+
insertions: totalInsertions,
|
|
127
|
+
deletions: totalDeletions,
|
|
128
|
+
features,
|
|
129
|
+
fixes,
|
|
130
|
+
refactors,
|
|
131
|
+
other,
|
|
132
|
+
authors,
|
|
133
|
+
directories,
|
|
134
|
+
timeRange: { from, to },
|
|
135
|
+
duration
|
|
136
|
+
};
|
|
137
|
+
if (this.config.includeLinkedInDraft) {
|
|
138
|
+
report.linkedInDraft = this.generateLinkedInDraft(report);
|
|
139
|
+
}
|
|
140
|
+
return report;
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Generate a branded HTML email from a session report
|
|
144
|
+
*/
|
|
145
|
+
buildEmail(report) {
|
|
146
|
+
const { brandName, brandColor } = this.config;
|
|
147
|
+
const featureList = report.features.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
148
|
+
const fixList = report.fixes.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
149
|
+
const refactorList = report.refactors.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
150
|
+
const otherList = report.other.map((f) => `<li>${escapeHtml(f)}</li>`).join("");
|
|
151
|
+
const sections = [];
|
|
152
|
+
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>`);
|
|
153
|
+
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>`);
|
|
154
|
+
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>`);
|
|
155
|
+
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>`);
|
|
156
|
+
const authorRows = Object.entries(report.authors).map(([name, count]) => `<span style="margin-right:12px">${escapeHtml(name)}: <strong>${count}</strong></span>`).join("");
|
|
157
|
+
return `<div style="font-family:system-ui,-apple-system,sans-serif;max-width:600px;margin:0 auto;padding:20px;color:#1a1a2e">
|
|
158
|
+
<div style="background:${brandColor};color:white;padding:16px 20px;border-radius:12px 12px 0 0">
|
|
159
|
+
<h1 style="margin:0;font-size:20px">${escapeHtml(brandName)} \u2014 Session Report</h1>
|
|
160
|
+
<p style="margin:4px 0 0;opacity:0.85;font-size:13px">${report.timeRange.from.split("T")[0]} \u2022 ${report.duration} session</p>
|
|
161
|
+
</div>
|
|
162
|
+
|
|
163
|
+
<div style="background:#f8fafc;padding:16px 20px;border:1px solid #e2e8f0;border-top:none">
|
|
164
|
+
<div style="display:flex;gap:20px;text-align:center">
|
|
165
|
+
<div><div style="font-size:28px;font-weight:bold;color:${brandColor}">${report.totalCommits}</div><div style="font-size:11px;color:#64748b">Commits</div></div>
|
|
166
|
+
<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>
|
|
167
|
+
<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>
|
|
168
|
+
<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>
|
|
169
|
+
</div>
|
|
170
|
+
</div>
|
|
171
|
+
|
|
172
|
+
<div style="padding:16px 20px;border:1px solid #e2e8f0;border-top:none;border-radius:0 0 12px 12px">
|
|
173
|
+
${sections.join("\n")}
|
|
174
|
+
<div style="margin-top:16px;padding:12px;background:#f1f5f9;border-radius:8px;font-size:12px;color:#475569">
|
|
175
|
+
<strong>Authors:</strong> ${authorRows}
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
|
|
179
|
+
<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>
|
|
180
|
+
</div>`;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Analyze git history and send the email report
|
|
184
|
+
*/
|
|
185
|
+
async analyzeAndSend(options = {}) {
|
|
186
|
+
const report = await this.analyze(options);
|
|
187
|
+
if (report.totalCommits === 0) {
|
|
188
|
+
throw new Error("No commits found in the specified time range");
|
|
189
|
+
}
|
|
190
|
+
const html = this.buildEmail(report);
|
|
191
|
+
const subject = `${this.config.brandName}: ${report.totalCommits} commits \u2014 ${report.features.length} features, ${report.fixes.length} fixes`;
|
|
192
|
+
const { data, error } = await this.resend.emails.send({
|
|
193
|
+
from: this.config.fromEmail,
|
|
194
|
+
to: this.config.email,
|
|
195
|
+
cc: this.config.cc,
|
|
196
|
+
subject,
|
|
197
|
+
html
|
|
198
|
+
});
|
|
199
|
+
if (error) throw new Error(`Email send failed: ${error.message}`);
|
|
200
|
+
return { report, emailId: data?.id || "sent" };
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Generate a LinkedIn post draft from the session report
|
|
204
|
+
*/
|
|
205
|
+
generateLinkedInDraft(report) {
|
|
206
|
+
const topFeatures = report.features.slice(0, 3).map((f) => `\u2192 ${f}`).join("\n");
|
|
207
|
+
return `Shipped ${report.totalCommits} commits today.
|
|
208
|
+
|
|
209
|
+
${report.features.length} features. ${report.fixes.length} fixes. ${report.duration} of building.
|
|
210
|
+
|
|
211
|
+
${topFeatures}
|
|
212
|
+
|
|
213
|
+
The best developers don't just code \u2014 they ship. And they communicate what they shipped.
|
|
214
|
+
|
|
215
|
+
What did you build today?
|
|
216
|
+
|
|
217
|
+
#BuildInPublic #ShipLog #FounderLife`;
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
function escapeHtml(str) {
|
|
221
|
+
return str.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
222
|
+
}
|
|
223
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
224
|
+
0 && (module.exports = {
|
|
225
|
+
ShipLog
|
|
226
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
interface ShipLogConfig {
|
|
9
|
+
/** Email address to send the report to */
|
|
10
|
+
email: string;
|
|
11
|
+
/** CC recipients (team, investors, clients) */
|
|
12
|
+
cc?: string[];
|
|
13
|
+
/** Resend API key for email delivery */
|
|
14
|
+
resendApiKey: string;
|
|
15
|
+
/** Brand name shown in email header */
|
|
16
|
+
brandName?: string;
|
|
17
|
+
/** Brand color (hex) for email accent */
|
|
18
|
+
brandColor?: string;
|
|
19
|
+
/** From email address (must be verified in Resend) */
|
|
20
|
+
fromEmail?: string;
|
|
21
|
+
/** Git repo path (defaults to cwd) */
|
|
22
|
+
repoPath?: string;
|
|
23
|
+
/** Include LinkedIn post draft in email */
|
|
24
|
+
includeLinkedInDraft?: boolean;
|
|
25
|
+
}
|
|
26
|
+
interface SessionReport {
|
|
27
|
+
totalCommits: number;
|
|
28
|
+
filesChanged: number;
|
|
29
|
+
insertions: number;
|
|
30
|
+
deletions: number;
|
|
31
|
+
features: string[];
|
|
32
|
+
fixes: string[];
|
|
33
|
+
refactors: string[];
|
|
34
|
+
other: string[];
|
|
35
|
+
authors: Record<string, number>;
|
|
36
|
+
directories: Record<string, number>;
|
|
37
|
+
timeRange: {
|
|
38
|
+
from: string;
|
|
39
|
+
to: string;
|
|
40
|
+
};
|
|
41
|
+
duration: string;
|
|
42
|
+
linkedInDraft?: string;
|
|
43
|
+
}
|
|
44
|
+
declare class ShipLog {
|
|
45
|
+
private git;
|
|
46
|
+
private resend;
|
|
47
|
+
private config;
|
|
48
|
+
constructor(config: ShipLogConfig);
|
|
49
|
+
/**
|
|
50
|
+
* Analyze git history since a given time and return a structured report
|
|
51
|
+
*/
|
|
52
|
+
analyze(options?: {
|
|
53
|
+
since?: string;
|
|
54
|
+
branch?: string;
|
|
55
|
+
}): Promise<SessionReport>;
|
|
56
|
+
/**
|
|
57
|
+
* Generate a branded HTML email from a session report
|
|
58
|
+
*/
|
|
59
|
+
buildEmail(report: SessionReport): string;
|
|
60
|
+
/**
|
|
61
|
+
* Analyze git history and send the email report
|
|
62
|
+
*/
|
|
63
|
+
analyzeAndSend(options?: {
|
|
64
|
+
since?: string;
|
|
65
|
+
branch?: string;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
report: SessionReport;
|
|
68
|
+
emailId: string;
|
|
69
|
+
}>;
|
|
70
|
+
/**
|
|
71
|
+
* Generate a LinkedIn post draft from the session report
|
|
72
|
+
*/
|
|
73
|
+
private generateLinkedInDraft;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { type SessionReport, ShipLog, type ShipLogConfig };
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
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
|
+
interface ShipLogConfig {
|
|
9
|
+
/** Email address to send the report to */
|
|
10
|
+
email: string;
|
|
11
|
+
/** CC recipients (team, investors, clients) */
|
|
12
|
+
cc?: string[];
|
|
13
|
+
/** Resend API key for email delivery */
|
|
14
|
+
resendApiKey: string;
|
|
15
|
+
/** Brand name shown in email header */
|
|
16
|
+
brandName?: string;
|
|
17
|
+
/** Brand color (hex) for email accent */
|
|
18
|
+
brandColor?: string;
|
|
19
|
+
/** From email address (must be verified in Resend) */
|
|
20
|
+
fromEmail?: string;
|
|
21
|
+
/** Git repo path (defaults to cwd) */
|
|
22
|
+
repoPath?: string;
|
|
23
|
+
/** Include LinkedIn post draft in email */
|
|
24
|
+
includeLinkedInDraft?: boolean;
|
|
25
|
+
}
|
|
26
|
+
interface SessionReport {
|
|
27
|
+
totalCommits: number;
|
|
28
|
+
filesChanged: number;
|
|
29
|
+
insertions: number;
|
|
30
|
+
deletions: number;
|
|
31
|
+
features: string[];
|
|
32
|
+
fixes: string[];
|
|
33
|
+
refactors: string[];
|
|
34
|
+
other: string[];
|
|
35
|
+
authors: Record<string, number>;
|
|
36
|
+
directories: Record<string, number>;
|
|
37
|
+
timeRange: {
|
|
38
|
+
from: string;
|
|
39
|
+
to: string;
|
|
40
|
+
};
|
|
41
|
+
duration: string;
|
|
42
|
+
linkedInDraft?: string;
|
|
43
|
+
}
|
|
44
|
+
declare class ShipLog {
|
|
45
|
+
private git;
|
|
46
|
+
private resend;
|
|
47
|
+
private config;
|
|
48
|
+
constructor(config: ShipLogConfig);
|
|
49
|
+
/**
|
|
50
|
+
* Analyze git history since a given time and return a structured report
|
|
51
|
+
*/
|
|
52
|
+
analyze(options?: {
|
|
53
|
+
since?: string;
|
|
54
|
+
branch?: string;
|
|
55
|
+
}): Promise<SessionReport>;
|
|
56
|
+
/**
|
|
57
|
+
* Generate a branded HTML email from a session report
|
|
58
|
+
*/
|
|
59
|
+
buildEmail(report: SessionReport): string;
|
|
60
|
+
/**
|
|
61
|
+
* Analyze git history and send the email report
|
|
62
|
+
*/
|
|
63
|
+
analyzeAndSend(options?: {
|
|
64
|
+
since?: string;
|
|
65
|
+
branch?: string;
|
|
66
|
+
}): Promise<{
|
|
67
|
+
report: SessionReport;
|
|
68
|
+
emailId: string;
|
|
69
|
+
}>;
|
|
70
|
+
/**
|
|
71
|
+
* Generate a LinkedIn post draft from the session report
|
|
72
|
+
*/
|
|
73
|
+
private generateLinkedInDraft;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export { type SessionReport, ShipLog, type ShipLogConfig };
|
package/dist/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wilsong/shiplog",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "What did your AI build while you were at the gym? Session reports for vibe-coding developers.",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"shiplog": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsup src/index.ts src/cli.ts --format esm,cjs --dts",
|
|
12
|
+
"dev": "tsup src/index.ts src/cli.ts --format esm,cjs --dts --watch",
|
|
13
|
+
"test": "vitest"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"developer-tools",
|
|
17
|
+
"vibe-coding",
|
|
18
|
+
"session-reports",
|
|
19
|
+
"git-analytics",
|
|
20
|
+
"ai-coding",
|
|
21
|
+
"shipping-velocity",
|
|
22
|
+
"changelog",
|
|
23
|
+
"email-reports"
|
|
24
|
+
],
|
|
25
|
+
"author": "Wilson Guenther <wilsonguenther@gmail.com>",
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"repository": {
|
|
28
|
+
"type": "git",
|
|
29
|
+
"url": "https://github.com/wilsonguenther-dev/shiplog"
|
|
30
|
+
},
|
|
31
|
+
"homepage": "https://shiplog.dev",
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"simple-git": "^3.27.0",
|
|
34
|
+
"resend": "^4.0.0",
|
|
35
|
+
"commander": "^12.1.0",
|
|
36
|
+
"chalk": "^5.3.0",
|
|
37
|
+
"ora": "^8.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"tsup": "^8.3.0",
|
|
41
|
+
"typescript": "^5.6.0",
|
|
42
|
+
"vitest": "^2.1.0",
|
|
43
|
+
"@types/node": "^22.0.0"
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* ShipLog CLI — Session reports from the command line
|
|
4
|
+
* Usage:
|
|
5
|
+
* shiplog send --email you@example.com --since "4 hours ago"
|
|
6
|
+
* shiplog watch --email you@example.com --idle-timeout 30
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { Command } from "commander";
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import ora from "ora";
|
|
12
|
+
import { ShipLog } from "./index.js";
|
|
13
|
+
import { readFileSync, existsSync } from "fs";
|
|
14
|
+
import { join } from "path";
|
|
15
|
+
|
|
16
|
+
const program = new Command();
|
|
17
|
+
|
|
18
|
+
interface ConfigFile {
|
|
19
|
+
email?: string;
|
|
20
|
+
cc?: string[];
|
|
21
|
+
resendApiKey?: string;
|
|
22
|
+
brandName?: string;
|
|
23
|
+
brandColor?: string;
|
|
24
|
+
fromEmail?: string;
|
|
25
|
+
idleTimeout?: string;
|
|
26
|
+
includeLinkedInDraft?: boolean;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function loadConfig(): ConfigFile {
|
|
30
|
+
const configPath = join(process.cwd(), ".shiplog.json");
|
|
31
|
+
if (existsSync(configPath)) {
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(readFileSync(configPath, "utf8"));
|
|
34
|
+
} catch {
|
|
35
|
+
return {};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
program
|
|
42
|
+
.name("shiplog")
|
|
43
|
+
.description("Session reports for vibe-coding developers")
|
|
44
|
+
.version("0.1.0");
|
|
45
|
+
|
|
46
|
+
program
|
|
47
|
+
.command("send")
|
|
48
|
+
.description("Analyze recent git commits and send an email report")
|
|
49
|
+
.option("-e, --email <email>", "Email address to send report to")
|
|
50
|
+
.option("-s, --since <time>", "Time range (e.g. '4 hours ago', '1 day ago')", "8 hours ago")
|
|
51
|
+
.option("-b, --branch <branch>", "Git branch to analyze", "HEAD")
|
|
52
|
+
.option("-k, --key <apiKey>", "Resend API key")
|
|
53
|
+
.option("--brand <name>", "Brand name for email header")
|
|
54
|
+
.option("--color <hex>", "Brand color (hex)")
|
|
55
|
+
.option("--cc <emails>", "CC recipients (comma-separated)")
|
|
56
|
+
.option("--linkedin", "Include LinkedIn post draft")
|
|
57
|
+
.option("--dry-run", "Analyze only, don't send email")
|
|
58
|
+
.action(async (options) => {
|
|
59
|
+
const config = loadConfig();
|
|
60
|
+
const email = options.email || config.email || process.env.SHIPLOG_EMAIL;
|
|
61
|
+
const apiKey = options.key || config.resendApiKey || process.env.RESEND_API_KEY;
|
|
62
|
+
|
|
63
|
+
if (!email) {
|
|
64
|
+
console.error(chalk.red("Error: --email is required (or set in .shiplog.json / SHIPLOG_EMAIL env)"));
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
if (!apiKey && !options.dryRun) {
|
|
68
|
+
console.error(chalk.red("Error: --key is required (or set in .shiplog.json / RESEND_API_KEY env)"));
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const spinner = ora("Analyzing git history...").start();
|
|
73
|
+
|
|
74
|
+
try {
|
|
75
|
+
const shiplog = new ShipLog({
|
|
76
|
+
email,
|
|
77
|
+
resendApiKey: apiKey || "",
|
|
78
|
+
brandName: options.brand || config.brandName || "ShipLog",
|
|
79
|
+
brandColor: options.color || config.brandColor || "#0077B5",
|
|
80
|
+
fromEmail: config.fromEmail,
|
|
81
|
+
cc: options.cc ? options.cc.split(",").map((e: string) => e.trim()) : config.cc,
|
|
82
|
+
includeLinkedInDraft: options.linkedin || config.includeLinkedInDraft,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const report = await shiplog.analyze({ since: options.since, branch: options.branch });
|
|
86
|
+
|
|
87
|
+
spinner.succeed(chalk.green(`Found ${report.totalCommits} commits in ${report.duration}`));
|
|
88
|
+
|
|
89
|
+
// Print summary to console
|
|
90
|
+
console.log("");
|
|
91
|
+
console.log(chalk.bold("📊 Session Summary"));
|
|
92
|
+
console.log(chalk.gray("─".repeat(40)));
|
|
93
|
+
console.log(` ${chalk.green("✨ Features:")} ${report.features.length}`);
|
|
94
|
+
report.features.forEach(f => console.log(` ${chalk.green("→")} ${f}`));
|
|
95
|
+
console.log(` ${chalk.red("🔧 Fixes:")} ${report.fixes.length}`);
|
|
96
|
+
report.fixes.forEach(f => console.log(` ${chalk.red("→")} ${f}`));
|
|
97
|
+
console.log(` ${chalk.blue("♻️ Refactors:")} ${report.refactors.length}`);
|
|
98
|
+
console.log(` ${chalk.gray("📝 Other:")} ${report.other.length}`);
|
|
99
|
+
console.log(` ${chalk.cyan("📄 Lines:")} +${report.insertions} / -${report.deletions}`);
|
|
100
|
+
console.log("");
|
|
101
|
+
|
|
102
|
+
if (report.linkedInDraft) {
|
|
103
|
+
console.log(chalk.bold("💼 LinkedIn Draft:"));
|
|
104
|
+
console.log(chalk.gray("─".repeat(40)));
|
|
105
|
+
console.log(chalk.dim(report.linkedInDraft));
|
|
106
|
+
console.log("");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (options.dryRun) {
|
|
110
|
+
console.log(chalk.yellow("Dry run — email not sent."));
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const sendSpinner = ora(`Sending report to ${email}...`).start();
|
|
115
|
+
const result = await shiplog.analyzeAndSend({ since: options.since, branch: options.branch });
|
|
116
|
+
sendSpinner.succeed(chalk.green(`Email sent! (${result.emailId})`));
|
|
117
|
+
|
|
118
|
+
} catch (err) {
|
|
119
|
+
spinner.fail(chalk.red((err as Error).message));
|
|
120
|
+
process.exit(1);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
program
|
|
125
|
+
.command("init")
|
|
126
|
+
.description("Create a .shiplog.json config file")
|
|
127
|
+
.action(() => {
|
|
128
|
+
const configPath = join(process.cwd(), ".shiplog.json");
|
|
129
|
+
if (existsSync(configPath)) {
|
|
130
|
+
console.log(chalk.yellow(".shiplog.json already exists"));
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
const template = JSON.stringify({
|
|
134
|
+
email: "you@example.com",
|
|
135
|
+
resendApiKey: "re_...",
|
|
136
|
+
brandName: "Your Project",
|
|
137
|
+
brandColor: "#0077B5",
|
|
138
|
+
includeLinkedInDraft: true,
|
|
139
|
+
}, null, 2);
|
|
140
|
+
require("fs").writeFileSync(configPath, template);
|
|
141
|
+
console.log(chalk.green("Created .shiplog.json — edit it with your settings"));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
program.parse();
|