@sparkleideas/deployment 3.0.0-alpha.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,380 @@
1
+ /**
2
+ * Release Manager
3
+ * Handles version bumping, changelog generation, and git tagging
4
+ */
5
+
6
+ import { execSync, execFileSync } from 'child_process';
7
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
8
+ import { join } from 'path';
9
+
10
+ /**
11
+ * Allowed git commands for security - prevents command injection
12
+ */
13
+ const ALLOWED_GIT_COMMANDS = [
14
+ 'git status --porcelain',
15
+ 'git rev-parse HEAD',
16
+ 'git log',
17
+ 'git tag',
18
+ 'git add',
19
+ 'git commit',
20
+ 'git describe',
21
+ ];
22
+
23
+ /**
24
+ * Validate command against allowlist to prevent command injection
25
+ */
26
+ function validateCommand(cmd: string): void {
27
+ // Check for shell metacharacters
28
+ if (/[;&|`$()<>]/.test(cmd)) {
29
+ throw new Error(`Invalid command: contains shell metacharacters`);
30
+ }
31
+
32
+ // Must start with an allowed command prefix
33
+ const isAllowed = ALLOWED_GIT_COMMANDS.some(prefix => cmd.startsWith(prefix));
34
+ if (!isAllowed) {
35
+ throw new Error(`Command not allowed: ${cmd.split(' ')[0]}`);
36
+ }
37
+ }
38
+ import type {
39
+ ReleaseOptions,
40
+ ReleaseResult,
41
+ PackageInfo,
42
+ GitCommit,
43
+ ChangelogEntry,
44
+ VersionBumpType
45
+ } from './types.js';
46
+
47
+ export class ReleaseManager {
48
+ private cwd: string;
49
+
50
+ constructor(cwd: string = process.cwd()) {
51
+ this.cwd = cwd;
52
+ }
53
+
54
+ /**
55
+ * Prepare a release with version bumping, changelog, and git tagging
56
+ */
57
+ async prepareRelease(options: ReleaseOptions = {}): Promise<ReleaseResult> {
58
+ const {
59
+ bumpType = 'patch',
60
+ version,
61
+ channel = 'latest',
62
+ generateChangelog = true,
63
+ createTag = true,
64
+ commit = true,
65
+ dryRun = false,
66
+ skipValidation = false,
67
+ tagPrefix = 'v',
68
+ changelogPath = 'CHANGELOG.md'
69
+ } = options;
70
+
71
+ const result: ReleaseResult = {
72
+ oldVersion: '',
73
+ newVersion: '',
74
+ success: false,
75
+ warnings: []
76
+ };
77
+
78
+ try {
79
+ // Read package.json
80
+ const pkgPath = join(this.cwd, 'package.json');
81
+ if (!existsSync(pkgPath)) {
82
+ throw new Error('package.json not found');
83
+ }
84
+
85
+ const pkg: PackageInfo = JSON.parse(readFileSync(pkgPath, 'utf-8'));
86
+ result.oldVersion = pkg.version;
87
+
88
+ // Check for uncommitted changes
89
+ if (!skipValidation) {
90
+ const gitStatus = this.execCommand('git status --porcelain', true);
91
+ if (gitStatus && !dryRun) {
92
+ result.warnings?.push('Uncommitted changes detected');
93
+ }
94
+ }
95
+
96
+ // Determine new version
97
+ result.newVersion = version || this.bumpVersion(pkg.version, bumpType, channel);
98
+
99
+ // Generate changelog if requested
100
+ if (generateChangelog) {
101
+ const commits = this.getCommitsSinceLastTag();
102
+ const changelogEntry = this.generateChangelogEntry(result.newVersion, commits);
103
+ result.changelog = this.formatChangelogEntry(changelogEntry);
104
+
105
+ if (!dryRun) {
106
+ this.updateChangelogFile(changelogPath, result.changelog);
107
+ }
108
+ }
109
+
110
+ // Update package.json version
111
+ if (!dryRun) {
112
+ pkg.version = result.newVersion;
113
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n');
114
+ }
115
+
116
+ // Create git commit
117
+ if (commit && !dryRun) {
118
+ const commitMessage = `chore(release): ${result.newVersion}`;
119
+
120
+ // Stage changes
121
+ this.execCommand(`git add package.json ${changelogPath}`);
122
+
123
+ // Commit
124
+ this.execCommand(`git commit -m "${commitMessage}"`);
125
+
126
+ result.commitHash = this.execCommand('git rev-parse HEAD', true).trim();
127
+ }
128
+
129
+ // Create git tag
130
+ if (createTag && !dryRun) {
131
+ result.tag = `${tagPrefix}${result.newVersion}`;
132
+ const tagMessage = `Release ${result.newVersion}`;
133
+ this.execCommand(`git tag -a ${result.tag} -m "${tagMessage}"`);
134
+ }
135
+
136
+ result.success = true;
137
+ return result;
138
+
139
+ } catch (error) {
140
+ result.error = error instanceof Error ? error.message : String(error);
141
+ return result;
142
+ }
143
+ }
144
+
145
+ /**
146
+ * Bump version based on type
147
+ */
148
+ private bumpVersion(
149
+ currentVersion: string,
150
+ bumpType: VersionBumpType,
151
+ channel: string
152
+ ): string {
153
+ const versionMatch = currentVersion.match(/^(\d+)\.(\d+)\.(\d+)(?:-([a-z]+)\.(\d+))?$/);
154
+
155
+ if (!versionMatch) {
156
+ throw new Error(`Invalid version format: ${currentVersion}`);
157
+ }
158
+
159
+ let [, major, minor, patch, prerelease, prereleaseNum] = versionMatch;
160
+ let newMajor = parseInt(major);
161
+ let newMinor = parseInt(minor);
162
+ let newPatch = parseInt(patch);
163
+ let newPrerelease: string | undefined = prerelease;
164
+ let newPrereleaseNum = prereleaseNum ? parseInt(prereleaseNum) : 0;
165
+
166
+ switch (bumpType) {
167
+ case 'major':
168
+ newMajor++;
169
+ newMinor = 0;
170
+ newPatch = 0;
171
+ newPrerelease = undefined;
172
+ break;
173
+
174
+ case 'minor':
175
+ newMinor++;
176
+ newPatch = 0;
177
+ newPrerelease = undefined;
178
+ break;
179
+
180
+ case 'patch':
181
+ newPatch++;
182
+ newPrerelease = undefined;
183
+ break;
184
+
185
+ case 'prerelease':
186
+ if (newPrerelease && channel === newPrerelease) {
187
+ newPrereleaseNum++;
188
+ } else {
189
+ newPrereleaseNum = 1;
190
+ newPrerelease = channel;
191
+ }
192
+ break;
193
+ }
194
+
195
+ let version = `${newMajor}.${newMinor}.${newPatch}`;
196
+ if (newPrerelease && bumpType === 'prerelease') {
197
+ version += `-${newPrerelease}.${newPrereleaseNum}`;
198
+ }
199
+
200
+ return version;
201
+ }
202
+
203
+ /**
204
+ * Get git commits since last tag
205
+ */
206
+ private getCommitsSinceLastTag(): GitCommit[] {
207
+ try {
208
+ const lastTag = this.execCommand('git describe --tags --abbrev=0', true).trim();
209
+ const range = `${lastTag}..HEAD`;
210
+ return this.parseCommits(range);
211
+ } catch {
212
+ // No tags found, get all commits
213
+ return this.parseCommits('');
214
+ }
215
+ }
216
+
217
+ /**
218
+ * Parse git commits
219
+ */
220
+ private parseCommits(range: string): GitCommit[] {
221
+ const format = '--pretty=format:%H%n%s%n%an%n%ai%n---COMMIT---';
222
+ const cmd = range
223
+ ? `git log ${range} ${format}`
224
+ : `git log ${format}`;
225
+
226
+ const output = this.execCommand(cmd, true);
227
+ const commits: GitCommit[] = [];
228
+
229
+ const commitBlocks = output.split('---COMMIT---').filter(Boolean);
230
+
231
+ for (const block of commitBlocks) {
232
+ const lines = block.trim().split('\n');
233
+ if (lines.length < 4) continue;
234
+
235
+ const [hash, message, author, date] = lines;
236
+
237
+ // Parse conventional commit format
238
+ const conventionalMatch = message.match(/^(\w+)(?:\(([^)]+)\))?: (.+)$/);
239
+
240
+ commits.push({
241
+ hash: hash.trim(),
242
+ message: message.trim(),
243
+ author: author.trim(),
244
+ date: date.trim(),
245
+ type: conventionalMatch?.[1],
246
+ scope: conventionalMatch?.[2],
247
+ breaking: message.includes('BREAKING CHANGE')
248
+ });
249
+ }
250
+
251
+ return commits;
252
+ }
253
+
254
+ /**
255
+ * Generate changelog entry from commits
256
+ */
257
+ private generateChangelogEntry(version: string, commits: GitCommit[]): ChangelogEntry {
258
+ const entry: ChangelogEntry = {
259
+ version,
260
+ date: new Date().toISOString().split('T')[0],
261
+ changes: {
262
+ breaking: [],
263
+ features: [],
264
+ fixes: [],
265
+ chore: [],
266
+ docs: [],
267
+ other: []
268
+ }
269
+ };
270
+
271
+ for (const commit of commits) {
272
+ const message = commit.scope
273
+ ? `**${commit.scope}**: ${commit.message.split(':').slice(1).join(':').trim()}`
274
+ : commit.message;
275
+
276
+ if (commit.breaking) {
277
+ entry.changes.breaking?.push(message);
278
+ } else if (commit.type === 'feat') {
279
+ entry.changes.features?.push(message);
280
+ } else if (commit.type === 'fix') {
281
+ entry.changes.fixes?.push(message);
282
+ } else if (commit.type === 'chore') {
283
+ entry.changes.chore?.push(message);
284
+ } else if (commit.type === 'docs') {
285
+ entry.changes.docs?.push(message);
286
+ } else {
287
+ entry.changes.other?.push(message);
288
+ }
289
+ }
290
+
291
+ return entry;
292
+ }
293
+
294
+ /**
295
+ * Format changelog entry as markdown
296
+ */
297
+ private formatChangelogEntry(entry: ChangelogEntry): string {
298
+ let markdown = `## [${entry.version}] - ${entry.date}\n\n`;
299
+
300
+ const sections = [
301
+ { title: 'BREAKING CHANGES', items: entry.changes.breaking },
302
+ { title: 'Features', items: entry.changes.features },
303
+ { title: 'Bug Fixes', items: entry.changes.fixes },
304
+ { title: 'Documentation', items: entry.changes.docs },
305
+ { title: 'Chores', items: entry.changes.chore },
306
+ { title: 'Other Changes', items: entry.changes.other }
307
+ ];
308
+
309
+ for (const section of sections) {
310
+ if (section.items && section.items.length > 0) {
311
+ markdown += `### ${section.title}\n\n`;
312
+ for (const item of section.items) {
313
+ markdown += `- ${item}\n`;
314
+ }
315
+ markdown += '\n';
316
+ }
317
+ }
318
+
319
+ return markdown;
320
+ }
321
+
322
+ /**
323
+ * Update CHANGELOG.md file
324
+ */
325
+ private updateChangelogFile(path: string, newEntry: string): void {
326
+ const changelogPath = join(this.cwd, path);
327
+ let content = '';
328
+
329
+ if (existsSync(changelogPath)) {
330
+ content = readFileSync(changelogPath, 'utf-8');
331
+
332
+ // Insert after header
333
+ const headerEnd = content.indexOf('\n\n') + 2;
334
+ if (headerEnd > 1) {
335
+ content = content.slice(0, headerEnd) + newEntry + content.slice(headerEnd);
336
+ } else {
337
+ content = newEntry + '\n' + content;
338
+ }
339
+ } else {
340
+ content = `# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n${newEntry}`;
341
+ }
342
+
343
+ writeFileSync(changelogPath, content);
344
+ }
345
+
346
+ /**
347
+ * Execute command safely with validation
348
+ * Only allows git commands from the allowlist
349
+ */
350
+ private execCommand(cmd: string, returnOutput = false): string {
351
+ // Validate command against allowlist
352
+ validateCommand(cmd);
353
+
354
+ try {
355
+ const output = execSync(cmd, {
356
+ cwd: this.cwd,
357
+ encoding: 'utf-8',
358
+ stdio: returnOutput ? 'pipe' : 'inherit',
359
+ timeout: 30000, // 30 second timeout
360
+ maxBuffer: 10 * 1024 * 1024, // 10MB buffer limit
361
+ });
362
+ return returnOutput ? output : '';
363
+ } catch (error) {
364
+ if (returnOutput && error instanceof Error) {
365
+ return '';
366
+ }
367
+ throw error;
368
+ }
369
+ }
370
+ }
371
+
372
+ /**
373
+ * Convenience function to prepare a release
374
+ */
375
+ export async function prepareRelease(
376
+ options: ReleaseOptions = {}
377
+ ): Promise<ReleaseResult> {
378
+ const manager = new ReleaseManager();
379
+ return manager.prepareRelease(options);
380
+ }
package/src/types.ts ADDED
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Type definitions for deployment module
3
+ */
4
+
5
+ export type VersionBumpType = 'major' | 'minor' | 'patch' | 'prerelease';
6
+ export type ReleaseChannel = 'alpha' | 'beta' | 'rc' | 'latest';
7
+
8
+ export interface ReleaseOptions {
9
+ /** Type of version bump */
10
+ bumpType?: VersionBumpType;
11
+ /** Specific version to set (overrides bumpType) */
12
+ version?: string;
13
+ /** Release channel/tag */
14
+ channel?: ReleaseChannel;
15
+ /** Generate changelog from git commits */
16
+ generateChangelog?: boolean;
17
+ /** Create git tag */
18
+ createTag?: boolean;
19
+ /** Commit changes */
20
+ commit?: boolean;
21
+ /** Dry run mode */
22
+ dryRun?: boolean;
23
+ /** Skip validation checks */
24
+ skipValidation?: boolean;
25
+ /** Custom git tag prefix */
26
+ tagPrefix?: string;
27
+ /** Custom changelog file path */
28
+ changelogPath?: string;
29
+ }
30
+
31
+ export interface ReleaseResult {
32
+ /** Previous version */
33
+ oldVersion: string;
34
+ /** New version */
35
+ newVersion: string;
36
+ /** Git tag name */
37
+ tag?: string;
38
+ /** Changelog content */
39
+ changelog?: string;
40
+ /** Commit hash */
41
+ commitHash?: string;
42
+ /** Success status */
43
+ success: boolean;
44
+ /** Error message if failed */
45
+ error?: string;
46
+ /** Validation warnings */
47
+ warnings?: string[];
48
+ }
49
+
50
+ export interface PublishOptions {
51
+ /** npm tag (alpha, beta, latest) */
52
+ tag?: string;
53
+ /** Access level (public, restricted) */
54
+ access?: 'public' | 'restricted';
55
+ /** Dry run mode */
56
+ dryRun?: boolean;
57
+ /** Custom registry URL */
58
+ registry?: string;
59
+ /** OTP for 2FA */
60
+ otp?: string;
61
+ /** Skip build step */
62
+ skipBuild?: boolean;
63
+ /** Custom build command */
64
+ buildCommand?: string;
65
+ }
66
+
67
+ export interface PublishResult {
68
+ /** Package name */
69
+ packageName: string;
70
+ /** Published version */
71
+ version: string;
72
+ /** npm tag */
73
+ tag: string;
74
+ /** Package tarball URL */
75
+ tarball?: string;
76
+ /** Success status */
77
+ success: boolean;
78
+ /** Error message if failed */
79
+ error?: string;
80
+ /** Publish timestamp */
81
+ publishedAt?: Date;
82
+ }
83
+
84
+ export interface ValidationOptions {
85
+ /** Run linter */
86
+ lint?: boolean;
87
+ /** Run tests */
88
+ test?: boolean;
89
+ /** Run build */
90
+ build?: boolean;
91
+ /** Check dependencies */
92
+ checkDependencies?: boolean;
93
+ /** Check uncommitted changes */
94
+ checkGitStatus?: boolean;
95
+ /** Custom lint command */
96
+ lintCommand?: string;
97
+ /** Custom test command */
98
+ testCommand?: string;
99
+ /** Custom build command */
100
+ buildCommand?: string;
101
+ }
102
+
103
+ export interface ValidationResult {
104
+ /** Overall validation success */
105
+ valid: boolean;
106
+ /** Individual check results */
107
+ checks: {
108
+ lint?: { passed: boolean; errors?: string[] };
109
+ test?: { passed: boolean; errors?: string[] };
110
+ build?: { passed: boolean; errors?: string[] };
111
+ dependencies?: { passed: boolean; errors?: string[] };
112
+ gitStatus?: { passed: boolean; errors?: string[] };
113
+ packageJson?: { passed: boolean; errors?: string[] };
114
+ };
115
+ /** Overall errors */
116
+ errors: string[];
117
+ /** Warnings */
118
+ warnings: string[];
119
+ }
120
+
121
+ export interface PackageInfo {
122
+ name: string;
123
+ version: string;
124
+ description?: string;
125
+ private?: boolean;
126
+ repository?: {
127
+ type: string;
128
+ url: string;
129
+ };
130
+ dependencies?: Record<string, string>;
131
+ devDependencies?: Record<string, string>;
132
+ publishConfig?: {
133
+ access?: string;
134
+ registry?: string;
135
+ };
136
+ }
137
+
138
+ export interface GitCommit {
139
+ hash: string;
140
+ message: string;
141
+ author: string;
142
+ date: string;
143
+ type?: string;
144
+ scope?: string;
145
+ breaking?: boolean;
146
+ }
147
+
148
+ export interface ChangelogEntry {
149
+ version: string;
150
+ date: string;
151
+ changes: {
152
+ breaking?: string[];
153
+ features?: string[];
154
+ fixes?: string[];
155
+ chore?: string[];
156
+ docs?: string[];
157
+ other?: string[];
158
+ };
159
+ }