deploylog 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.
package/dist/api.d.ts CHANGED
@@ -30,3 +30,25 @@ export interface CreateEntryInput {
30
30
  publish?: boolean;
31
31
  }
32
32
  export declare function createEntry(projectSlug: string, input: CreateEntryInput): Promise<Entry>;
33
+ export interface SummarizeInput {
34
+ project_slug?: string;
35
+ commits?: string[];
36
+ release_notes?: string;
37
+ version?: string;
38
+ }
39
+ export type EntryType = 'feature' | 'fix' | 'improvement' | 'breaking' | 'announcement';
40
+ export interface AiSummary {
41
+ title: string;
42
+ entry_type: EntryType;
43
+ body_markdown: string;
44
+ }
45
+ export interface SummarizeResponse {
46
+ summary: AiSummary;
47
+ model: string;
48
+ usage: {
49
+ used: number;
50
+ limit: number | null;
51
+ month_key: string;
52
+ };
53
+ }
54
+ export declare function summarize(input: SummarizeInput): Promise<SummarizeResponse>;
package/dist/api.js CHANGED
@@ -41,3 +41,9 @@ export async function createEntry(projectSlug, input) {
41
41
  body: JSON.stringify(input),
42
42
  });
43
43
  }
44
+ export async function summarize(input) {
45
+ return request('/ai-summarize', {
46
+ method: 'POST',
47
+ body: JSON.stringify(input),
48
+ });
49
+ }
package/dist/git.d.ts ADDED
@@ -0,0 +1,38 @@
1
+ export declare class GitError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ /**
5
+ * Thin shell-out wrapper. Exported for test injection.
6
+ * Returns stdout with trailing newline trimmed, or null on non-zero exit.
7
+ */
8
+ export declare function runGit(args: string[], cwd?: string): string | null;
9
+ export interface GitRunner {
10
+ (args: string[]): string | null;
11
+ }
12
+ export declare function isGitRepo(run?: GitRunner): boolean;
13
+ /**
14
+ * Most recent annotated or lightweight tag reachable from HEAD, or null.
15
+ */
16
+ export declare function getLastTag(run?: GitRunner): string | null;
17
+ /**
18
+ * If HEAD is on a tag that looks like semver (v1.2.3 or 1.2.3), return the
19
+ * bare semver string (without the 'v'). Otherwise null.
20
+ */
21
+ export declare function getHeadVersion(run?: GitRunner): string | null;
22
+ export interface CommitSummary {
23
+ hash: string;
24
+ subject: string;
25
+ }
26
+ /**
27
+ * Commit subjects (and short hashes) between `ref` and HEAD, oldest-first.
28
+ * If `ref` is null, returns the most recent `limit` commits on HEAD.
29
+ */
30
+ export declare function getCommitsSince(ref: string | null, limit?: number, run?: GitRunner): CommitSummary[];
31
+ /**
32
+ * Derive a sensible default title from the latest tag (or fallback).
33
+ */
34
+ export declare function defaultTitleFromGit(version: string | null, lastTag: string | null): string;
35
+ /**
36
+ * Format a commit list as a markdown bullet body.
37
+ */
38
+ export declare function formatCommitsAsMarkdown(commits: CommitSummary[]): string;
package/dist/git.js ADDED
@@ -0,0 +1,92 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ export class GitError extends Error {
3
+ constructor(message) {
4
+ super(message);
5
+ this.name = 'GitError';
6
+ }
7
+ }
8
+ /**
9
+ * Thin shell-out wrapper. Exported for test injection.
10
+ * Returns stdout with trailing newline trimmed, or null on non-zero exit.
11
+ */
12
+ export function runGit(args, cwd = process.cwd()) {
13
+ try {
14
+ return execFileSync('git', args, {
15
+ cwd,
16
+ encoding: 'utf8',
17
+ stdio: ['ignore', 'pipe', 'pipe'],
18
+ }).replace(/\n$/, '');
19
+ }
20
+ catch {
21
+ return null;
22
+ }
23
+ }
24
+ export function isGitRepo(run = runGit) {
25
+ return run(['rev-parse', '--is-inside-work-tree']) === 'true';
26
+ }
27
+ /**
28
+ * Most recent annotated or lightweight tag reachable from HEAD, or null.
29
+ */
30
+ export function getLastTag(run = runGit) {
31
+ const out = run(['describe', '--tags', '--abbrev=0']);
32
+ return out && out.length > 0 ? out : null;
33
+ }
34
+ /**
35
+ * If HEAD is on a tag that looks like semver (v1.2.3 or 1.2.3), return the
36
+ * bare semver string (without the 'v'). Otherwise null.
37
+ */
38
+ export function getHeadVersion(run = runGit) {
39
+ const exact = run(['tag', '--points-at', 'HEAD']);
40
+ if (!exact)
41
+ return null;
42
+ for (const line of exact.split('\n')) {
43
+ const m = line.match(/^v?(\d+\.\d+\.\d+)$/);
44
+ if (m?.[1])
45
+ return m[1];
46
+ }
47
+ return null;
48
+ }
49
+ /**
50
+ * Commit subjects (and short hashes) between `ref` and HEAD, oldest-first.
51
+ * If `ref` is null, returns the most recent `limit` commits on HEAD.
52
+ */
53
+ export function getCommitsSince(ref, limit = 200, run = runGit) {
54
+ const range = ref ? `${ref}..HEAD` : 'HEAD';
55
+ const args = ['log', range, `--pretty=format:%h\t%s`, '--no-merges', `-${limit}`, '--reverse'];
56
+ const out = run(args);
57
+ if (out === null) {
58
+ // `ref..HEAD` with an unknown ref (or an empty repo) returns null via our wrapper.
59
+ return [];
60
+ }
61
+ if (out.length === 0)
62
+ return [];
63
+ const commits = [];
64
+ for (const line of out.split('\n')) {
65
+ const tab = line.indexOf('\t');
66
+ if (tab < 0)
67
+ continue;
68
+ const hash = line.slice(0, tab);
69
+ const subject = line.slice(tab + 1).trim();
70
+ if (subject.length > 0)
71
+ commits.push({ hash, subject });
72
+ }
73
+ return commits;
74
+ }
75
+ /**
76
+ * Derive a sensible default title from the latest tag (or fallback).
77
+ */
78
+ export function defaultTitleFromGit(version, lastTag) {
79
+ if (version)
80
+ return `Release v${version}`;
81
+ if (lastTag)
82
+ return `Changes since ${lastTag}`;
83
+ return 'Recent changes';
84
+ }
85
+ /**
86
+ * Format a commit list as a markdown bullet body.
87
+ */
88
+ export function formatCommitsAsMarkdown(commits) {
89
+ if (commits.length === 0)
90
+ return '_No new commits since last tag._';
91
+ return commits.map((c) => `- ${c.subject}`).join('\n');
92
+ }
package/dist/index.js CHANGED
@@ -2,13 +2,14 @@
2
2
  import { Command } from 'commander';
