@rindrics/initrepo 0.0.1 → 0.1.5

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.
Files changed (45) hide show
  1. package/.github/codeql/codeql-config.yml +7 -0
  2. package/.github/dependabot.yml +11 -0
  3. package/.github/release.yml +4 -0
  4. package/.github/workflows/ci.yml +67 -0
  5. package/.github/workflows/codeql.yml +46 -0
  6. package/.github/workflows/publish.yml +35 -0
  7. package/.github/workflows/tagpr.yml +21 -0
  8. package/.husky/commit-msg +1 -0
  9. package/.husky/pre-push +2 -0
  10. package/.tagpr +7 -0
  11. package/.tool-versions +1 -0
  12. package/CHANGELOG.md +28 -0
  13. package/README.md +40 -28
  14. package/biome.json +38 -0
  15. package/bun.lock +334 -0
  16. package/commitlint.config.js +3 -0
  17. package/dist/cli.js +11215 -0
  18. package/docs/adr/0001-simple-module-structure-over-ddd.md +111 -0
  19. package/package.json +37 -7
  20. package/src/cli.test.ts +20 -0
  21. package/src/cli.ts +27 -0
  22. package/src/commands/init.test.ts +170 -0
  23. package/src/commands/init.ts +172 -0
  24. package/src/commands/prepare-release.test.ts +183 -0
  25. package/src/commands/prepare-release.ts +354 -0
  26. package/src/config.ts +13 -0
  27. package/src/generators/project.test.ts +363 -0
  28. package/src/generators/project.ts +300 -0
  29. package/src/templates/common/dependabot.yml.ejs +12 -0
  30. package/src/templates/common/release.yml.ejs +4 -0
  31. package/src/templates/common/workflows/tagpr.yml.ejs +31 -0
  32. package/src/templates/typescript/.tagpr.ejs +5 -0
  33. package/src/templates/typescript/codeql/codeql-config.yml.ejs +7 -0
  34. package/src/templates/typescript/package.json.ejs +29 -0
  35. package/src/templates/typescript/src/index.ts.ejs +1 -0
  36. package/src/templates/typescript/tsconfig.json.ejs +17 -0
  37. package/src/templates/typescript/workflows/ci.yml.ejs +58 -0
  38. package/src/templates/typescript/workflows/codeql.yml.ejs +46 -0
  39. package/src/types.ts +13 -0
  40. package/src/utils/github-repo.test.ts +34 -0
  41. package/src/utils/github-repo.ts +141 -0
  42. package/src/utils/github.ts +47 -0
  43. package/src/utils/npm.test.ts +99 -0
  44. package/src/utils/npm.ts +59 -0
  45. package/tsconfig.json +16 -0
