pi-release 1.0.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/LICENSE +21 -0
- package/README.md +179 -0
- package/biome.json +19 -0
- package/extensions/index.test.ts +398 -0
- package/extensions/index.ts +955 -0
- package/package.json +46 -0
- package/tsconfig.json +21 -0
- package/vitest.config.ts +10 -0
|
@@ -0,0 +1,955 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-release — Release Automation Extension
|
|
3
|
+
*
|
|
4
|
+
* Automates the full release flow: bump version → changelog → commit →
|
|
5
|
+
* tag → GitHub release → npm publish.
|
|
6
|
+
*
|
|
7
|
+
* Tools:
|
|
8
|
+
* 1. bump_version — Bump package.json version, create git tag
|
|
9
|
+
* 2. create_github_release — Create GitHub release from tag
|
|
10
|
+
* 3. publish_npm — Publish package to npm registry
|
|
11
|
+
*
|
|
12
|
+
* Commands:
|
|
13
|
+
* /release — Full orchestrated release flow
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execSync } from "node:child_process";
|
|
17
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
18
|
+
import { join } from "node:path";
|
|
19
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { Type } from "typebox";
|
|
21
|
+
|
|
22
|
+
// ── Types ───────────────────────────────────────────────────────────
|
|
23
|
+
|
|
24
|
+
type BumpType = "major" | "minor" | "patch";
|
|
25
|
+
|
|
26
|
+
interface ReleaseConfig {
|
|
27
|
+
/** npm registry URL (default: https://registry.npmjs.org/) */
|
|
28
|
+
registry: string;
|
|
29
|
+
/** GitHub owner/repo (auto-detected from git remote) */
|
|
30
|
+
githubRepo: string | null;
|
|
31
|
+
/** Default bump type (default: patch) */
|
|
32
|
+
defaultBump: BumpType;
|
|
33
|
+
/** Pre-release tag (e.g. "beta", "alpha") — empty string for stable */
|
|
34
|
+
preReleaseTag: string;
|
|
35
|
+
/** Whether to generate changelog before release */
|
|
36
|
+
generateChangelog: boolean;
|
|
37
|
+
/** Whether to create git commit for version bump */
|
|
38
|
+
commitBump: boolean;
|
|
39
|
+
/** Custom commit message template — {version} is replaced */
|
|
40
|
+
commitMessage: string;
|
|
41
|
+
/** Whether to create git tag */
|
|
42
|
+
createTag: boolean;
|
|
43
|
+
/** Custom tag prefix (default: "v") */
|
|
44
|
+
tagPrefix: string;
|
|
45
|
+
/** Whether to create GitHub release */
|
|
46
|
+
createGithubRelease: boolean;
|
|
47
|
+
/** Whether to publish to npm */
|
|
48
|
+
publishToNpm: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface BumpResult {
|
|
52
|
+
previousVersion: string;
|
|
53
|
+
newVersion: string;
|
|
54
|
+
tag: string;
|
|
55
|
+
commitHash: string | null;
|
|
56
|
+
dryRun: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
interface GithubReleaseResult {
|
|
60
|
+
tag: string;
|
|
61
|
+
url: string;
|
|
62
|
+
dryRun: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
interface PublishResult {
|
|
66
|
+
package: string;
|
|
67
|
+
version: string;
|
|
68
|
+
registry: string;
|
|
69
|
+
dryRun: boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ── Default Config ──────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
const DEFAULT_CONFIG: ReleaseConfig = {
|
|
75
|
+
registry: "https://registry.npmjs.org/",
|
|
76
|
+
githubRepo: null,
|
|
77
|
+
defaultBump: "patch",
|
|
78
|
+
preReleaseTag: "",
|
|
79
|
+
generateChangelog: true,
|
|
80
|
+
commitBump: true,
|
|
81
|
+
commitMessage: "chore(release): {version}",
|
|
82
|
+
createTag: true,
|
|
83
|
+
tagPrefix: "v",
|
|
84
|
+
createGithubRelease: true,
|
|
85
|
+
publishToNpm: true,
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
// ── Config Loading ──────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function loadConfig(cwd?: string): ReleaseConfig {
|
|
91
|
+
const base = cwd || process.cwd();
|
|
92
|
+
const configPaths = ["pi-release.json", ".pi-release.json"];
|
|
93
|
+
|
|
94
|
+
for (const configPath of configPaths) {
|
|
95
|
+
const fullPath = join(base, configPath);
|
|
96
|
+
if (existsSync(fullPath)) {
|
|
97
|
+
try {
|
|
98
|
+
const raw = readFileSync(fullPath, "utf-8");
|
|
99
|
+
const parsed = JSON.parse(raw) as Partial<ReleaseConfig>;
|
|
100
|
+
return { ...DEFAULT_CONFIG, ...parsed };
|
|
101
|
+
} catch {
|
|
102
|
+
// Fall back to defaults if config is malformed
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return { ...DEFAULT_CONFIG };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Shell Helpers ───────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function exec(cmd: string, cwd?: string): string {
|
|
113
|
+
try {
|
|
114
|
+
return execSync(cmd, {
|
|
115
|
+
cwd: cwd || process.cwd(),
|
|
116
|
+
encoding: "utf-8",
|
|
117
|
+
timeout: 60_000,
|
|
118
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
119
|
+
}).trim();
|
|
120
|
+
} catch (err: unknown) {
|
|
121
|
+
const e = err as { stdout?: string; stderr?: string; message?: string };
|
|
122
|
+
if (e.stderr) throw new Error(e.stderr);
|
|
123
|
+
throw new Error(e.message || "Command failed");
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Package.json Helpers ────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
function readPackageJson(cwd?: string): { name: string; version: string } {
|
|
130
|
+
const base = cwd || process.cwd();
|
|
131
|
+
const pkgPath = join(base, "package.json");
|
|
132
|
+
if (!existsSync(pkgPath)) {
|
|
133
|
+
throw new Error("No package.json found in current directory");
|
|
134
|
+
}
|
|
135
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
136
|
+
const pkg = JSON.parse(raw) as { name?: string; version?: string };
|
|
137
|
+
if (!pkg.name) throw new Error("package.json missing 'name' field");
|
|
138
|
+
if (!pkg.version) throw new Error("package.json missing 'version' field");
|
|
139
|
+
return { name: pkg.name, version: pkg.version };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function writePackageVersion(version: string, cwd?: string): void {
|
|
143
|
+
const base = cwd || process.cwd();
|
|
144
|
+
const pkgPath = join(base, "package.json");
|
|
145
|
+
const raw = readFileSync(pkgPath, "utf-8");
|
|
146
|
+
const pkg = JSON.parse(raw) as Record<string, unknown>;
|
|
147
|
+
pkg.version = version;
|
|
148
|
+
writeFileSync(pkgPath, `${JSON.stringify(pkg, null, 2)}\n`, "utf-8");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── Version Bumping ─────────────────────────────────────────────────
|
|
152
|
+
|
|
153
|
+
export function bumpSemver(version: string, type: BumpType): string {
|
|
154
|
+
const parts = version.split(".").map(Number);
|
|
155
|
+
if (parts.length !== 3 || parts.some((p) => Number.isNaN(p))) {
|
|
156
|
+
throw new Error(`Invalid semver version: ${version}`);
|
|
157
|
+
}
|
|
158
|
+
const [major = 0, minor = 0, patch = 0] = parts;
|
|
159
|
+
|
|
160
|
+
switch (type) {
|
|
161
|
+
case "major":
|
|
162
|
+
return `${major + 1}.0.0`;
|
|
163
|
+
case "minor":
|
|
164
|
+
return `${major}.${minor + 1}.0`;
|
|
165
|
+
case "patch":
|
|
166
|
+
return `${major}.${minor}.${patch + 1}`;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Git Helpers ─────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
function getGitRemote(cwd?: string): string | null {
|
|
173
|
+
try {
|
|
174
|
+
return exec("git remote get-url origin", cwd);
|
|
175
|
+
} catch {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function parseGithubRepo(remoteUrl: string): string | null {
|
|
181
|
+
// SSH: git@github.com:owner/repo.git
|
|
182
|
+
const sshMatch = /^git@github\.com:(.+?)\.git$/.exec(remoteUrl);
|
|
183
|
+
if (sshMatch) return sshMatch[1];
|
|
184
|
+
|
|
185
|
+
// HTTPS: https://github.com/owner/repo.git
|
|
186
|
+
const httpsMatch = /^https:\/\/github\.com\/(.+?)\.git$/.exec(remoteUrl);
|
|
187
|
+
if (httpsMatch) return httpsMatch[1];
|
|
188
|
+
|
|
189
|
+
// HTTPS without .git
|
|
190
|
+
const httpsNoGit = /^https:\/\/github\.com\/(.+?)$/.exec(remoteUrl);
|
|
191
|
+
if (httpsNoGit) return httpsNoGit[1];
|
|
192
|
+
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function isGitClean(cwd?: string): boolean {
|
|
197
|
+
try {
|
|
198
|
+
const status = exec("git status --porcelain", cwd);
|
|
199
|
+
return status.length === 0;
|
|
200
|
+
} catch {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function getLatestTag(cwd?: string): string | null {
|
|
206
|
+
try {
|
|
207
|
+
return exec("git describe --tags --abbrev=0", cwd);
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function tagExists(tag: string, cwd?: string): boolean {
|
|
214
|
+
try {
|
|
215
|
+
exec(`git rev-parse ${tag}`, cwd);
|
|
216
|
+
return true;
|
|
217
|
+
} catch {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ── Changelog Generation ────────────────────────────────────────────
|
|
223
|
+
|
|
224
|
+
const CONVENTIONAL_PATTERN = /^(\w+)(?:\(([^)]*)\))?(!)?:\s+(.+)$/;
|
|
225
|
+
const TYPE_LABELS: Record<string, string> = {
|
|
226
|
+
feat: "Features",
|
|
227
|
+
fix: "Bug Fixes",
|
|
228
|
+
docs: "Documentation",
|
|
229
|
+
style: "Styles",
|
|
230
|
+
refactor: "Code Refactoring",
|
|
231
|
+
perf: "Performance Improvements",
|
|
232
|
+
test: "Tests",
|
|
233
|
+
ci: "CI/CD",
|
|
234
|
+
build: "Build System",
|
|
235
|
+
chore: "Miscellaneous",
|
|
236
|
+
revert: "Reverts",
|
|
237
|
+
};
|
|
238
|
+
const TYPE_ORDER = [
|
|
239
|
+
"feat",
|
|
240
|
+
"fix",
|
|
241
|
+
"perf",
|
|
242
|
+
"refactor",
|
|
243
|
+
"docs",
|
|
244
|
+
"style",
|
|
245
|
+
"test",
|
|
246
|
+
"ci",
|
|
247
|
+
"build",
|
|
248
|
+
"chore",
|
|
249
|
+
"revert",
|
|
250
|
+
];
|
|
251
|
+
|
|
252
|
+
interface SimpleCommit {
|
|
253
|
+
hash: string;
|
|
254
|
+
type: string;
|
|
255
|
+
scope: string | null;
|
|
256
|
+
subject: string;
|
|
257
|
+
breaking: boolean;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function parseSimpleCommit(line: string): SimpleCommit | null {
|
|
261
|
+
const parts = line.split("|");
|
|
262
|
+
if (parts.length < 2) return null;
|
|
263
|
+
|
|
264
|
+
const hash = parts[0]?.trim() || "";
|
|
265
|
+
const subject = parts.slice(1).join("|").trim();
|
|
266
|
+
|
|
267
|
+
const match = CONVENTIONAL_PATTERN.exec(subject);
|
|
268
|
+
if (!match) {
|
|
269
|
+
return { hash, type: "other", scope: null, subject, breaking: false };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const [, type, scope, bang, desc] = match;
|
|
273
|
+
return {
|
|
274
|
+
hash,
|
|
275
|
+
type: (type || "other").toLowerCase(),
|
|
276
|
+
scope: scope || null,
|
|
277
|
+
subject: desc?.trim() || subject,
|
|
278
|
+
breaking: bang === "!",
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
export function generateBasicChangelog(version: string, cwd?: string): string {
|
|
283
|
+
const base = cwd || process.cwd();
|
|
284
|
+
const latestTag = getLatestTag(base);
|
|
285
|
+
const range = latestTag ? `${latestTag}..HEAD` : "";
|
|
286
|
+
const cmd = `git log ${range} --format="%H|%s" --date=short`;
|
|
287
|
+
|
|
288
|
+
let logOutput: string;
|
|
289
|
+
try {
|
|
290
|
+
logOutput = exec(cmd, base);
|
|
291
|
+
} catch {
|
|
292
|
+
return `# Changelog\n\n## [${version}]\n\nNo commits found.\n`;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
if (!logOutput) {
|
|
296
|
+
return `# Changelog\n\n## [${version}]\n\nNo commits found.\n`;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const lines = logOutput.split("\n");
|
|
300
|
+
const commits: SimpleCommit[] = [];
|
|
301
|
+
for (const line of lines) {
|
|
302
|
+
const commit = parseSimpleCommit(line);
|
|
303
|
+
if (commit) commits.push(commit);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Group by type
|
|
307
|
+
const grouped = new Map<string, SimpleCommit[]>();
|
|
308
|
+
for (const commit of commits) {
|
|
309
|
+
const existing = grouped.get(commit.type) || [];
|
|
310
|
+
existing.push(commit);
|
|
311
|
+
grouped.set(commit.type, existing);
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const date = new Date().toISOString().split("T")[0];
|
|
315
|
+
const mdLines: string[] = [
|
|
316
|
+
"# Changelog",
|
|
317
|
+
"",
|
|
318
|
+
`## [${version}] - ${date}`,
|
|
319
|
+
"",
|
|
320
|
+
];
|
|
321
|
+
|
|
322
|
+
// Breaking changes first
|
|
323
|
+
const breaking = commits.filter((c) => c.breaking);
|
|
324
|
+
if (breaking.length > 0) {
|
|
325
|
+
mdLines.push("### ⚠ BREAKING CHANGES", "");
|
|
326
|
+
for (const c of breaking) {
|
|
327
|
+
const scope = c.scope ? `**${c.scope}:** ` : "";
|
|
328
|
+
mdLines.push(`- ${scope}${c.subject} (${c.hash.slice(0, 7)})`);
|
|
329
|
+
}
|
|
330
|
+
mdLines.push("");
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Grouped entries
|
|
334
|
+
for (const type of TYPE_ORDER) {
|
|
335
|
+
const typeCommits = grouped.get(type);
|
|
336
|
+
if (typeCommits && typeCommits.length > 0) {
|
|
337
|
+
mdLines.push(`### ${TYPE_LABELS[type] || type}`, "");
|
|
338
|
+
for (const c of typeCommits) {
|
|
339
|
+
const scope = c.scope ? `**${c.scope}:** ` : "";
|
|
340
|
+
mdLines.push(`- ${scope}${c.subject} (${c.hash.slice(0, 7)})`);
|
|
341
|
+
}
|
|
342
|
+
mdLines.push("");
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Other types not in TYPE_ORDER
|
|
347
|
+
for (const [type, typeCommits] of grouped) {
|
|
348
|
+
if (!TYPE_ORDER.includes(type) && typeCommits.length > 0) {
|
|
349
|
+
mdLines.push(`### ${TYPE_LABELS[type] || type}`, "");
|
|
350
|
+
for (const c of typeCommits) {
|
|
351
|
+
mdLines.push(`- ${c.subject} (${c.hash.slice(0, 7)})`);
|
|
352
|
+
}
|
|
353
|
+
mdLines.push("");
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
return mdLines.join("\n");
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
// ── Tool Implementations ────────────────────────────────────────────
|
|
361
|
+
|
|
362
|
+
export function bumpVersionImpl(
|
|
363
|
+
bumpType: BumpType,
|
|
364
|
+
options: {
|
|
365
|
+
cwd?: string;
|
|
366
|
+
dryRun?: boolean;
|
|
367
|
+
preReleaseTag?: string;
|
|
368
|
+
commitMessage?: string;
|
|
369
|
+
tagPrefix?: string;
|
|
370
|
+
} = {},
|
|
371
|
+
): BumpResult {
|
|
372
|
+
const cwd = options.cwd;
|
|
373
|
+
const config = loadConfig(cwd);
|
|
374
|
+
const dryRun = options.dryRun ?? false;
|
|
375
|
+
const tagPrefix = options.tagPrefix ?? config.tagPrefix;
|
|
376
|
+
const preRelease = options.preReleaseTag ?? config.preReleaseTag;
|
|
377
|
+
|
|
378
|
+
const pkg = readPackageJson(cwd);
|
|
379
|
+
const previousVersion = pkg.version;
|
|
380
|
+
let newVersion = bumpSemver(previousVersion, bumpType);
|
|
381
|
+
|
|
382
|
+
if (preRelease) {
|
|
383
|
+
newVersion = `${newVersion}-${preRelease}`;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const tag = `${tagPrefix}${newVersion}`;
|
|
387
|
+
const commitMsg = (options.commitMessage || config.commitMessage).replace(
|
|
388
|
+
"{version}",
|
|
389
|
+
newVersion,
|
|
390
|
+
);
|
|
391
|
+
|
|
392
|
+
if (dryRun) {
|
|
393
|
+
return {
|
|
394
|
+
previousVersion,
|
|
395
|
+
newVersion,
|
|
396
|
+
tag,
|
|
397
|
+
commitHash: null,
|
|
398
|
+
dryRun: true,
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Check git state
|
|
403
|
+
if (!isGitClean(cwd)) {
|
|
404
|
+
throw new Error(
|
|
405
|
+
"Working directory has uncommitted changes. Commit or stash them first.",
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (tagExists(tag, cwd)) {
|
|
410
|
+
throw new Error(`Tag ${tag} already exists`);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Write new version to package.json
|
|
414
|
+
writePackageVersion(newVersion, cwd);
|
|
415
|
+
|
|
416
|
+
// Commit the version bump
|
|
417
|
+
let commitHash: string | null = null;
|
|
418
|
+
if (config.commitBump) {
|
|
419
|
+
exec("git add package.json", cwd);
|
|
420
|
+
exec(`git commit -m "${commitMsg}"`, cwd);
|
|
421
|
+
commitHash = exec("git rev-parse HEAD", cwd);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// Create tag
|
|
425
|
+
if (config.createTag) {
|
|
426
|
+
exec(`git tag ${tag}`, cwd);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
return {
|
|
430
|
+
previousVersion,
|
|
431
|
+
newVersion,
|
|
432
|
+
tag,
|
|
433
|
+
commitHash,
|
|
434
|
+
dryRun: false,
|
|
435
|
+
};
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
export function createGithubReleaseImpl(
|
|
439
|
+
tag: string,
|
|
440
|
+
options: {
|
|
441
|
+
cwd?: string;
|
|
442
|
+
dryRun?: boolean;
|
|
443
|
+
title?: string;
|
|
444
|
+
notes?: string;
|
|
445
|
+
prerelease?: boolean;
|
|
446
|
+
} = {},
|
|
447
|
+
): GithubReleaseResult {
|
|
448
|
+
const cwd = options.cwd;
|
|
449
|
+
const config = loadConfig(cwd);
|
|
450
|
+
const dryRun = options.dryRun ?? false;
|
|
451
|
+
|
|
452
|
+
// Detect GitHub repo
|
|
453
|
+
const remote = getGitRemote(cwd);
|
|
454
|
+
const repo = remote ? parseGithubRepo(remote) : config.githubRepo;
|
|
455
|
+
if (!repo) {
|
|
456
|
+
throw new Error(
|
|
457
|
+
"Cannot determine GitHub repo. Set githubRepo in pi-release.json or ensure git remote 'origin' points to GitHub.",
|
|
458
|
+
);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
const title = options.title || `Release ${tag}`;
|
|
462
|
+
const url = `https://github.com/${repo}/releases/tag/${tag}`;
|
|
463
|
+
|
|
464
|
+
if (dryRun) {
|
|
465
|
+
return { tag, url, dryRun: true };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// Check gh CLI is available
|
|
469
|
+
try {
|
|
470
|
+
exec("gh --version", cwd);
|
|
471
|
+
} catch {
|
|
472
|
+
throw new Error(
|
|
473
|
+
"GitHub CLI (gh) is not installed. Install it from https://cli.github.com/",
|
|
474
|
+
);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
// Check tag exists
|
|
478
|
+
if (!tagExists(tag, cwd)) {
|
|
479
|
+
throw new Error(`Tag ${tag} does not exist. Run bump_version first.`);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// Build gh release create command
|
|
483
|
+
const args = ["gh release create", tag];
|
|
484
|
+
args.push("--repo", repo);
|
|
485
|
+
args.push("--title", `"${title}"`);
|
|
486
|
+
|
|
487
|
+
if (options.notes) {
|
|
488
|
+
args.push("--notes", `"${options.notes}"`);
|
|
489
|
+
} else {
|
|
490
|
+
args.push("--generate-notes");
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
if (options.prerelease) {
|
|
494
|
+
args.push("--prerelease");
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
exec(args.join(" "), cwd);
|
|
498
|
+
|
|
499
|
+
return { tag, url, dryRun: false };
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export function publishNpmImpl(
|
|
503
|
+
options: {
|
|
504
|
+
cwd?: string;
|
|
505
|
+
dryRun?: boolean;
|
|
506
|
+
otp?: string;
|
|
507
|
+
registry?: string;
|
|
508
|
+
tag?: string;
|
|
509
|
+
} = {},
|
|
510
|
+
): PublishResult {
|
|
511
|
+
const cwd = options.cwd;
|
|
512
|
+
const config = loadConfig(cwd);
|
|
513
|
+
const dryRun = options.dryRun ?? false;
|
|
514
|
+
const registry = options.registry || config.registry;
|
|
515
|
+
|
|
516
|
+
const pkg = readPackageJson(cwd);
|
|
517
|
+
|
|
518
|
+
if (dryRun) {
|
|
519
|
+
return {
|
|
520
|
+
package: pkg.name,
|
|
521
|
+
version: pkg.version,
|
|
522
|
+
registry,
|
|
523
|
+
dryRun: true,
|
|
524
|
+
};
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
// Build npm publish command
|
|
528
|
+
const args = ["npm publish"];
|
|
529
|
+
if (registry !== "https://registry.npmjs.org/") {
|
|
530
|
+
args.push("--registry", registry);
|
|
531
|
+
}
|
|
532
|
+
if (options.otp) {
|
|
533
|
+
args.push("--otp", options.otp);
|
|
534
|
+
}
|
|
535
|
+
if (options.tag) {
|
|
536
|
+
args.push("--tag", options.tag);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
exec(args.join(" "), cwd);
|
|
540
|
+
|
|
541
|
+
return {
|
|
542
|
+
package: pkg.name,
|
|
543
|
+
version: pkg.version,
|
|
544
|
+
registry,
|
|
545
|
+
dryRun: false,
|
|
546
|
+
};
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// ── Formatting ──────────────────────────────────────────────────────
|
|
550
|
+
|
|
551
|
+
function formatBumpResult(result: BumpResult): string {
|
|
552
|
+
const lines: string[] = [];
|
|
553
|
+
const prefix = result.dryRun ? "[DRY RUN] " : "";
|
|
554
|
+
|
|
555
|
+
lines.push(`${prefix}📦 Version Bump`);
|
|
556
|
+
lines.push(` ${result.previousVersion} → ${result.newVersion}`);
|
|
557
|
+
lines.push(` Tag: ${result.tag}`);
|
|
558
|
+
if (result.commitHash) {
|
|
559
|
+
lines.push(` Commit: ${result.commitHash.slice(0, 8)}`);
|
|
560
|
+
}
|
|
561
|
+
if (result.dryRun) {
|
|
562
|
+
lines.push(" ⚠️ No changes made (dry run)");
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
return lines.join("\n");
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function formatGithubReleaseResult(result: GithubReleaseResult): string {
|
|
569
|
+
const lines: string[] = [];
|
|
570
|
+
const prefix = result.dryRun ? "[DRY RUN] " : "";
|
|
571
|
+
|
|
572
|
+
lines.push(`${prefix}🚀 GitHub Release`);
|
|
573
|
+
lines.push(` Tag: ${result.tag}`);
|
|
574
|
+
lines.push(` URL: ${result.url}`);
|
|
575
|
+
if (result.dryRun) {
|
|
576
|
+
lines.push(" ⚠️ No release created (dry run)");
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
return lines.join("\n");
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function formatPublishResult(result: PublishResult): string {
|
|
583
|
+
const lines: string[] = [];
|
|
584
|
+
const prefix = result.dryRun ? "[DRY RUN] " : "";
|
|
585
|
+
|
|
586
|
+
lines.push(`${prefix}📤 npm Publish`);
|
|
587
|
+
lines.push(` Package: ${result.package}@${result.version}`);
|
|
588
|
+
lines.push(` Registry: ${result.registry}`);
|
|
589
|
+
if (result.dryRun) {
|
|
590
|
+
lines.push(" ⚠️ Not published (dry run)");
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
return lines.join("\n");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function formatReleaseReport(
|
|
597
|
+
bump: BumpResult,
|
|
598
|
+
changelog: string,
|
|
599
|
+
ghRelease: GithubReleaseResult | null,
|
|
600
|
+
publish: PublishResult | null,
|
|
601
|
+
): string {
|
|
602
|
+
const bar = "═".repeat(60);
|
|
603
|
+
const lines: string[] = [];
|
|
604
|
+
|
|
605
|
+
lines.push(`╔${bar}╗`);
|
|
606
|
+
lines.push("║ 🎉 RELEASE REPORT");
|
|
607
|
+
lines.push(`║ ${bump.newVersion}`);
|
|
608
|
+
if (bump.dryRun) lines.push("║ [DRY RUN — no changes made]");
|
|
609
|
+
lines.push(`╚${bar}╝`);
|
|
610
|
+
lines.push("");
|
|
611
|
+
lines.push(formatBumpResult(bump));
|
|
612
|
+
lines.push("");
|
|
613
|
+
|
|
614
|
+
if (changelog) {
|
|
615
|
+
lines.push("📝 Changelog:");
|
|
616
|
+
lines.push(changelog);
|
|
617
|
+
lines.push("");
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
if (ghRelease) {
|
|
621
|
+
lines.push(formatGithubReleaseResult(ghRelease));
|
|
622
|
+
lines.push("");
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
if (publish) {
|
|
626
|
+
lines.push(formatPublishResult(publish));
|
|
627
|
+
lines.push("");
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
if (bump.dryRun) {
|
|
631
|
+
lines.push("─".repeat(40));
|
|
632
|
+
lines.push("This was a dry run. Run without --dry-run to execute.");
|
|
633
|
+
} else {
|
|
634
|
+
lines.push("─".repeat(40));
|
|
635
|
+
lines.push("✅ Release complete!");
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
return lines.join("\n");
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// ── Extension Registration ──────────────────────────────────────────
|
|
642
|
+
|
|
643
|
+
export default function (pi: ExtensionAPI) {
|
|
644
|
+
// ── Tool: bump_version ──────────────────────────────────────────
|
|
645
|
+
pi.registerTool({
|
|
646
|
+
name: "bump_version",
|
|
647
|
+
label: "Bump Version",
|
|
648
|
+
description:
|
|
649
|
+
"Bump the version in package.json (major/minor/patch), create a git commit and tag. Supports pre-release tags (beta, alpha, rc) and dry-run mode. Requires a clean git working directory.",
|
|
650
|
+
parameters: Type.Object({
|
|
651
|
+
bump: Type.Optional(
|
|
652
|
+
Type.Union(
|
|
653
|
+
[Type.Literal("major"), Type.Literal("minor"), Type.Literal("patch")],
|
|
654
|
+
{
|
|
655
|
+
description:
|
|
656
|
+
"Version bump type: major (1.0.0→2.0.0), minor (1.0.0→1.1.0), patch (1.0.0→1.0.1). Default: patch.",
|
|
657
|
+
default: "patch",
|
|
658
|
+
},
|
|
659
|
+
),
|
|
660
|
+
),
|
|
661
|
+
pre_release: Type.Optional(
|
|
662
|
+
Type.String({
|
|
663
|
+
description:
|
|
664
|
+
"Pre-release tag to append (e.g. 'beta', 'alpha', 'rc'). Result: 1.0.0-beta.",
|
|
665
|
+
}),
|
|
666
|
+
),
|
|
667
|
+
dry_run: Type.Optional(
|
|
668
|
+
Type.Boolean({
|
|
669
|
+
description:
|
|
670
|
+
"Preview the version bump without making changes. Default: false.",
|
|
671
|
+
default: false,
|
|
672
|
+
}),
|
|
673
|
+
),
|
|
674
|
+
message: Type.Optional(
|
|
675
|
+
Type.String({
|
|
676
|
+
description:
|
|
677
|
+
"Custom commit message. Use {version} as placeholder. Default: 'chore(release): {version}'.",
|
|
678
|
+
}),
|
|
679
|
+
),
|
|
680
|
+
}),
|
|
681
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
682
|
+
try {
|
|
683
|
+
const bumpType = (params.bump || "patch") as BumpType;
|
|
684
|
+
const result = bumpVersionImpl(bumpType, {
|
|
685
|
+
cwd: ctx.cwd,
|
|
686
|
+
dryRun: params.dry_run,
|
|
687
|
+
preReleaseTag: params.pre_release,
|
|
688
|
+
commitMessage: params.message,
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
return {
|
|
692
|
+
content: [{ type: "text", text: formatBumpResult(result) }],
|
|
693
|
+
details: result,
|
|
694
|
+
};
|
|
695
|
+
} catch (err: unknown) {
|
|
696
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
697
|
+
return {
|
|
698
|
+
content: [{ type: "text", text: `❌ Version bump failed: ${msg}` }],
|
|
699
|
+
details: { error: msg },
|
|
700
|
+
};
|
|
701
|
+
}
|
|
702
|
+
},
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
// ── Tool: create_github_release ─────────────────────────────────
|
|
706
|
+
pi.registerTool({
|
|
707
|
+
name: "create_github_release",
|
|
708
|
+
label: "Create GitHub Release",
|
|
709
|
+
description:
|
|
710
|
+
"Create a GitHub release from a git tag using 'gh release create'. Auto-generates release notes unless custom notes are provided. Requires GitHub CLI (gh) to be installed and authenticated.",
|
|
711
|
+
parameters: Type.Object({
|
|
712
|
+
tag: Type.String({
|
|
713
|
+
description:
|
|
714
|
+
"Git tag to create the release from (e.g. 'v1.0.0'). Tag must already exist.",
|
|
715
|
+
}),
|
|
716
|
+
title: Type.Optional(
|
|
717
|
+
Type.String({
|
|
718
|
+
description: "Release title. Defaults to 'Release {tag}'.",
|
|
719
|
+
}),
|
|
720
|
+
),
|
|
721
|
+
notes: Type.Optional(
|
|
722
|
+
Type.String({
|
|
723
|
+
description:
|
|
724
|
+
"Custom release notes. If omitted, GitHub auto-generates notes from commits since last release.",
|
|
725
|
+
}),
|
|
726
|
+
),
|
|
727
|
+
prerelease: Type.Optional(
|
|
728
|
+
Type.Boolean({
|
|
729
|
+
description: "Mark as a pre-release. Default: false.",
|
|
730
|
+
default: false,
|
|
731
|
+
}),
|
|
732
|
+
),
|
|
733
|
+
dry_run: Type.Optional(
|
|
734
|
+
Type.Boolean({
|
|
735
|
+
description:
|
|
736
|
+
"Preview the release without creating it. Default: false.",
|
|
737
|
+
default: false,
|
|
738
|
+
}),
|
|
739
|
+
),
|
|
740
|
+
}),
|
|
741
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
742
|
+
try {
|
|
743
|
+
const result = createGithubReleaseImpl(params.tag, {
|
|
744
|
+
cwd: ctx.cwd,
|
|
745
|
+
dryRun: params.dry_run,
|
|
746
|
+
title: params.title,
|
|
747
|
+
notes: params.notes,
|
|
748
|
+
prerelease: params.prerelease,
|
|
749
|
+
});
|
|
750
|
+
|
|
751
|
+
return {
|
|
752
|
+
content: [{ type: "text", text: formatGithubReleaseResult(result) }],
|
|
753
|
+
details: result,
|
|
754
|
+
};
|
|
755
|
+
} catch (err: unknown) {
|
|
756
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
757
|
+
return {
|
|
758
|
+
content: [
|
|
759
|
+
{
|
|
760
|
+
type: "text",
|
|
761
|
+
text: `❌ GitHub release creation failed: ${msg}`,
|
|
762
|
+
},
|
|
763
|
+
],
|
|
764
|
+
details: { error: msg },
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
});
|
|
769
|
+
|
|
770
|
+
// ── Tool: publish_npm ──────────────────────────────────────────
|
|
771
|
+
pi.registerTool({
|
|
772
|
+
name: "publish_npm",
|
|
773
|
+
label: "Publish to npm",
|
|
774
|
+
description:
|
|
775
|
+
"Publish the current package to npm. Supports custom registries, OTP for 2FA, dist-tags, and dry-run mode.",
|
|
776
|
+
parameters: Type.Object({
|
|
777
|
+
otp: Type.Optional(
|
|
778
|
+
Type.String({
|
|
779
|
+
description: "One-time password for npm 2FA authentication.",
|
|
780
|
+
}),
|
|
781
|
+
),
|
|
782
|
+
registry: Type.Optional(
|
|
783
|
+
Type.String({
|
|
784
|
+
description:
|
|
785
|
+
"npm registry URL. Defaults to https://registry.npmjs.org/ or value in pi-release.json.",
|
|
786
|
+
}),
|
|
787
|
+
),
|
|
788
|
+
tag: Type.Optional(
|
|
789
|
+
Type.String({
|
|
790
|
+
description:
|
|
791
|
+
"npm dist-tag (e.g. 'beta', 'next'). Defaults to 'latest'.",
|
|
792
|
+
}),
|
|
793
|
+
),
|
|
794
|
+
dry_run: Type.Optional(
|
|
795
|
+
Type.Boolean({
|
|
796
|
+
description: "Preview the publish without executing. Default: false.",
|
|
797
|
+
default: false,
|
|
798
|
+
}),
|
|
799
|
+
),
|
|
800
|
+
}),
|
|
801
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
802
|
+
try {
|
|
803
|
+
const result = publishNpmImpl({
|
|
804
|
+
cwd: ctx.cwd,
|
|
805
|
+
dryRun: params.dry_run,
|
|
806
|
+
otp: params.otp,
|
|
807
|
+
registry: params.registry,
|
|
808
|
+
tag: params.tag,
|
|
809
|
+
});
|
|
810
|
+
|
|
811
|
+
return {
|
|
812
|
+
content: [{ type: "text", text: formatPublishResult(result) }],
|
|
813
|
+
details: result,
|
|
814
|
+
};
|
|
815
|
+
} catch (err: unknown) {
|
|
816
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
817
|
+
return {
|
|
818
|
+
content: [{ type: "text", text: `❌ npm publish failed: ${msg}` }],
|
|
819
|
+
details: { error: msg },
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
},
|
|
823
|
+
});
|
|
824
|
+
|
|
825
|
+
// ── Command: /release ──────────────────────────────────────────
|
|
826
|
+
pi.registerCommand("release", {
|
|
827
|
+
description:
|
|
828
|
+
"Full release flow: bump version → generate changelog → commit → tag → GitHub release → npm publish. Usage: /release [major|minor|patch] [--dry-run]",
|
|
829
|
+
handler: async (args, ctx) => {
|
|
830
|
+
const config = loadConfig(ctx.cwd);
|
|
831
|
+
const argStr = args?.trim() || "";
|
|
832
|
+
|
|
833
|
+
// Parse arguments
|
|
834
|
+
const isDryRun = argStr.includes("--dry-run");
|
|
835
|
+
const cleanArgs = argStr.replace("--dry-run", "").trim();
|
|
836
|
+
const bumpType = (cleanArgs || config.defaultBump) as BumpType;
|
|
837
|
+
|
|
838
|
+
if (!["major", "minor", "patch"].includes(bumpType)) {
|
|
839
|
+
ctx.ui.notify(
|
|
840
|
+
`❌ Invalid bump type '${bumpType}'. Use: major, minor, or patch.`,
|
|
841
|
+
"error",
|
|
842
|
+
);
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const dryLabel = isDryRun ? " [DRY RUN]" : "";
|
|
847
|
+
ctx.ui.notify(`🚀 Starting ${bumpType} release${dryLabel}...`, "info");
|
|
848
|
+
|
|
849
|
+
try {
|
|
850
|
+
// Step 1: Bump version
|
|
851
|
+
ctx.ui.notify("📦 Step 1/4: Bumping version...", "info");
|
|
852
|
+
const bump = bumpVersionImpl(bumpType, {
|
|
853
|
+
cwd: ctx.cwd,
|
|
854
|
+
dryRun: isDryRun,
|
|
855
|
+
preReleaseTag: config.preReleaseTag,
|
|
856
|
+
});
|
|
857
|
+
ctx.ui.notify(formatBumpResult(bump), "info");
|
|
858
|
+
|
|
859
|
+
// Step 2: Generate changelog
|
|
860
|
+
let changelog = "";
|
|
861
|
+
if (config.generateChangelog) {
|
|
862
|
+
ctx.ui.notify("📝 Step 2/4: Generating changelog...", "info");
|
|
863
|
+
changelog = generateBasicChangelog(bump.newVersion, ctx.cwd);
|
|
864
|
+
|
|
865
|
+
if (!isDryRun) {
|
|
866
|
+
// Append to CHANGELOG.md
|
|
867
|
+
const changelogPath = join(
|
|
868
|
+
ctx.cwd || process.cwd(),
|
|
869
|
+
"CHANGELOG.md",
|
|
870
|
+
);
|
|
871
|
+
let existing = "";
|
|
872
|
+
if (existsSync(changelogPath)) {
|
|
873
|
+
existing = readFileSync(changelogPath, "utf-8");
|
|
874
|
+
}
|
|
875
|
+
const newContent = changelog.replace("# Changelog\n", "");
|
|
876
|
+
const fullChangelog = existing
|
|
877
|
+
? `# Changelog\n\n${newContent}\n${existing.replace("# Changelog\n", "").trim()}\n`
|
|
878
|
+
: changelog;
|
|
879
|
+
writeFileSync(changelogPath, fullChangelog, "utf-8");
|
|
880
|
+
|
|
881
|
+
// Commit changelog
|
|
882
|
+
exec("git add CHANGELOG.md", ctx.cwd);
|
|
883
|
+
exec(
|
|
884
|
+
`git commit -m "docs(release): changelog for ${bump.newVersion}"`,
|
|
885
|
+
ctx.cwd,
|
|
886
|
+
);
|
|
887
|
+
}
|
|
888
|
+
ctx.ui.notify(" ✅ Changelog generated", "info");
|
|
889
|
+
} else {
|
|
890
|
+
ctx.ui.notify(
|
|
891
|
+
"📝 Step 2/4: Changelog generation skipped (disabled in config)",
|
|
892
|
+
"info",
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Step 3: GitHub release
|
|
897
|
+
let ghRelease: GithubReleaseResult | null = null;
|
|
898
|
+
if (config.createGithubRelease) {
|
|
899
|
+
ctx.ui.notify("🚀 Step 3/4: Creating GitHub release...", "info");
|
|
900
|
+
try {
|
|
901
|
+
ghRelease = createGithubReleaseImpl(bump.tag, {
|
|
902
|
+
cwd: ctx.cwd,
|
|
903
|
+
dryRun: isDryRun,
|
|
904
|
+
});
|
|
905
|
+
ctx.ui.notify(formatGithubReleaseResult(ghRelease), "info");
|
|
906
|
+
} catch (err) {
|
|
907
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
908
|
+
ctx.ui.notify(`⚠️ GitHub release skipped: ${msg}`, "warning");
|
|
909
|
+
}
|
|
910
|
+
} else {
|
|
911
|
+
ctx.ui.notify(
|
|
912
|
+
"🚀 Step 3/4: GitHub release skipped (disabled in config)",
|
|
913
|
+
"info",
|
|
914
|
+
);
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
// Step 4: npm publish
|
|
918
|
+
let publish: PublishResult | null = null;
|
|
919
|
+
if (config.publishToNpm) {
|
|
920
|
+
ctx.ui.notify("📤 Step 4/4: Publishing to npm...", "info");
|
|
921
|
+
try {
|
|
922
|
+
publish = publishNpmImpl({
|
|
923
|
+
cwd: ctx.cwd,
|
|
924
|
+
dryRun: isDryRun,
|
|
925
|
+
});
|
|
926
|
+
ctx.ui.notify(formatPublishResult(publish), "info");
|
|
927
|
+
} catch (err) {
|
|
928
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
929
|
+
ctx.ui.notify(`⚠️ npm publish skipped: ${msg}`, "warning");
|
|
930
|
+
}
|
|
931
|
+
} else {
|
|
932
|
+
ctx.ui.notify(
|
|
933
|
+
"📤 Step 4/4: npm publish skipped (disabled in config)",
|
|
934
|
+
"info",
|
|
935
|
+
);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Final report
|
|
939
|
+
const report = formatReleaseReport(bump, changelog, ghRelease, publish);
|
|
940
|
+
ctx.ui.notify(report, "info");
|
|
941
|
+
} catch (err: unknown) {
|
|
942
|
+
const msg = err instanceof Error ? err.message : "Unknown error";
|
|
943
|
+
ctx.ui.notify(`❌ Release failed: ${msg}`, "error");
|
|
944
|
+
}
|
|
945
|
+
},
|
|
946
|
+
});
|
|
947
|
+
|
|
948
|
+
// ── Event: session_start ────────────────────────────────────────
|
|
949
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
950
|
+
ctx.ui.notify(
|
|
951
|
+
"🚀 pi-release loaded — run /release to start a release",
|
|
952
|
+
"info",
|
|
953
|
+
);
|
|
954
|
+
});
|
|
955
|
+
}
|