3
3
  import chalk from 'chalk';
4
4
  import { setApiKey, setApiUrl, getConfigPath, clearConfig } from './config.js';
5
- import { listProjects, listEntries, createEntry, ApiError } from './api.js';
5
+ import { listProjects, listEntries, createEntry, summarize, ApiError, } from './api.js';
6
6
  import { readProjectConfig } from './project-config.js';
7
+ import { isGitRepo, getLastTag, getHeadVersion, getCommitsSince, defaultTitleFromGit, formatCommitsAsMarkdown, } from './git.js';
7
8
  const program = new Command();
8
9
  program
9
10
  .name('deploylog')
10
11
  .description('Push changelog entries from the terminal')
11
- .version('0.1.0');
12
+ .version('0.2.0');
12
13
  // ─── login ──────────────────────────────────────────────────────────────────
13
14
  program
14
15
  .command('login')
@@ -108,22 +109,82 @@ program
108
109
  program
109
110
  .command('push')
110
111
  .description('Create a new changelog entry')
111
- .requiredOption('-t, --title <title>', 'Entry title')
112
- .requiredOption('-b, --body <markdown>', 'Entry body (Markdown)')
112
+ .option('-t, --title <title>', 'Entry title (required unless --from-git or --ai-summarize)')
113
+ .option('-b, --body <markdown>', 'Entry body (Markdown)')
113
114
  .option('-p, --project <slug>', 'Project slug (or set in .deploylog.yml)')
114
115
  .option('--type <type>', 'Entry type: feature, fix, improvement, breaking, announcement')
115
116
  .option('--version <version>', 'Semver version (e.g. 1.2.3)')
116
117
  .option('--publish', 'Publish immediately (default: draft)')
117
118
  .option('--draft', 'Save as draft (default)')