@@ -0,0 +1,111 @@
1
+ # ADR 0001: Simple Module Structure Over DDD
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ We are building `@rindrics/initrepo`, a CLI tool that generates project scaffolding with:
10
+
11
+ - CI/CD workflows (test, lint, tagpr, release)
12
+ - husky configuration
13
+ - Language-specific setup (TypeScript initially)
14
+ - GitHub repository setup (repo creation, tags, branch protection)
15
+
16
+ The tool has two main commands:
17
+
18
+ 1. `init` - Initialize a new project with all configurations
19
+ 2. `replace-devcode` - Replace development placeholder with production name and enable release workflow
20
+
21
+ We needed to decide on an appropriate architecture for this tool.
22
+
23
+ ## Decision
24
+
25
+ We chose a **simple module-based structure** instead of Domain-Driven Design (DDD).
26
+
27
+ ### Chosen Structure
28
+
29
+ ```text
30
+ src/
31
+ ├── cli.ts # CLI entry point (Commander.js)
32
+ ├── commands/
33
+ │ ├── init.ts # init command implementation
34
+ │ └── replace-devcode.ts # replace-devcode command implementation
35
+ ├── generators/
36
+ │ ├── languages/ # Language-specific logic
37
+ │ │ ├── index.ts # Common interface & factory
38
+ │ │ └── typescript.ts # TypeScript generator
39
+ │ ├── project.ts # package.json, etc.
40
+ │ ├── cicd.ts # GitHub Actions workflows
41
+ │ └── husky.ts # husky setup
42
+ ├── github/
43
+ │ └── client.ts # GitHub API operations (Octokit)
44
+ ├── templates/
45
+ │ ├── common/ # Language-agnostic templates
46
+ │ └── typescript/ # TypeScript-specific templates
47
+ └── types.ts # Shared type definitions
48
+ ```
49
+
50
+ ### Why Not DDD?
51
+
52
+ DDD excels when:
53
+
54
+ - Complex business rules exist (aggregates, value objects, domain events)
55
+ - A ubiquitous language with domain experts is needed
56
+ - The domain model evolves over time
57
+
58
+ This tool is essentially a **file generation utility**:
59
+
60
+ - **Input**: Project name, language, options
61
+ - **Output**: Generated files, GitHub repository
62
+
63
+ There are no:
64
+
65
+ - Domain entities to model
66
+ - Complex business invariants to protect
67
+ - Data persistence requiring repository patterns
68
+ - Use cases complex enough to warrant a separate application layer
69
+
70
+ ### Language Support Strategy
71
+
72
+ Languages are supported through two mechanisms:
73
+
74
+ 1. **`generators/languages/*.ts`** - Logic for each language (what files to generate, what dependencies to include, CI configuration)
75
+ 2. **`templates/{lang}/`** - Actual template files for each language
76
+
77
+ A common `LanguageGenerator` interface ensures consistent behavior:
78
+
79
+ ```typescript
80
+ interface LanguageGenerator {
81
+ name: string;
82
+ getFiles(): GeneratedFile[];
83
+ getDependencies(): Dependencies;
84
+ getCiConfig(): CiConfig;
85
+ }
86
+ ```
87
+
88
+ Adding a new language requires:
89
+
90
+ 1. Implementing `LanguageGenerator` in `generators/languages/`
91
+ 2. Adding templates in `templates/{lang}/`
92
+ 3. Registering in the factory function
93
+
94
+ ## Consequences
95
+
96
+ ### Positive
97
+
98
+ - **Simplicity**: Code is easy to navigate and understand
99
+ - **Testability**: Each module can be tested in isolation
100
+ - **Extensibility**: New generators or languages can be added without touching existing code
101
+ - **Right-sized**: No unnecessary abstraction layers
102
+ - **Fast development**: Less boilerplate, quicker iterations
103
+
104
+ ### Negative
105
+
106
+ - **Limited structure for growth**: If the tool grows significantly in complexity, we may need to introduce more structure
107
+ - **No enforced boundaries**: Developers must exercise discipline to maintain separation of concerns
108
+
109
+ ### Neutral
110
+
111
+ - If complex domain logic emerges (e.g., conditional generation rules, plugin systems), we can introduce targeted patterns without full DDD adoption
package/package.json CHANGED
@@ -1,10 +1,40 @@
1
1
  {
2
2
  "name": "@rindrics/initrepo",
3
- "version": "0.0.1",
4
- "description": "OIDC trusted publishing setup package for @rindrics/initrepo",
5
- "keywords": [
6
- "oidc",
7
- "trusted-publishing",
8
- "setup"
9
- ]
3
+ "version": "0.1.5",
4
+ "description": "setup GitHub repo with dev tools",
5
+ "type": "module",
6
+ "bin": {
7
+ "initrepo": "./dist/cli.js"
8
+ },
9
+ "scripts": {
10
+ "dev": "bun run src/cli.ts",
11
+ "build": "bun build src/cli.ts --outdir dist --target node",
12
+ "lint": "biome lint src",
13
+ "format": "biome format src --write",
14
+ "check": "biome check src",
15
+ "test": "bun test",
16
+ "clean": "rm -rf test-* dist",
17
+ "prepare": "husky"
18
+ },
19
+ "keywords": ["cli", "scaffold", "setup", "repository"],
20
+ "author": "Rindrics",
21
+ "license": "MIT",
22
+ "repository": {
23
+ "type": "git",
24
+ "url": "git+https://github.com/Rindrics/initrepo.git"
25
+ },
26
+ "devDependencies": {
27
+ "@biomejs/biome": "^2.0.0",
28
+ "@commitlint/cli": "^19.0.0",
29
+ "@commitlint/config-conventional": "^20.2.0",
30
+ "@types/ejs": "^3.1.5",
31
+ "bun-types": "^1.0.0",
32
+ "husky": "^9.0.0",
33
+ "typescript": "^5.0.0"
34
+ },
35
+ "dependencies": {
36
+ "commander": "^14.0.2",
37
+ "ejs": "^3.1.0",
38
+ "octokit": "^5.0.5"
39
+ }
10
40
  }
