@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,183 @@
1
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
2
+ import * as fs from 'node:fs/promises';
3
+ import * as path from 'node:path';
4
+ import { Command } from 'commander';
5
+ import {
6
+ detectDevcode,
7
+ prepareRelease,
8
+ registerPrepareReleaseCommand,
9
+ } from './prepare-release';
10
+
11
+ describe('prepare-release command', () => {
12
+ const testDir = path.join(import.meta.dir, '../../.test-prepare-release');
13
+
14
+ beforeEach(async () => {
15
+ await fs.rm(testDir, { recursive: true, force: true });
16
+ await fs.mkdir(testDir, { recursive: true });
17
+ await fs.mkdir(path.join(testDir, '.github/workflows'), {
18
+ recursive: true,
19
+ });
20
+ await fs.mkdir(path.join(testDir, '.github/codeql'), { recursive: true });
21
+ await fs.mkdir(path.join(testDir, 'src'), { recursive: true });
22
+ });
23
+
24
+ afterEach(async () => {
25
+ await fs.rm(testDir, { recursive: true, force: true });
26
+ });
27
+
28
+ describe('detectDevcode', () => {
29
+ test('should return devcode name when private: true', async () => {
30
+ const packageJson = {
31
+ name: 'my-devcode',
32
+ version: '0.0.0',
33
+ private: true,
34
+ };
35
+ await fs.writeFile(
36
+ path.join(testDir, 'package.json'),
37
+ JSON.stringify(packageJson),
38
+ );
39
+
40
+ const devcode = await detectDevcode(testDir);
41
+ expect(devcode).toBe('my-devcode');
42
+ });
43
+
44
+ test('should throw when private flag is missing', async () => {
45
+ const packageJson = { name: 'my-project', version: '0.0.0' };
46
+ await fs.writeFile(
47
+ path.join(testDir, 'package.json'),
48
+ JSON.stringify(packageJson),
49
+ );
50
+
51
+ expect(detectDevcode(testDir)).rejects.toThrow('not a devcode project');
52
+ });
53
+
54
+ test('should throw when package.json not found', async () => {
55
+ expect(detectDevcode(testDir)).rejects.toThrow('package.json not found');
56
+ });
57
+ });
58
+
59
+ describe('prepareRelease', () => {
60
+ test('should replace only in managed locations', async () => {
61
+ // Setup test files with private: true
62
+ await fs.writeFile(
63
+ path.join(testDir, 'package.json'),
64
+ JSON.stringify({ name: 'devcode', version: '0.0.0', private: true }),
65
+ );
66
+ await fs.writeFile(
67
+ path.join(testDir, '.github/workflows/tagpr.yml'),
68
+ `name: tagpr
69
+ jobs:
70
+ tagpr:
71
+ steps:
72
+ - uses: actions/checkout@v6
73
+ # TODO: After replace-devcode, add token: \${{ secrets.PAT_FOR_TAGPR }}
74
+ - uses: Songmu/tagpr@v1
75
+ env:
76
+ GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }}
77
+ `,
78
+ );
79
+ await fs.writeFile(
80
+ path.join(testDir, '.github/codeql/codeql-config.yml'),
81
+ 'name: "CodeQL config for devcode"\n\npaths:\n - src',
82
+ );
83
+
84
+ await prepareRelease({
85
+ publishName: '@scope/package',
86
+ targetDir: testDir,
87
+ });
88
+
89
+ // Verify package.json - name should be replaced
90
+ const pkg = JSON.parse(
91
+ await fs.readFile(path.join(testDir, 'package.json'), 'utf-8'),
92
+ );
93
+ expect(pkg.name).toBe('@scope/package');
94
+ expect(pkg.private).toBeUndefined();
95
+
96
+ // Verify tagpr.yml - tokens should be replaced
97
+ const tagpr = await fs.readFile(
98
+ path.join(testDir, '.github/workflows/tagpr.yml'),
99
+ 'utf-8',
100
+ );
101
+ expect(tagpr).toContain('secrets.PAT_FOR_TAGPR');
102
+ expect(tagpr).not.toContain('secrets.GITHUB_TOKEN');
103
+
104
+ // Verify codeql-config.yml - only name field should be replaced
105
+ const codeql = await fs.readFile(
106
+ path.join(testDir, '.github/codeql/codeql-config.yml'),
107
+ 'utf-8',
108
+ );
109
+ expect(codeql).toContain('@scope/package');
110
+ });
111
+
112
+ test('should detect unmanaged occurrences', async () => {
113
+ // Setup with devcode appearing in an unmanaged file
114
+ await fs.writeFile(
115
+ path.join(testDir, 'package.json'),
116
+ JSON.stringify({ name: 'my-devcode', version: '0.0.0', private: true }),
117
+ );
118
+ await fs.writeFile(
119
+ path.join(testDir, 'src/index.ts'),
120
+ 'console.log("Hello from my-devcode!");',
121
+ );
122
+
123
+ // Capture console output
124
+ const logs: string[] = [];
125
+ const originalLog = console.log;
126
+ console.log = (...args) => logs.push(args.join(' '));
127
+
128
+ try {
129
+ await prepareRelease({
130
+ publishName: '@scope/package',
131
+ targetDir: testDir,
132
+ });
133
+ } finally {
134
+ console.log = originalLog;
135
+ }
136
+
137
+ // Verify unmanaged occurrence was reported
138
+ const output = logs.join('\n');
139
+ expect(output).toContain('unmanaged occurrence');
140
+ expect(output).toContain('src/index.ts');
141
+
142
+ // Verify unmanaged file was NOT modified
143
+ const srcContent = await fs.readFile(
144
+ path.join(testDir, 'src/index.ts'),
145
+ 'utf-8',
146
+ );
147
+ expect(srcContent).toContain('my-devcode');
148
+ expect(srcContent).not.toContain('@scope/package');
149
+ });
150
+
151
+ test('should fail if not a devcode project', async () => {
152
+ await fs.writeFile(
153
+ path.join(testDir, 'package.json'),
154
+ JSON.stringify({ name: 'not-devcode', version: '1.0.0' }),
155
+ );
156
+
157
+ expect(
158
+ prepareRelease({ publishName: '@scope/package', targetDir: testDir }),
159
+ ).rejects.toThrow('not a devcode project');
160
+ });
161
+ });
162
+
163
+ describe('registerPrepareReleaseCommand', () => {
164
+ test('should register prepare-release command with publish-name argument', () => {
165
+ const program = new Command();
166
+ registerPrepareReleaseCommand(program);
167
+
168
+ const cmd = program.commands.find((c) => c.name() === 'prepare-release');
169
+ expect(cmd).toBeDefined();
170
+ expect(cmd?.description()).toContain('auto-detects');
171
+ });
172
+
173
+ test('should have target-dir option', () => {
174
+ const program = new Command();
175
+ registerPrepareReleaseCommand(program);
176
+
177
+ const cmd = program.commands.find((c) => c.name() === 'prepare-release');
178
+ const options = cmd?.options.map((o) => o.long);
179
+
180
+ expect(options).toContain('--target-dir');
181
+ });
182
+ });
183
+ });
@@ -0,0 +1,354 @@
1
+ import * as fs from 'node:fs/promises';
2
+ import * as path from 'node:path';
3
+ import type { Command } from 'commander';
4
+
5
+ export interface PrepareReleaseOptions {
6
+ /** New package name for release */
7
+ publishName: string;
8
+ /** Target directory (defaults to current directory) */
9
+ targetDir?: string;
10
+ }
11
+
12
+ interface PackageJson {
13
+ name: string;
14
+ private?: boolean;
15
+ [key: string]: unknown;
16
+ }
17
+
18
+ /**
19
+ * Managed locations where devcode replacement is performed automatically.
20
+ * Each location specifies the file and how to replace.
21
+ */
22
+ interface ManagedLocation {
23
+ file: string;
24
+ description: string;
25
+ replace: (
26
+ targetDir: string,
27
+ devcode: string,
28
+ publishName: string,
29
+ ) => Promise<void>;
30
+ }
31
+
32
+ /**
33
+ * Reads package.json and detects if this is a devcode project
34
+ * Returns the devcode name if private: true, otherwise throws
35
+ */
36
+ export async function detectDevcode(targetDir: string): Promise<string> {
37
+ const packageJsonPath = path.join(targetDir, 'package.json');
38
+
39
+ let content: string;
40
+ try {
41
+ content = await fs.readFile(packageJsonPath, 'utf-8');
42
+ } catch (error) {
43
+ const fsError = error as NodeJS.ErrnoException;
44
+ if (fsError.code === 'ENOENT') {
45
+ throw new Error(
46
+ 'package.json not found. Are you in a project directory?',
47
+ );
48
+ }
49
+ throw error;
50
+ }
51
+
52
+ const pkg = JSON.parse(content) as PackageJson;
53
+
54
+ if (!pkg.private) {
55
+ throw new Error(
56
+ 'This project is not a devcode project (missing "private": true in package.json)',
57
+ );
58
+ }
59
+
60
+ return pkg.name;
61
+ }
62
+
63
+ /**
64
+ * Updates package.json: replaces name and removes "private": true
65
+ * This is a MANAGED replacement - only touches the "name" field
66
+ */
67
+ async function replaceInPackageJson(
68
+ targetDir: string,
69
+ _devcode: string,
70
+ publishName: string,
71
+ ): Promise<void> {
72
+ const packageJsonPath = path.join(targetDir, 'package.json');
73
+ const content = await fs.readFile(packageJsonPath, 'utf-8');
74
+ const pkg = JSON.parse(content) as PackageJson;
75
+
76
+ // Only replace the managed "name" field
77
+ pkg.name = publishName;
78
+
79
+ // Remove private flag
80
+ delete pkg.private;
81
+
82
+ await fs.writeFile(
83
+ packageJsonPath,
84
+ `${JSON.stringify(pkg, null, 2)}\n`,
85
+ 'utf-8',
86
+ );
87
+ }
88
+
89
+ /**
90
+ * Updates codeql-config.yml: replaces only the "name" field
91
+ * This is a MANAGED replacement - only touches the YAML name field
92
+ */
93
+ async function replaceInCodeqlConfig(
94
+ targetDir: string,
95
+ devcode: string,
96
+ publishName: string,
97
+ ): Promise<void> {
98
+ const configPath = path.join(targetDir, '.github/codeql/codeql-config.yml');
99
+
100
+ let content: string;
101
+ try {
102
+ content = await fs.readFile(configPath, 'utf-8');
103
+ } catch (error) {
104
+ const fsError = error as NodeJS.ErrnoException;
105
+ if (fsError.code === 'ENOENT') {
106
+ return; // File doesn't exist, nothing to do
107
+ }
108
+ throw error;
109
+ }
110
+
111
+ // Line-based parsing to avoid ReDoS vulnerability
112
+ // Only replace devcode in the first line that starts with "name:"
113
+ const lines = content.split('\n');
114
+ for (let i = 0; i < lines.length; i++) {
115
+ const line = lines[i];
116
+ if (line.trimStart().startsWith('name:')) {
117
+ // Replace devcode only in this specific line
118
+ lines[i] = line.replace(devcode, publishName);
119
+ break;
120
+ }
121
+ }
122
+ content = lines.join('\n');
123
+
124
+ await fs.writeFile(configPath, content, 'utf-8');
125
+ }
126
+
127
+ /**
128
+ * Updates tagpr.yml: switches from GITHUB_TOKEN to PAT_FOR_TAGPR
129
+ * This is a MANAGED replacement - only touches specific workflow patterns
130
+ */
131
+ async function replaceInTagprWorkflow(
132
+ targetDir: string,
133
+ _devcode: string,
134
+ _publishName: string,
135
+ ): Promise<void> {
136
+ const workflowPath = path.join(targetDir, '.github/workflows/tagpr.yml');
137
+
138
+ let content: string;
139
+ try {
140
+ content = await fs.readFile(workflowPath, 'utf-8');
141
+ } catch (error) {
142
+ const fsError = error as NodeJS.ErrnoException;
143
+ if (fsError.code === 'ENOENT') {
144
+ return; // File doesn't exist, nothing to do
145
+ }
146
+ throw error;
147
+ }
148
+
149
+ // Replace GITHUB_TOKEN with PAT_FOR_TAGPR in env
150
+ content = content.replace(
151
+ /GITHUB_TOKEN: \$\{\{ secrets\.GITHUB_TOKEN \}\}/g,
152
+ 'GITHUB_TOKEN: ${{ secrets.PAT_FOR_TAGPR }}',
153
+ );
154
+
155
+ // Add token to checkout if not present
156
+ content = content.replace(
157
+ /(uses: actions\/checkout@v\d+)\n(\s*)# TODO: After replace-devcode, add token: \$\{\{ secrets\.PAT_FOR_TAGPR \}\}/g,
158
+ '$1\n$2with:\n$2 token: ${{ secrets.PAT_FOR_TAGPR }}',
159
+ );
160
+
161
+ // Remove remaining TODO comments
162
+ content = content.replace(/\s*# TODO: After replace-devcode.*\n/g, '\n');
163
+
164
+ // Clean up extra blank lines
165
+ content = content.replace(/\n{3,}/g, '\n\n');
166
+
167
+ await fs.writeFile(workflowPath, content, 'utf-8');
168
+ }
169
+
170
+ /**
171
+ * Managed locations where devcode is automatically replaced
172
+ */
173
+ const MANAGED_LOCATIONS: ManagedLocation[] = [
174
+ {
175
+ file: 'package.json',
176
+ description: 'name field',
177
+ replace: replaceInPackageJson,
178
+ },
179
+ {
180
+ file: '.github/codeql/codeql-config.yml',
181
+ description: 'name field',
182
+ replace: replaceInCodeqlConfig,
183
+ },
184
+ {
185
+ file: '.github/workflows/tagpr.yml',
186
+ description: 'GITHUB_TOKEN → PAT_FOR_TAGPR',
187
+ replace: replaceInTagprWorkflow,
188
+ },
189
+ ];
190
+
191
+ /**
192
+ * Scans project for unmanaged occurrences of devcode
193
+ */
194
+ async function findUnmanagedOccurrences(
195
+ targetDir: string,
196
+ devcode: string,
197
+ ): Promise<{ file: string; line: number; content: string }[]> {
198
+ const occurrences: { file: string; line: number; content: string }[] = [];
199
+ const managedFiles = new Set(MANAGED_LOCATIONS.map((l) => l.file));
200
+
201
+ // Files to scan (excluding node_modules, .git, etc.)
202
+ const filesToScan = await findFilesRecursive(targetDir, [
203
+ 'node_modules',
204
+ '.git',
205
+ 'dist',
206
+ 'bun.lockb',
207
+ ]);
208
+
209
+ for (const file of filesToScan) {
210
+ const relativePath = path.relative(targetDir, file);
211
+
212
+ // Skip managed files
213
+ if (managedFiles.has(relativePath)) {
214
+ continue;
215
+ }
216
+
217
+ try {
218
+ const content = await fs.readFile(file, 'utf-8');
219
+ const lines = content.split('\n');
220
+
221
+ for (let i = 0; i < lines.length; i++) {
222
+ if (lines[i].includes(devcode)) {
223
+ occurrences.push({
224
+ file: relativePath,
225
+ line: i + 1,
226
+ content: lines[i].trim().substring(0, 80),
227
+ });
228
+ }
229
+ }
230
+ } catch {
231
+ // Skip files that can't be read (binary, etc.)
232
+ }
233
+ }
234
+
235
+ return occurrences;
236
+ }
237
+
238
+ /**
239
+ * Recursively find all files in a directory
240
+ */
241
+ async function findFilesRecursive(
242
+ dir: string,
243
+ excludeDirs: string[],
244
+ ): Promise<string[]> {
245
+ const files: string[] = [];
246
+
247
+ try {
248
+ const entries = await fs.readdir(dir, { withFileTypes: true });
249
+
250
+ for (const entry of entries) {
251
+ const fullPath = path.join(dir, entry.name);
252
+
253
+ if (entry.isDirectory()) {
254
+ if (!excludeDirs.includes(entry.name)) {
255
+ files.push(...(await findFilesRecursive(fullPath, excludeDirs)));
256
+ }
257
+ } else if (entry.isFile()) {
258
+ files.push(fullPath);
259
+ }
260
+ }
261
+ } catch {
262
+ // Skip directories that can't be read
263
+ }
264
+
265
+ return files;
266
+ }
267
+
268
+ /**
269
+ * Main prepare-release logic
270
+ */
271
+ export async function prepareRelease(
272
+ options: PrepareReleaseOptions,
273
+ ): Promise<void> {
274
+ const targetDir = options.targetDir ?? process.cwd();
275
+
276
+ // Detect devcode from package.json (private: true)
277
+ const devcode = await detectDevcode(targetDir);
278
+
279
+ console.log(`Detected devcode project: ${devcode}`);
280
+ console.log(`Preparing release: ${devcode} → ${options.publishName}\n`);
281
+
282
+ // Process managed locations
283
+ console.log('šŸ“ Managed replacements:');
284
+ for (const location of MANAGED_LOCATIONS) {
285
+ try {
286
+ await location.replace(targetDir, devcode, options.publishName);
287
+ console.log(` āœ… ${location.file} (${location.description})`);
288
+ } catch (error) {
289
+ console.log(
290
+ ` āš ļø ${location.file}: ${error instanceof Error ? error.message : String(error)}`,
291
+ );
292
+ }
293
+ }
294
+
295
+ // Scan for unmanaged occurrences
296
+ const unmanaged = await findUnmanagedOccurrences(targetDir, devcode);
297
+
298
+ if (unmanaged.length > 0) {
299
+ console.log(
300
+ `\nāš ļø Found ${unmanaged.length} unmanaged occurrence(s) of "${devcode}":`,
301
+ );
302
+ console.log(
303
+ ' These were NOT automatically replaced. Please review manually:',
304
+ );
305
+ for (const occurrence of unmanaged) {
306
+ console.log(` - ${occurrence.file}:${occurrence.line}`);
307
+ console.log(` ${occurrence.content}`);
308
+ }
309
+ }
310
+
311
+ console.log(`\nšŸŽ‰ Release preparation complete!`);
312
+ console.log(` Package renamed: ${devcode} → ${options.publishName}`);
313
+ console.log(` Private flag removed`);
314
+ console.log(` Workflows updated to use PAT_FOR_TAGPR`);
315
+
316
+ console.log(`\nāš ļø Action required: Set up PAT_FOR_TAGPR secret`);
317
+ console.log(` 1. Create a Personal Access Token (classic) at:`);
318
+ console.log(` https://github.com/settings/tokens/new`);
319
+ console.log(` 2. Required permissions:`);
320
+ console.log(` • repo (Full control of private repositories)`);
321
+ console.log(` - or for public repos: public_repo`);
322
+ console.log(` • workflow (Update GitHub Action workflows)`);
323
+ console.log(
324
+ ` 3. Add the token as a repository secret named PAT_FOR_TAGPR:`,
325
+ );
326
+ console.log(
327
+ ` Settings → Secrets and variables → Actions → New repository secret`,
328
+ );
329
+ }
330
+
331
+ export function registerPrepareReleaseCommand(program: Command): void {
332
+ program
333
+ .command('prepare-release <publish-name>')
334
+ .description(
335
+ 'Prepare a devcode project for release (auto-detects devcode from package.json)',
336
+ )
337
+ .option(
338
+ '-t, --target-dir <path>',
339
+ 'Target directory (defaults to current directory)',
340
+ )
341
+ .action(async (publishName: string, opts: { targetDir?: string }) => {
342
+ try {
343
+ await prepareRelease({
344
+ publishName,
345
+ targetDir: opts.targetDir,
346
+ });
347
+ } catch (error) {
348
+ console.error(
349
+ `āŒ Failed to prepare release: ${error instanceof Error ? error.message : String(error)}`,
350
+ );
351
+ process.exit(1);
352
+ }
353
+ });
354
+ }
package/src/config.ts ADDED
@@ -0,0 +1,13 @@
1
+ /**
2
+ * GitHub Actions configuration for generated workflows
3
+ * Key: action name, Value: fallback version when API fetch fails
4
+ */
5
+ export const GITHUB_ACTIONS = {
6
+ 'actions/checkout': 'v6',
7
+ 'Songmu/tagpr': 'v1',
8
+ 'oven-sh/setup-bun': 'v2',
9
+ 'github/codeql-action': 'v3',
10
+ } as const;
11
+
12
+ /** Default fallback version for unknown actions */
13
+ export const DEFAULT_ACTION_VERSION = 'v1';