119
+ .option('--from-git', 'Derive title/body from commits since the last tag')
120
+ .option('--ai-summarize', 'Rewrite the entry with Claude Haiku (user-friendly release notes)')
121
+ .option('-y, --yes', 'Skip interactive confirmation for AI-generated content')
118
122
  .action(async (opts) => {
119
123
  try {
120
124
  const slug = resolveProject(opts.project);
121
125
  const projectConfig = readProjectConfig();
126
+ // Gather source material (commits + version) if --from-git.
127
+ let commits = [];
128
+ let gitVersion = null;
129
+ let gitTitle = null;
130
+ let gitBody = null;
131
+ if (opts.fromGit) {
132
+ if (!isGitRepo()) {
133
+ console.error(chalk.red('Not in a git repository. Remove --from-git or cd to a repo.'));
134
+ process.exit(1);
135
+ }
136
+ const lastTag = getLastTag();
137
+ commits = getCommitsSince(lastTag);
138
+ gitVersion = getHeadVersion();
139
+ gitTitle = defaultTitleFromGit(gitVersion, lastTag);
140
+ gitBody = formatCommitsAsMarkdown(commits);
141
+ if (commits.length === 0 && !opts.body && !opts.aiSummarize) {
142
+ console.error(chalk.yellow(lastTag
143
+ ? `No commits since tag ${lastTag}. Nothing to summarize.`
144
+ : 'No commits found on HEAD.'));
145
+ process.exit(1);
146
+ }
147
+ }
148
+ // Build the entry (with optional AI rewrite).
149
+ let title = opts.title ?? gitTitle ?? '';
150
+ let body = opts.body ?? gitBody ?? '';
151
+ let entryType = opts.type ?? projectConfig?.default_type ?? null;
152
+ const version = opts.version ?? gitVersion ?? undefined;
153
+ if (opts.aiSummarize) {
154
+ const hasSource = commits.length > 0 || (opts.body && opts.body.trim().length > 0);
155
+ if (!hasSource) {
156
+ console.error(chalk.red('--ai-summarize needs source material. Pass --from-git or provide --body as raw notes.'));
157
+ process.exit(1);
158
+ }
159
+ process.stdout.write(chalk.dim('Summarizing with Claude Haiku... '));
160
+ const res = await summarize({
161
+ project_slug: slug,
162
+ commits: commits.map((c) => c.subject),
163
+ release_notes: opts.body,
164
+ version,
165
+ });
166
+ process.stdout.write(chalk.green('done\n'));
167
+ title = opts.title ?? res.summary.title;
168
+ body = res.summary.body_markdown;
169
+ entryType = opts.type ?? res.summary.entry_type;
170
+ printAiPreview(res.summary, res.usage);
171
+ if (!opts.yes && process.stdin.isTTY) {
172
+ const ok = await confirm('Publish this entry?');
173
+ if (!ok) {
174
+ console.log(chalk.yellow('Cancelled. No entry was created.'));
175
+ return;
176
+ }
177
+ }
178
+ }
179
+ if (!title || !body) {
180
+ console.error(chalk.red('Entry requires --title and --body (or --from-git / --ai-summarize to derive them).'));
181
+ process.exit(1);
182
+ }
122
183
  const entry = await createEntry(slug, {
123
- title: opts.title,
124
- body_markdown: opts.body,
125
- entry_type: opts.type ?? projectConfig?.default_type ?? null,
126
- version: opts.version,
184
+ title,
185
+ body_markdown: body,
186
+ entry_type: entryType,
187
+ version,
127
188
  publish: opts.publish && !opts.draft,
128
189
  });
129
190
  const status = entry.published
@@ -140,6 +201,28 @@ program
140
201
  }
141
202
  });
142
203
  // ─── helpers ────────────────────────────────────────────────────────────────
204
+ function printAiPreview(summary, usage) {
205
+ console.log();
206
+ console.log(chalk.bold('AI-generated entry:'));
207
+ console.log(` ${chalk.dim('Title:')} ${summary.title}`);
208
+ console.log(` ${chalk.dim('Type:')} ${summary.entry_type}`);
209
+ console.log(chalk.dim(' Body:'));
210
+ for (const line of summary.body_markdown.split('\n')) {
211
+ console.log(` ${line}`);
212
+ }
213
+ const limitLabel = usage.limit === null ? '∞' : String(usage.limit);
214
+ console.log(chalk.dim(` Usage: ${usage.used}/${limitLabel} this month (${usage.month_key})`));
215
+ console.log();
216
+ }
217
+ async function confirm(question) {
218
+ const { createInterface } = await import('node:readline');
219
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
220
+ const answer = await new Promise((resolve) => {
221
+ rl.question(`${question} [y/N] `, resolve);
222
+ });
223
+ rl.close();
224
+ return /^y(es)?$/i.test(answer.trim());
225
+ }
143
226
  function resolveProject(cliArg) {
144
227
  if (cliArg)
145
228
  return cliArg;
package/package.json CHANGED
@@ -1,19 +1,29 @@
1
1
  {
2
2
  "name": "deploylog",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Push changelog entries from the terminal",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "deploylog": "./dist/index.js"
8
8
  },
9
- "files": ["dist"],
9
+ "files": [
10
+ "dist"
11
+ ],
10
12
  "scripts": {
11
13
  "build": "tsc",
12
14
  "dev": "tsc --watch",
13
15
  "prepublishOnly": "tsc",
14
- "start": "node dist/index.js"
16
+ "start": "node dist/index.js",
17
+ "test": "vitest run",
18
+ "test:watch": "vitest"
15
19
  },
16
- "keywords": ["changelog", "deploylog", "cli", "release-notes", "devtools"],
20
+ "keywords": [
21
+ "changelog",
22
+ "deploylog",
23
+ "cli",
24
+ "release-notes",
25
+ "devtools"
26
+ ],
17
27
  "license": "MIT",
18
28
  "repository": {
19
29
  "type": "git",
@@ -28,6 +38,7 @@
28
38
  },
29
39
  "devDependencies": {
30
40
  "@types/node": "^22.15.3",
31
- "typescript": "^5.8.3"
41
+ "typescript": "^5.8.3",
42
+ "vitest": "^4.1.4"
32
43
  }
33
44
  }