@@ -0,0 +1,20 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import packageJson from '../package.json';
3
+ import { createProgram } from './cli';
4
+
5
+ describe('CLI', () => {
6
+ test('should have correct name from package.json', () => {
7
+ const program = createProgram();
8
+ expect(program.name()).toBe(packageJson.name);
9
+ });
10
+
11
+ test('should have correct version from package.json', () => {
12
+ const program = createProgram();
13
+ expect(program.version()).toBe(packageJson.version);
14
+ });
15
+
16
+ test('should have description', () => {
17
+ const program = createProgram();
18
+ expect(program.description()).toBe('Rapid repository setup CLI tool');
19
+ });
20
+ });
package/src/cli.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import packageJson from '../package.json';
4
+ import { registerInitCommand } from './commands/init';
5
+ import { registerPrepareReleaseCommand } from './commands/prepare-release';
6
+
7
+ const { version: VERSION, name: NAME } = packageJson;
8
+
9
+ export function createProgram(): Command {
10
+ const program = new Command();
11
+
12
+ program
13
+ .name(NAME)
14
+ .description('Rapid repository setup CLI tool')
15
+ .version(VERSION);
16
+
17
+ registerInitCommand(program);
18
+ registerPrepareReleaseCommand(program);
19
+
20
+ return program;
21
+ }
22
+
23
+ // Run CLI when executed directly
24
+ if (import.meta.main) {
25
+ const program = createProgram();
26
+ program.parse();
27
+ }
@@ -0,0 +1,170 @@
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from 'bun:test';
2
+ import { Command } from 'commander';
3
+ import * as projectGenerator from '../generators/project';
4
+ import {
5
+ initProject,
6
+ promptYesNo,
7
+ registerInitCommand,
8
+ validateLanguage,
9
+ } from './init';
10
+
11
+ describe('init command', () => {
12
+ describe('validateLanguage', () => {
13
+ test('should accept typescript', () => {
14
+ expect(validateLanguage('typescript')).toBe('typescript');
15
+ });
16
+
17
+ test('should throw for unsupported language', () => {
18
+ expect(() => validateLanguage('python')).toThrow(
19
+ 'Unsupported language: python',
20
+ );
21
+ });
22
+ });
23
+
24
+ describe('initProject', () => {
25
+ let consoleSpy: ReturnType<typeof spyOn>;
26
+ let generateProjectSpy: ReturnType<typeof spyOn>;
27
+
28
+ beforeEach(() => {
29
+ consoleSpy = spyOn(console, 'log').mockImplementation(() => {});
30
+ // Mock generateProject to avoid file operations and network calls
31
+ generateProjectSpy = spyOn(
32
+ projectGenerator,
33
+ 'generateProject',
34
+ ).mockResolvedValue(undefined);
35
+ });
36
+
37
+ afterEach(() => {
38
+ consoleSpy.mockRestore();
39
+ generateProjectSpy.mockRestore();
40
+ });
41
+
42
+ test('should log project creation message', async () => {
43
+ await initProject({
44
+ projectName: 'test-project',
45
+ lang: 'typescript',
46
+ isDevcode: false,
47
+ });
48
+
49
+ expect(consoleSpy).toHaveBeenCalledWith(
50
+ 'Creating project: test-project (typescript)',
51
+ );
52
+ });
53
+
54
+ test('should include devcode label when isDevcode is true', async () => {
55
+ await initProject({
56
+ projectName: 'test-project',
57
+ lang: 'typescript',
58
+ isDevcode: true,
59
+ });
60
+
61
+ expect(consoleSpy).toHaveBeenCalledWith(
62
+ 'Creating project: test-project (typescript) [devcode]',
63
+ );
64
+ });
65
+
66
+ test('should pass author option to generateProject', async () => {
67
+ await initProject({
68
+ projectName: 'test-project',
69
+ lang: 'typescript',
70
+ isDevcode: false,
71
+ author: 'custom-author',
72
+ });
73
+
74
+ expect(generateProjectSpy).toHaveBeenCalledWith({
75
+ projectName: 'test-project',
76
+ lang: 'typescript',
77
+ isDevcode: false,
78
+ author: 'custom-author',
79
+ });
80
+ });
81
+ });
82
+
83
+ describe('registerInitCommand', () => {
84
+ test('should register init command to program', () => {
85
+ const program = new Command();
86
+ registerInitCommand(program);
87
+
88
+ const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
89
+ expect(initCmd).toBeDefined();
90
+ expect(initCmd?.description()).toContain('npm publish');
91
+ });
92
+
93
+ test('should have --devcode option', () => {
94
+ const program = new Command();
95
+ registerInitCommand(program);
96
+
97
+ const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
98
+ const devcodeOption = initCmd?.options.find(
99
+ (opt) => opt.long === '--devcode',
100
+ );
101
+ expect(devcodeOption).toBeDefined();
102
+ });
103
+
104
+ test('should have --author option', () => {
105
+ const program = new Command();
106
+ registerInitCommand(program);
107
+
108
+ const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
109
+ const authorOption = initCmd?.options.find(
110
+ (opt) => opt.long === '--author',
111
+ );
112
+ expect(authorOption).toBeDefined();
113
+ });
114
+
115
+ test('should have --create-repo option', () => {
116
+ const program = new Command();
117
+ registerInitCommand(program);
118
+
119
+ const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
120
+ const createRepoOption = initCmd?.options.find(
121
+ (opt) => opt.long === '--create-repo',
122
+ );
123
+ expect(createRepoOption).toBeDefined();
124
+ });
125
+
126
+ test('should have --no-create-repo option', () => {
127
+ const program = new Command();
128
+ registerInitCommand(program);
129
+
130
+ const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
131
+ const noCreateRepoOption = initCmd?.options.find(
132
+ (opt) => opt.long === '--no-create-repo',
133
+ );
134
+ expect(noCreateRepoOption).toBeDefined();
135
+ });
136
+
137
+ test('should have --no-devcode option', () => {
138
+ const program = new Command();
139
+ registerInitCommand(program);
140
+
141
+ const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
142
+ const noDevcodeOption = initCmd?.options.find(
143
+ (opt) => opt.long === '--no-devcode',
144
+ );
145
+ expect(noDevcodeOption).toBeDefined();
146
+ });
147
+
148
+ test('should have --private option', () => {
149
+ const program = new Command();
150
+ registerInitCommand(program);
151
+
152
+ const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
153
+ const privateOption = initCmd?.options.find(
154
+ (opt) => opt.long === '--private',
155
+ );
156
+ expect(privateOption).toBeDefined();
157
+ });
158
+
159
+ test('should have --no-private option', () => {
160
+ const program = new Command();
161
+ registerInitCommand(program);
162
+
163
+ const initCmd = program.commands.find((cmd) => cmd.name() === 'init');
164
+ const noPrivateOption = initCmd?.options.find(
165
+ (opt) => opt.long === '--no-private',
166
+ );
167
+ expect(noPrivateOption).toBeDefined();
168
+ });
169
+ });
170
+ });
@@ -0,0 +1,172 @@
1
+ import * as readline from 'node:readline/promises';
2
+ import type { Command } from 'commander';
3
+ import { generateProject } from '../generators/project';
4
+ import type { InitOptions, Language } from '../types';
5
+ import { createGitHubRepo, hasGitHubToken } from '../utils/github-repo';
6
+
7
+ const SUPPORTED_LANGUAGES: Language[] = ['typescript'];
8
+
9
+ export function validateLanguage(lang: string): Language {
10
+ if (!SUPPORTED_LANGUAGES.includes(lang as Language)) {
11
+ throw new Error(
12
+ `Unsupported language: ${lang}. Supported: ${SUPPORTED_LANGUAGES.join(', ')}`,
13
+ );
14
+ }
15
+ return lang as Language;
16
+ }
17
+
18
+ /**
19
+ * Prompts user with a yes/no question
20
+ */
21
+ export async function promptYesNo(
22
+ question: string,
23
+ defaultValue = false,
24
+ ): Promise<boolean> {
25
+ const rl = readline.createInterface({
26
+ input: process.stdin,
27
+ output: process.stdout,
28
+ });
29
+
30
+ const hint = defaultValue ? '(Y/n)' : '(y/N)';
31
+
32
+ try {
33
+ const answer = await rl.question(`${question} ${hint}: `);
34
+ if (answer.trim() === '') {
35
+ return defaultValue;
36
+ }
37
+ return answer.toLowerCase() === 'y' || answer.toLowerCase() === 'yes';
38
+ } finally {
39
+ rl.close();
40
+ }
41
+ }
42
+
43
+ export interface InitProjectOptions extends InitOptions {
44
+ createRepo?: boolean;
45
+ isPrivate?: boolean;
46
+ }
47
+
48
+ export async function initProject(options: InitProjectOptions): Promise<void> {
49
+ const devcodeLabel = options.isDevcode ? ' [devcode]' : '';
50
+ console.log(
51
+ `Creating project: ${options.projectName} (${options.lang})${devcodeLabel}`,
52
+ );
53
+
54
+ // Generate project files
55
+ await generateProject(options);
56
+ console.log(`✅ Project files created at ./${options.projectName}`);
57
+
58
+ // Create GitHub repository if requested
59
+ if (options.createRepo) {
60
+ if (!hasGitHubToken()) {
61
+ console.warn('⚠️ GITHUB_TOKEN not set, skipping repository creation');
62
+ console.warn(
63
+ ' Set GITHUB_TOKEN environment variable to enable repo creation',
64
+ );
65
+ } else {
66
+ try {
67
+ console.log('📦 Creating GitHub repository...');
68
+ const result = await createGitHubRepo({
69
+ name: options.projectName,
70
+ description: options.isDevcode
71
+ ? `[devcode] ${options.projectName}`
72
+ : undefined,
73
+ isPrivate: options.isPrivate ?? false,
74
+ });
75
+
76
+ if (result.alreadyExisted) {
77
+ console.log(`ℹ️ Repository already exists: ${result.url}`);
78
+ console.log(` Labels ensured: tagpr:minor, tagpr:major`);
79
+ } else {
80
+ console.log(`✅ GitHub repository created: ${result.url}`);
81
+ console.log(` Labels created: tagpr:minor, tagpr:major`);
82
+ }
83
+
84
+ console.log(`\n To push your code:`);
85
+ console.log(` cd ${options.projectName}`);
86
+ console.log(` git init`);
87
+ console.log(` git add .`);
88
+ console.log(` git commit -m "chore: initial commit"`);
89
+ console.log(` git remote add origin ${result.cloneUrl}`);
90
+ console.log(` git push -u origin main`);
91
+ } catch (error) {
92
+ console.error(
93
+ `❌ Failed to create GitHub repository: ${error instanceof Error ? error.message : String(error)}`,
94
+ );
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ interface InitCommandOptions {
101
+ lang: string;
102
+ devcode?: boolean;
103
+ author?: string;
104
+ createRepo?: boolean;
105
+ private?: boolean;
106
+ }
107
+
108
+ export function registerInitCommand(program: Command): void {
109
+ program
110
+ .command('init <project-name>')
111
+ .description(
112
+ 'Initialize a new project. <project-name> is the name used for npm publish.',
113
+ )
114
+ .option('-l, --lang <language>', 'Project language', 'typescript')
115
+ .option(
116
+ '-d, --devcode',
117
+ 'Use devcode mode (adds private: true to package.json)',
118
+ )
119
+ .option('--no-devcode', 'Not a devcode project')
120
+ .option(
121
+ '-a, --author <name>',
122
+ 'Package author (defaults to npm whoami for TypeScript)',
123
+ )
124
+ .option(
125
+ '--create-repo',
126
+ 'Create GitHub repository and tagpr labels (requires GITHUB_TOKEN)',
127
+ )
128
+ .option('--no-create-repo', 'Skip GitHub repository creation')
129
+ .option('-p, --private', 'Make GitHub repository private')
130
+ .option('--no-private', 'Make GitHub repository public')
131
+ .action(async (projectName: string, opts: InitCommandOptions) => {
132
+ const lang = validateLanguage(opts.lang);
133
+
134
+ // Determine if devcode - prompt if not specified
135
+ let isDevcode: boolean;
136
+ if (opts.devcode !== undefined) {
137
+ isDevcode = opts.devcode;
138
+ } else {
139
+ isDevcode = await promptYesNo(
140
+ `Is "${projectName}" a developmental code?`,
141
+ false,
142
+ );
143
+ }
144
+
145
+ // Determine if create repo - prompt if not specified
146
+ let createRepo: boolean;
147
+ if (opts.createRepo !== undefined) {
148
+ createRepo = opts.createRepo;
149
+ } else {
150
+ createRepo = await promptYesNo('Create GitHub repo?', true);
151
+ }
152
+
153
+ // Determine if private repo - prompt only if creating repo and not specified
154
+ let isPrivate = false;
155
+ if (createRepo) {
156
+ if (opts.private !== undefined) {
157
+ isPrivate = opts.private;
158
+ } else {
159
+ isPrivate = await promptYesNo('Make repo private?', false);
160
+ }
161
+ }
162
+
163
+ await initProject({
164
+ projectName,
165
+ lang,
166
+ isDevcode,
167
+ author: opts.author,
168
+ createRepo,
169
+ isPrivate,
170
+ });
171
+ });
172
+ }