@i-santos/create-package-starter 1.4.0 → 1.5.0-beta.2

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/README.md CHANGED
@@ -9,6 +9,8 @@ npx @i-santos/create-package-starter --name hello-package
9
9
  npx @i-santos/create-package-starter --name @i-santos/swarm --default-branch main
10
10
  npx @i-santos/create-package-starter init --dir ./existing-package
11
11
  npx @i-santos/create-package-starter setup-github --repo i-santos/firestack --dry-run
12
+ npx @i-santos/create-package-starter setup-beta --dir . --beta-branch release/beta
13
+ npx @i-santos/create-package-starter promote-stable --dir . --type patch --summary "Promote beta to stable"
12
14
  npx @i-santos/create-package-starter setup-npm --dir ./existing-package --publish-first
13
15
  ```
14
16
 
@@ -37,6 +39,25 @@ Configure GitHub repository settings:
37
39
  - `--ruleset <path>` (optional JSON override)
38
40
  - `--dry-run` (prints intended operations only)
39
41
 
42
+ Bootstrap beta release flow:
43
+
44
+ - `setup-beta`
45
+ - `--dir <directory>` (default: current directory)
46
+ - `--beta-branch <branch>` (default: `release/beta`)
47
+ - `--default-branch <branch>` (default: `main`)
48
+ - `--repo <owner/repo>` (optional; inferred from `remote.origin.url` when omitted)
49
+ - `--force` (overwrite managed scripts/workflow)
50
+ - `--dry-run` (prints intended operations only)
51
+ - `--yes` (skip interactive confirmations)
52
+
53
+ Prepare stable promotion from beta track:
54
+
55
+ - `promote-stable`
56
+ - `--dir <directory>` (default: current directory)
57
+ - `--type <patch|minor|major>` (default: `patch`)
58
+ - `--summary <text>` (default: `Promote beta track to stable release.`)
59
+ - `--dry-run` (prints intended operations only)
60
+
40
61
  Bootstrap npm publishing:
41
62
 
42
63
  - `setup-npm`
@@ -92,6 +113,28 @@ All commands print a deterministic summary with:
92
113
 
93
114
  If `gh` is missing or unauthenticated, command exits non-zero with actionable guidance.
94
115
 
116
+ ## setup-beta Behavior
117
+
118
+ `setup-beta` configures prerelease automation:
119
+
120
+ - adds beta scripts to `package.json`
121
+ - creates/preserves `.github/workflows/release.yml` with beta+stable branch triggers
122
+ - creates/preserves `.github/workflows/ci.yml` with beta+stable branch triggers
123
+ - ensures `release/beta` branch exists remotely (created from default branch if missing)
124
+ - applies beta branch protection ruleset on GitHub (including required CI matrix checks for Node 18 and 20)
125
+ - asks for confirmation before mutating repository settings and again before overwriting existing beta ruleset
126
+ - supports safe-merge by default and `--force` overwrite
127
+ - supports configurable beta branch (`release/beta` by default)
128
+
129
+ ## promote-stable Behavior
130
+
131
+ `promote-stable` prepares stable promotion from prerelease mode:
132
+
133
+ - validates `.changeset/pre.json` exists
134
+ - runs `changeset pre exit`
135
+ - creates a promotion changeset (`patch|minor|major`)
136
+ - prints next step guidance for opening beta->main PR
137
+
95
138
  ## setup-npm Behavior
96
139
 
97
140
  `setup-npm` validates npm publish readiness:
package/lib/run.js CHANGED
@@ -1,6 +1,7 @@
1
1
  const fs = require('fs');
2
2
  const path = require('path');
3
3
  const { spawnSync } = require('child_process');
4
+ const readline = require('readline/promises');
4
5
 
5
6
  const CHANGESETS_DEP = '@changesets/cli';
6
7
  const CHANGESETS_DEP_VERSION = '^2.29.7';
@@ -25,6 +26,8 @@ function usage() {
25
26
  ' create-package-starter --name <name> [--out <directory>] [--default-branch <branch>]',
26
27
  ' create-package-starter init [--dir <directory>] [--force] [--cleanup-legacy-release] [--scope <scope>] [--default-branch <branch>]',
27
28
  ' create-package-starter setup-github [--repo <owner/repo>] [--default-branch <branch>] [--ruleset <path>] [--dry-run]',
29
+ ' create-package-starter setup-beta [--dir <directory>] [--repo <owner/repo>] [--beta-branch <branch>] [--default-branch <branch>] [--force] [--dry-run] [--yes]',
30
+ ' create-package-starter promote-stable [--dir <directory>] [--type patch|minor|major] [--summary <text>] [--dry-run]',
28
31
  ' create-package-starter setup-npm [--dir <directory>] [--publish-first] [--dry-run]',
29
32
  '',
30
33
  'Examples:',
@@ -33,6 +36,8 @@ function usage() {
33
36
  ' create-package-starter init --dir ./my-package',
34
37
  ' create-package-starter init --cleanup-legacy-release',
35
38
  ' create-package-starter setup-github --repo i-santos/firestack --dry-run',
39
+ ' create-package-starter setup-beta --dir . --beta-branch release/beta',
40
+ ' create-package-starter promote-stable --dir . --type patch --summary "Promote beta to stable"',
36
41
  ' create-package-starter setup-npm --dir . --publish-first'
37
42
  ].join('\n');
38
43
  }
@@ -215,6 +220,118 @@ function parseSetupNpmArgs(argv) {
215
220
  return args;
216
221
  }
217
222
 
223
+ function parseSetupBetaArgs(argv) {
224
+ const args = {
225
+ dir: process.cwd(),
226
+ betaBranch: 'release/beta',
227
+ defaultBranch: DEFAULT_BASE_BRANCH,
228
+ force: false,
229
+ yes: false,
230
+ dryRun: false
231
+ };
232
+
233
+ for (let i = 0; i < argv.length; i += 1) {
234
+ const token = argv[i];
235
+
236
+ if (token === '--dir') {
237
+ args.dir = parseValueFlag(argv, i, '--dir');
238
+ i += 1;
239
+ continue;
240
+ }
241
+
242
+ if (token === '--repo') {
243
+ args.repo = parseValueFlag(argv, i, '--repo');
244
+ i += 1;
245
+ continue;
246
+ }
247
+
248
+ if (token === '--beta-branch') {
249
+ args.betaBranch = parseValueFlag(argv, i, '--beta-branch');
250
+ i += 1;
251
+ continue;
252
+ }
253
+
254
+ if (token === '--default-branch') {
255
+ args.defaultBranch = parseValueFlag(argv, i, '--default-branch');
256
+ i += 1;
257
+ continue;
258
+ }
259
+
260
+ if (token === '--force') {
261
+ args.force = true;
262
+ continue;
263
+ }
264
+
265
+ if (token === '--yes') {
266
+ args.yes = true;
267
+ continue;
268
+ }
269
+
270
+ if (token === '--dry-run') {
271
+ args.dryRun = true;
272
+ continue;
273
+ }
274
+
275
+ if (token === '--help' || token === '-h') {
276
+ args.help = true;
277
+ continue;
278
+ }
279
+
280
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
281
+ }
282
+
283
+ return args;
284
+ }
285
+
286
+ function parsePromoteStableArgs(argv) {
287
+ const args = {
288
+ dir: process.cwd(),
289
+ type: 'patch',
290
+ summary: 'Promote beta track to stable release.',
291
+ dryRun: false
292
+ };
293
+
294
+ for (let i = 0; i < argv.length; i += 1) {
295
+ const token = argv[i];
296
+
297
+ if (token === '--dir') {
298
+ args.dir = parseValueFlag(argv, i, '--dir');
299
+ i += 1;
300
+ continue;
301
+ }
302
+
303
+ if (token === '--type') {
304
+ args.type = parseValueFlag(argv, i, '--type');
305
+ i += 1;
306
+ continue;
307
+ }
308
+
309
+ if (token === '--summary') {
310
+ args.summary = parseValueFlag(argv, i, '--summary');
311
+ i += 1;
312
+ continue;
313
+ }
314
+
315
+ if (token === '--dry-run') {
316
+ args.dryRun = true;
317
+ continue;
318
+ }
319
+
320
+ if (token === '--help' || token === '-h') {
321
+ args.help = true;
322
+ continue;
323
+ }
324
+
325
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
326
+ }
327
+
328
+ if (!['patch', 'minor', 'major'].includes(args.type)) {
329
+ throw new Error(`Invalid --type value: ${args.type}. Expected patch, minor, or major.`);
330
+ }
331
+
332
+ return args;
333
+ }
334
+
218
335
  function parseArgs(argv) {
219
336
  if (argv[0] === 'init') {
220
337
  return {
@@ -237,6 +354,20 @@ function parseArgs(argv) {
237
354
  };
238
355
  }
239
356
 
357
+ if (argv[0] === 'setup-beta') {
358
+ return {
359
+ mode: 'setup-beta',
360
+ args: parseSetupBetaArgs(argv.slice(1))
361
+ };
362
+ }
363
+
364
+ if (argv[0] === 'promote-stable') {
365
+ return {
366
+ mode: 'promote-stable',
367
+ args: parsePromoteStableArgs(argv.slice(1))
368
+ };
369
+ }
370
+
240
371
  return {
241
372
  mode: 'create',
242
373
  args: parseCreateArgs(argv)
@@ -354,6 +485,38 @@ function printSummary(title, summary) {
354
485
  console.log(`warnings: ${list(summary.warnings)}`);
355
486
  }
356
487
 
488
+ function logStep(status, message) {
489
+ const labels = {
490
+ run: '[RUN ]',
491
+ ok: '[OK ]',
492
+ warn: '[WARN]',
493
+ err: '[ERR ]'
494
+ };
495
+ const prefix = labels[status] || '[INFO]';
496
+ const writer = status === 'err' ? console.error : console.log;
497
+ writer(`${prefix} ${message}`);
498
+ }
499
+
500
+ async function confirmOrThrow(questionText) {
501
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
502
+ throw new Error(`Confirmation required but no interactive terminal was detected. Re-run with --yes if you want to proceed non-interactively.`);
503
+ }
504
+
505
+ const rl = readline.createInterface({
506
+ input: process.stdin,
507
+ output: process.stdout
508
+ });
509
+
510
+ try {
511
+ const answer = await rl.question(`${questionText}\nType "yes" to continue: `);
512
+ if (answer.trim().toLowerCase() !== 'yes') {
513
+ throw new Error('Operation cancelled by user.');
514
+ }
515
+ } finally {
516
+ rl.close();
517
+ }
518
+ }
519
+
357
520
  function ensureFileFromTemplate(targetPath, templatePath, options) {
358
521
  const exists = fs.existsSync(targetPath);
359
522
 
@@ -374,6 +537,127 @@ function ensureFileFromTemplate(targetPath, templatePath, options) {
374
537
  return 'created';
375
538
  }
376
539
 
540
+ function ensureReleaseWorkflowBranches(content, defaultBranch, betaBranch) {
541
+ const lines = content.split('\n');
542
+ const onIndex = lines.findIndex((line) => line.trim() === 'on:');
543
+
544
+ if (onIndex < 0) {
545
+ return null;
546
+ }
547
+
548
+ let onSectionEnd = lines.length;
549
+ for (let i = onIndex + 1; i < lines.length; i += 1) {
550
+ const line = lines[i];
551
+ const isTopLevelKey = line && !line.startsWith(' ') && line.trim().endsWith(':');
552
+ if (isTopLevelKey) {
553
+ onSectionEnd = i;
554
+ break;
555
+ }
556
+ }
557
+
558
+ const onBlock = lines.slice(onIndex, onSectionEnd);
559
+ const pushRelativeIndex = onBlock.findIndex((line) => line.trim() === 'push:');
560
+ if (pushRelativeIndex < 0) {
561
+ return null;
562
+ }
563
+
564
+ const branchesRelativeIndex = onBlock.findIndex((line) => line.trim() === 'branches:');
565
+ if (branchesRelativeIndex < 0 || branchesRelativeIndex <= pushRelativeIndex) {
566
+ return null;
567
+ }
568
+
569
+ const listStart = branchesRelativeIndex + 1;
570
+ let listEnd = listStart;
571
+ while (listEnd < onBlock.length && onBlock[listEnd].trim().startsWith('- ')) {
572
+ listEnd += 1;
573
+ }
574
+
575
+ if (listEnd === listStart) {
576
+ return null;
577
+ }
578
+
579
+ const existingBranches = onBlock.slice(listStart, listEnd)
580
+ .map((line) => line.trim().replace(/^- /, '').trim())
581
+ .filter(Boolean);
582
+
583
+ const desiredBranches = [...new Set([defaultBranch, betaBranch])];
584
+ const mergedBranches = [...existingBranches];
585
+ for (const branch of desiredBranches) {
586
+ if (!mergedBranches.includes(branch)) {
587
+ mergedBranches.push(branch);
588
+ }
589
+ }
590
+
591
+ const changed = mergedBranches.length !== existingBranches.length
592
+ || mergedBranches.some((branch, index) => branch !== existingBranches[index]);
593
+
594
+ if (!changed) {
595
+ return {
596
+ changed: false,
597
+ content
598
+ };
599
+ }
600
+
601
+ const updatedOnBlock = [
602
+ ...onBlock.slice(0, listStart),
603
+ ...mergedBranches.map((branch) => ` - ${branch}`),
604
+ ...onBlock.slice(listEnd)
605
+ ];
606
+
607
+ const updatedLines = [
608
+ ...lines.slice(0, onIndex),
609
+ ...updatedOnBlock,
610
+ ...lines.slice(onSectionEnd)
611
+ ];
612
+
613
+ return {
614
+ changed: true,
615
+ content: updatedLines.join('\n')
616
+ };
617
+ }
618
+
619
+ function upsertReleaseWorkflow(targetPath, templatePath, options) {
620
+ const exists = fs.existsSync(targetPath);
621
+ if (!exists || options.force) {
622
+ if (options.dryRun) {
623
+ return {
624
+ result: exists ? 'overwritten' : 'created'
625
+ };
626
+ }
627
+
628
+ const result = ensureFileFromTemplate(targetPath, templatePath, {
629
+ force: options.force,
630
+ variables: options.variables
631
+ });
632
+ return { result };
633
+ }
634
+
635
+ const current = fs.readFileSync(targetPath, 'utf8');
636
+ const ensured = ensureReleaseWorkflowBranches(
637
+ current,
638
+ options.variables.DEFAULT_BRANCH,
639
+ options.variables.BETA_BRANCH
640
+ );
641
+
642
+ if (!ensured) {
643
+ return {
644
+ result: 'skipped',
645
+ warning: `Could not safely update trigger branches in ${path.basename(targetPath)}. Use --force to overwrite from template.`
646
+ };
647
+ }
648
+
649
+ if (!ensured.changed) {
650
+ return { result: 'skipped' };
651
+ }
652
+
653
+ if (options.dryRun) {
654
+ return { result: 'updated' };
655
+ }
656
+
657
+ fs.writeFileSync(targetPath, ensured.content);
658
+ return { result: 'updated' };
659
+ }
660
+
377
661
  function detectEquivalentManagedFile(packageDir, targetRelativePath) {
378
662
  if (targetRelativePath !== '.github/PULL_REQUEST_TEMPLATE.md') {
379
663
  return targetRelativePath;
@@ -456,7 +740,12 @@ function configureExistingPackage(packageDir, templateDir, options) {
456
740
  check: 'npm run test',
457
741
  changeset: 'changeset',
458
742
  'version-packages': 'changeset version',
459
- release: 'npm run check && changeset publish'
743
+ release: 'npm run check && changeset publish',
744
+ 'beta:enter': 'changeset pre enter beta',
745
+ 'beta:exit': 'changeset pre exit',
746
+ 'beta:version': 'changeset version',
747
+ 'beta:publish': 'changeset publish',
748
+ 'beta:promote': 'create-package-starter promote-stable --dir .'
460
749
  };
461
750
 
462
751
  let packageJsonChanged = false;
@@ -518,6 +807,7 @@ function configureExistingPackage(packageDir, templateDir, options) {
518
807
  variables: {
519
808
  PACKAGE_NAME: packageName,
520
809
  DEFAULT_BRANCH: options.defaultBranch,
810
+ BETA_BRANCH: options.betaBranch || 'release/beta',
521
811
  SCOPE: deriveScope(options.scope, packageName)
522
812
  }
523
813
  }, summary);
@@ -553,12 +843,14 @@ function createNewPackage(args) {
553
843
  const createdFiles = copyDirRecursive(templateDir, targetDir, {
554
844
  PACKAGE_NAME: args.name,
555
845
  DEFAULT_BRANCH: args.defaultBranch,
846
+ BETA_BRANCH: 'release/beta',
556
847
  SCOPE: deriveScope('', args.name)
557
848
  });
558
849
 
559
850
  summary.createdFiles.push(...createdFiles);
560
851
 
561
852
  summary.updatedScriptKeys.push('check', 'changeset', 'version-packages', 'release');
853
+ summary.updatedScriptKeys.push('beta:enter', 'beta:exit', 'beta:version', 'beta:publish', 'beta:promote');
562
854
  summary.updatedDependencyKeys.push(CHANGESETS_DEP);
563
855
 
564
856
  printSummary(`Package created in ${targetDir}`, summary);
@@ -638,6 +930,49 @@ function createBaseRulesetPayload(defaultBranch) {
638
930
  };
639
931
  }
640
932
 
933
+ function createBetaRulesetPayload(betaBranch) {
934
+ return {
935
+ name: `Beta branch protection (${betaBranch})`,
936
+ target: 'branch',
937
+ enforcement: 'active',
938
+ conditions: {
939
+ ref_name: {
940
+ include: [`refs/heads/${betaBranch}`],
941
+ exclude: []
942
+ }
943
+ },
944
+ bypass_actors: [],
945
+ rules: [
946
+ { type: 'deletion' },
947
+ { type: 'non_fast_forward' },
948
+ {
949
+ type: 'pull_request',
950
+ parameters: {
951
+ required_approving_review_count: 0,
952
+ dismiss_stale_reviews_on_push: true,
953
+ require_code_owner_review: false,
954
+ require_last_push_approval: false,
955
+ required_review_thread_resolution: true
956
+ }
957
+ },
958
+ {
959
+ type: 'required_status_checks',
960
+ parameters: {
961
+ strict_required_status_checks_policy: true,
962
+ required_status_checks: [
963
+ {
964
+ context: 'CI / check (18) (pull_request)'
965
+ },
966
+ {
967
+ context: 'CI / check (20) (pull_request)'
968
+ }
969
+ ]
970
+ }
971
+ }
972
+ ]
973
+ };
974
+ }
975
+
641
976
  function createRulesetPayload(args) {
642
977
  if (!args.ruleset) {
643
978
  return createBaseRulesetPayload(args.defaultBranch);
@@ -729,6 +1064,69 @@ function updateWorkflowPermissions(deps, repo) {
729
1064
  }
730
1065
  }
731
1066
 
1067
+ function isNotFoundResponse(result) {
1068
+ const output = `${result.stderr || ''}\n${result.stdout || ''}`.toLowerCase();
1069
+ return output.includes('404') || output.includes('not found');
1070
+ }
1071
+
1072
+ function ensureBranchExists(deps, repo, defaultBranch, targetBranch) {
1073
+ const encodedTarget = encodeURIComponent(targetBranch);
1074
+ const getTarget = ghApi(deps, 'GET', `/repos/${repo}/branches/${encodedTarget}`);
1075
+ if (getTarget.status === 0) {
1076
+ return 'exists';
1077
+ }
1078
+
1079
+ if (!isNotFoundResponse(getTarget)) {
1080
+ throw new Error(`Failed to check branch "${targetBranch}": ${getTarget.stderr || getTarget.stdout}`.trim());
1081
+ }
1082
+
1083
+ const encodedDefault = encodeURIComponent(defaultBranch);
1084
+ const getDefaultRef = ghApi(deps, 'GET', `/repos/${repo}/git/ref/heads/${encodedDefault}`);
1085
+ if (getDefaultRef.status !== 0) {
1086
+ throw new Error(`Failed to resolve default branch "${defaultBranch}": ${getDefaultRef.stderr || getDefaultRef.stdout}`.trim());
1087
+ }
1088
+
1089
+ const parsed = parseJsonOutput(getDefaultRef.stdout || '{}', 'Failed to parse default branch ref from GitHub API.');
1090
+ const sha = parsed && parsed.object && parsed.object.sha;
1091
+ if (!sha) {
1092
+ throw new Error(`Could not determine SHA for default branch "${defaultBranch}".`);
1093
+ }
1094
+
1095
+ const createRef = ghApi(deps, 'POST', `/repos/${repo}/git/refs`, {
1096
+ ref: `refs/heads/${targetBranch}`,
1097
+ sha
1098
+ });
1099
+ if (createRef.status !== 0) {
1100
+ throw new Error(`Failed to create branch "${targetBranch}": ${createRef.stderr || createRef.stdout}`.trim());
1101
+ }
1102
+
1103
+ return 'created';
1104
+ }
1105
+
1106
+ function branchExists(deps, repo, targetBranch) {
1107
+ const encodedTarget = encodeURIComponent(targetBranch);
1108
+ const getTarget = ghApi(deps, 'GET', `/repos/${repo}/branches/${encodedTarget}`);
1109
+ if (getTarget.status === 0) {
1110
+ return true;
1111
+ }
1112
+
1113
+ if (isNotFoundResponse(getTarget)) {
1114
+ return false;
1115
+ }
1116
+
1117
+ throw new Error(`Failed to check branch "${targetBranch}": ${getTarget.stderr || getTarget.stdout}`.trim());
1118
+ }
1119
+
1120
+ function findRulesetByName(deps, repo, name) {
1121
+ const listResult = ghApi(deps, 'GET', `/repos/${repo}/rulesets`);
1122
+ if (listResult.status !== 0) {
1123
+ throw new Error(`Failed to list rulesets: ${listResult.stderr || listResult.stdout}`.trim());
1124
+ }
1125
+
1126
+ const rulesets = parseJsonOutput(listResult.stdout || '[]', 'Failed to parse rulesets response from GitHub API.');
1127
+ return rulesets.find((ruleset) => ruleset.name === name) || null;
1128
+ }
1129
+
732
1130
  function ensureNpmAvailable(deps) {
733
1131
  const version = deps.exec('npm', ['--version']);
734
1132
  if (version.status !== 0) {
@@ -804,9 +1202,23 @@ function setupNpm(args, dependencies = {}) {
804
1202
  } else if (args.dryRun) {
805
1203
  summary.warnings.push(`dry-run: would run "npm publish --access public" in ${targetDir}`);
806
1204
  } else {
807
- const publish = deps.exec('npm', ['publish', '--access', 'public'], { cwd: targetDir });
1205
+ const publish = deps.exec('npm', ['publish', '--access', 'public'], { cwd: targetDir, stdio: 'inherit' });
808
1206
  if (publish.status !== 0) {
809
- throw new Error(`First publish failed: ${(publish.stderr || publish.stdout || '').trim()}`);
1207
+ const publishOutput = `${publish.stderr || ''}\n${publish.stdout || ''}`.toLowerCase();
1208
+ const isOtpError = publishOutput.includes('eotp') || publishOutput.includes('one-time password');
1209
+
1210
+ if (isOtpError) {
1211
+ throw new Error(
1212
+ [
1213
+ 'First publish failed due to npm 2FA/OTP requirements.',
1214
+ 'This command already delegates to the standard npm publish flow.',
1215
+ 'If npm still requires manual OTP entry, complete publish manually:',
1216
+ ` (cd ${targetDir} && npm publish --access public)`
1217
+ ].join('\n')
1218
+ );
1219
+ }
1220
+
1221
+ throw new Error('First publish failed. Check npm output above and try again.');
810
1222
  }
811
1223
  summary.updatedScriptKeys.push('npm.first_publish_done');
812
1224
  }
@@ -818,6 +1230,317 @@ function setupNpm(args, dependencies = {}) {
818
1230
  printSummary(`npm setup completed for ${packageJson.name}`, summary);
819
1231
  }
820
1232
 
1233
+ async function setupBeta(args, dependencies = {}) {
1234
+ const deps = {
1235
+ exec: dependencies.exec || execCommand
1236
+ };
1237
+
1238
+ const targetDir = path.resolve(args.dir);
1239
+ if (!fs.existsSync(targetDir)) {
1240
+ throw new Error(`Directory not found: ${targetDir}`);
1241
+ }
1242
+
1243
+ const packageJsonPath = path.join(targetDir, 'package.json');
1244
+ if (!fs.existsSync(packageJsonPath)) {
1245
+ throw new Error(`package.json not found in ${targetDir}`);
1246
+ }
1247
+
1248
+ const packageRoot = path.resolve(__dirname, '..');
1249
+ const templateDir = path.join(packageRoot, 'template');
1250
+ const packageJson = readJsonFile(packageJsonPath);
1251
+ packageJson.scripts = packageJson.scripts || {};
1252
+
1253
+ logStep('run', 'Checking GitHub CLI availability and authentication...');
1254
+ try {
1255
+ ensureGhAvailable(deps);
1256
+ logStep('ok', 'GitHub CLI is available and authenticated.');
1257
+ } catch (error) {
1258
+ logStep('err', error.message);
1259
+ if (error.message.includes('not authenticated')) {
1260
+ logStep('warn', 'Run "gh auth login" and retry.');
1261
+ }
1262
+ throw error;
1263
+ }
1264
+
1265
+ logStep('run', 'Resolving repository target...');
1266
+ const repo = resolveRepo(args, deps);
1267
+ logStep('ok', `Using repository ${repo}.`);
1268
+
1269
+ const summary = createSummary();
1270
+ summary.updatedScriptKeys.push('github.beta_branch', 'github.beta_ruleset', 'actions.default_workflow_permissions');
1271
+ const desiredScripts = {
1272
+ 'beta:enter': 'changeset pre enter beta',
1273
+ 'beta:exit': 'changeset pre exit',
1274
+ 'beta:version': 'changeset version',
1275
+ 'beta:publish': 'changeset publish',
1276
+ 'beta:promote': 'create-package-starter promote-stable --dir .'
1277
+ };
1278
+
1279
+ let packageJsonChanged = false;
1280
+ for (const [key, value] of Object.entries(desiredScripts)) {
1281
+ const exists = Object.prototype.hasOwnProperty.call(packageJson.scripts, key);
1282
+ if (!exists || args.force) {
1283
+ if (!exists || packageJson.scripts[key] !== value) {
1284
+ packageJson.scripts[key] = value;
1285
+ packageJsonChanged = true;
1286
+ }
1287
+ summary.updatedScriptKeys.push(key);
1288
+ } else {
1289
+ summary.skippedScriptKeys.push(key);
1290
+ }
1291
+ }
1292
+
1293
+ const workflowRelativePath = '.github/workflows/release.yml';
1294
+ const workflowTemplatePath = path.join(templateDir, workflowRelativePath);
1295
+ const workflowTargetPath = path.join(targetDir, workflowRelativePath);
1296
+ const ciWorkflowRelativePath = '.github/workflows/ci.yml';
1297
+ const ciWorkflowTemplatePath = path.join(templateDir, ciWorkflowRelativePath);
1298
+ const ciWorkflowTargetPath = path.join(targetDir, ciWorkflowRelativePath);
1299
+ if (!fs.existsSync(workflowTemplatePath)) {
1300
+ throw new Error(`Template not found: ${workflowTemplatePath}`);
1301
+ }
1302
+ if (!fs.existsSync(ciWorkflowTemplatePath)) {
1303
+ throw new Error(`Template not found: ${ciWorkflowTemplatePath}`);
1304
+ }
1305
+
1306
+ if (args.dryRun) {
1307
+ logStep('warn', 'Dry-run mode enabled. No remote or file changes will be applied.');
1308
+ const workflowPreview = upsertReleaseWorkflow(workflowTargetPath, workflowTemplatePath, {
1309
+ force: args.force,
1310
+ dryRun: true,
1311
+ variables: {
1312
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
1313
+ DEFAULT_BRANCH: args.defaultBranch,
1314
+ BETA_BRANCH: args.betaBranch,
1315
+ SCOPE: deriveScope('', packageJson.name || '')
1316
+ }
1317
+ });
1318
+ if (workflowPreview.result === 'created') {
1319
+ summary.warnings.push(`dry-run: would create ${workflowRelativePath}`);
1320
+ } else if (workflowPreview.result === 'overwritten') {
1321
+ summary.warnings.push(`dry-run: would overwrite ${workflowRelativePath}`);
1322
+ } else if (workflowPreview.result === 'updated') {
1323
+ summary.warnings.push(`dry-run: would update ${workflowRelativePath} trigger branches`);
1324
+ } else {
1325
+ summary.warnings.push(`dry-run: would keep existing ${workflowRelativePath}`);
1326
+ if (workflowPreview.warning) {
1327
+ summary.warnings.push(`dry-run: ${workflowPreview.warning}`);
1328
+ }
1329
+ }
1330
+ const ciWorkflowPreview = upsertReleaseWorkflow(ciWorkflowTargetPath, ciWorkflowTemplatePath, {
1331
+ force: args.force,
1332
+ dryRun: true,
1333
+ variables: {
1334
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
1335
+ DEFAULT_BRANCH: args.defaultBranch,
1336
+ BETA_BRANCH: args.betaBranch,
1337
+ SCOPE: deriveScope('', packageJson.name || '')
1338
+ }
1339
+ });
1340
+ if (ciWorkflowPreview.result === 'created') {
1341
+ summary.warnings.push(`dry-run: would create ${ciWorkflowRelativePath}`);
1342
+ } else if (ciWorkflowPreview.result === 'overwritten') {
1343
+ summary.warnings.push(`dry-run: would overwrite ${ciWorkflowRelativePath}`);
1344
+ } else if (ciWorkflowPreview.result === 'updated') {
1345
+ summary.warnings.push(`dry-run: would update ${ciWorkflowRelativePath} trigger branches`);
1346
+ } else {
1347
+ summary.warnings.push(`dry-run: would keep existing ${ciWorkflowRelativePath}`);
1348
+ if (ciWorkflowPreview.warning) {
1349
+ summary.warnings.push(`dry-run: ${ciWorkflowPreview.warning}`);
1350
+ }
1351
+ }
1352
+ if (packageJsonChanged) {
1353
+ summary.warnings.push('dry-run: would update package.json beta scripts');
1354
+ }
1355
+ summary.warnings.push(`dry-run: would ensure branch "${args.betaBranch}" exists in ${repo}`);
1356
+ summary.warnings.push(`dry-run: would upsert ruleset for refs/heads/${args.betaBranch}`);
1357
+ summary.warnings.push(`dry-run: would set Actions workflow permissions to write for ${repo}`);
1358
+ summary.warnings.push(`dry-run: beta branch configured as ${args.betaBranch}`);
1359
+ } else {
1360
+ const betaRulesetPayload = createBetaRulesetPayload(args.betaBranch);
1361
+ const doesBranchExist = branchExists(deps, repo, args.betaBranch);
1362
+ const existingRuleset = findRulesetByName(deps, repo, betaRulesetPayload.name);
1363
+
1364
+ if (args.yes) {
1365
+ logStep('warn', 'Confirmation prompts skipped due to --yes.');
1366
+ } else {
1367
+ await confirmOrThrow(
1368
+ [
1369
+ `This will modify GitHub repository settings for ${repo}:`,
1370
+ `- set Actions workflow permissions to write`,
1371
+ `- ensure branch "${args.betaBranch}" exists${doesBranchExist ? ' (already exists)' : ' (will be created)'}`,
1372
+ `- apply branch protection ruleset "${betaRulesetPayload.name}"`,
1373
+ '- require CI status checks "CI / check (18) (pull_request)" and "CI / check (20) (pull_request)" on beta branch',
1374
+ `- update local ${workflowRelativePath} and package.json beta scripts`
1375
+ ].join('\n')
1376
+ );
1377
+
1378
+ if (existingRuleset) {
1379
+ await confirmOrThrow(
1380
+ `Ruleset "${betaRulesetPayload.name}" already exists and will be overwritten.`
1381
+ );
1382
+ }
1383
+ }
1384
+
1385
+ logStep('run', `Ensuring ${workflowRelativePath} includes stable+beta triggers...`);
1386
+ const workflowUpsert = upsertReleaseWorkflow(workflowTargetPath, workflowTemplatePath, {
1387
+ force: args.force,
1388
+ dryRun: false,
1389
+ variables: {
1390
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
1391
+ DEFAULT_BRANCH: args.defaultBranch,
1392
+ BETA_BRANCH: args.betaBranch,
1393
+ SCOPE: deriveScope('', packageJson.name || '')
1394
+ }
1395
+ });
1396
+ const workflowResult = workflowUpsert.result;
1397
+
1398
+ if (workflowResult === 'created') {
1399
+ summary.createdFiles.push(workflowRelativePath);
1400
+ logStep('ok', `${workflowRelativePath} created.`);
1401
+ } else if (workflowResult === 'overwritten') {
1402
+ summary.overwrittenFiles.push(workflowRelativePath);
1403
+ logStep('ok', `${workflowRelativePath} overwritten.`);
1404
+ } else if (workflowResult === 'updated') {
1405
+ summary.overwrittenFiles.push(workflowRelativePath);
1406
+ logStep('ok', `${workflowRelativePath} updated with missing branch triggers.`);
1407
+ } else {
1408
+ summary.skippedFiles.push(workflowRelativePath);
1409
+ if (workflowUpsert.warning) {
1410
+ summary.warnings.push(workflowUpsert.warning);
1411
+ logStep('warn', workflowUpsert.warning);
1412
+ } else {
1413
+ logStep('warn', `${workflowRelativePath} already configured; kept as-is.`);
1414
+ }
1415
+ }
1416
+
1417
+ logStep('run', `Ensuring ${ciWorkflowRelativePath} includes stable+beta triggers...`);
1418
+ const ciWorkflowUpsert = upsertReleaseWorkflow(ciWorkflowTargetPath, ciWorkflowTemplatePath, {
1419
+ force: args.force,
1420
+ dryRun: false,
1421
+ variables: {
1422
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
1423
+ DEFAULT_BRANCH: args.defaultBranch,
1424
+ BETA_BRANCH: args.betaBranch,
1425
+ SCOPE: deriveScope('', packageJson.name || '')
1426
+ }
1427
+ });
1428
+ const ciWorkflowResult = ciWorkflowUpsert.result;
1429
+ if (ciWorkflowResult === 'created') {
1430
+ summary.createdFiles.push(ciWorkflowRelativePath);
1431
+ logStep('ok', `${ciWorkflowRelativePath} created.`);
1432
+ } else if (ciWorkflowResult === 'overwritten') {
1433
+ summary.overwrittenFiles.push(ciWorkflowRelativePath);
1434
+ logStep('ok', `${ciWorkflowRelativePath} overwritten.`);
1435
+ } else if (ciWorkflowResult === 'updated') {
1436
+ summary.overwrittenFiles.push(ciWorkflowRelativePath);
1437
+ logStep('ok', `${ciWorkflowRelativePath} updated with missing branch triggers.`);
1438
+ } else {
1439
+ summary.skippedFiles.push(ciWorkflowRelativePath);
1440
+ if (ciWorkflowUpsert.warning) {
1441
+ summary.warnings.push(ciWorkflowUpsert.warning);
1442
+ logStep('warn', ciWorkflowUpsert.warning);
1443
+ } else {
1444
+ logStep('warn', `${ciWorkflowRelativePath} already configured; kept as-is.`);
1445
+ }
1446
+ }
1447
+
1448
+ if (packageJsonChanged) {
1449
+ logStep('run', 'Updating package.json beta scripts...');
1450
+ writeJsonFile(packageJsonPath, packageJson);
1451
+ logStep('ok', 'package.json beta scripts updated.');
1452
+ } else {
1453
+ logStep('warn', 'package.json beta scripts already present; no changes needed.');
1454
+ }
1455
+
1456
+ logStep('run', 'Applying GitHub Actions workflow permissions...');
1457
+ updateWorkflowPermissions(deps, repo);
1458
+ logStep('ok', 'Workflow permissions configured.');
1459
+
1460
+ logStep('run', `Ensuring branch "${args.betaBranch}" exists...`);
1461
+ const branchResult = ensureBranchExists(deps, repo, args.defaultBranch, args.betaBranch);
1462
+ if (branchResult === 'created') {
1463
+ summary.createdFiles.push(`github-branch:${args.betaBranch}`);
1464
+ logStep('ok', `Branch "${args.betaBranch}" created from "${args.defaultBranch}".`);
1465
+ } else {
1466
+ summary.skippedFiles.push(`github-branch:${args.betaBranch}`);
1467
+ logStep('warn', `Branch "${args.betaBranch}" already exists.`);
1468
+ }
1469
+
1470
+ logStep('run', `Applying protection ruleset to "${args.betaBranch}"...`);
1471
+ const upsertResult = upsertRuleset(deps, repo, betaRulesetPayload);
1472
+ summary.overwrittenFiles.push(`github-beta-ruleset:${upsertResult}`);
1473
+ logStep('ok', `Beta branch ruleset ${upsertResult}.`);
1474
+ }
1475
+
1476
+ summary.warnings.push(`Trusted Publisher supports a single workflow file per package. Keep publishing on .github/workflows/release.yml for both stable and beta.`);
1477
+ summary.warnings.push(`Next step: run "npm run beta:enter" once on "${args.betaBranch}", commit .changeset/pre.json, and push.`);
1478
+ printSummary(`beta setup completed for ${targetDir}`, summary);
1479
+ }
1480
+
1481
+ function createChangesetFile(targetDir, packageName, bumpType, summaryText) {
1482
+ const changesetDir = path.join(targetDir, '.changeset');
1483
+ fs.mkdirSync(changesetDir, { recursive: true });
1484
+ const fileName = `promote-stable-${Date.now()}.md`;
1485
+ const filePath = path.join(changesetDir, fileName);
1486
+ const content = [
1487
+ '---',
1488
+ `"${packageName}": ${bumpType}`,
1489
+ '---',
1490
+ '',
1491
+ summaryText
1492
+ ].join('\n');
1493
+ fs.writeFileSync(filePath, `${content}\n`);
1494
+ return path.posix.join('.changeset', fileName);
1495
+ }
1496
+
1497
+ function promoteStable(args, dependencies = {}) {
1498
+ const deps = {
1499
+ exec: dependencies.exec || execCommand
1500
+ };
1501
+
1502
+ const targetDir = path.resolve(args.dir);
1503
+ if (!fs.existsSync(targetDir)) {
1504
+ throw new Error(`Directory not found: ${targetDir}`);
1505
+ }
1506
+
1507
+ const packageJsonPath = path.join(targetDir, 'package.json');
1508
+ if (!fs.existsSync(packageJsonPath)) {
1509
+ throw new Error(`package.json not found in ${targetDir}`);
1510
+ }
1511
+
1512
+ const prePath = path.join(targetDir, '.changeset', 'pre.json');
1513
+ if (!fs.existsSync(prePath)) {
1514
+ throw new Error(`No prerelease state found in ${targetDir}. Run "changeset pre enter beta" first.`);
1515
+ }
1516
+
1517
+ const packageJson = readJsonFile(packageJsonPath);
1518
+ if (!packageJson.name) {
1519
+ throw new Error(`package.json in ${targetDir} must define "name".`);
1520
+ }
1521
+
1522
+ const summary = createSummary();
1523
+ summary.updatedScriptKeys.push('changeset.pre_exit', 'changeset.promote_stable');
1524
+
1525
+ if (args.dryRun) {
1526
+ summary.warnings.push(`dry-run: would run "npx @changesets/cli pre exit" in ${targetDir}`);
1527
+ summary.warnings.push(`dry-run: would create promotion changeset for ${packageJson.name} (${args.type})`);
1528
+ summary.warnings.push(`dry-run: promote flow targets stable branch ${DEFAULT_BASE_BRANCH}`);
1529
+ printSummary(`stable promotion dry-run for ${targetDir}`, summary);
1530
+ return;
1531
+ }
1532
+
1533
+ const preExit = deps.exec('npx', ['@changesets/cli', 'pre', 'exit'], { cwd: targetDir });
1534
+ if (preExit.status !== 0) {
1535
+ throw new Error(`Failed to exit prerelease mode: ${(preExit.stderr || preExit.stdout || '').trim()}`);
1536
+ }
1537
+
1538
+ const createdChangeset = createChangesetFile(targetDir, packageJson.name, args.type, args.summary);
1539
+ summary.createdFiles.push(createdChangeset);
1540
+ summary.warnings.push('Next step: open PR from beta branch to main and merge to publish stable.');
1541
+ printSummary(`stable promotion prepared for ${targetDir}`, summary);
1542
+ }
1543
+
821
1544
  function setupGithub(args, dependencies = {}) {
822
1545
  const deps = {
823
1546
  exec: dependencies.exec || execCommand
@@ -885,6 +1608,16 @@ async function run(argv, dependencies = {}) {
885
1608
  return;
886
1609
  }
887
1610
 
1611
+ if (parsed.mode === 'setup-beta') {
1612
+ setupBeta(parsed.args, dependencies);
1613
+ return;
1614
+ }
1615
+
1616
+ if (parsed.mode === 'promote-stable') {
1617
+ promoteStable(parsed.args, dependencies);
1618
+ return;
1619
+ }
1620
+
888
1621
  if (parsed.mode === 'setup-npm') {
889
1622
  setupNpm(parsed.args, dependencies);
890
1623
  return;
@@ -897,6 +1630,9 @@ module.exports = {
897
1630
  run,
898
1631
  parseRepoFromRemote,
899
1632
  createBaseRulesetPayload,
1633
+ createBetaRulesetPayload,
900
1634
  setupGithub,
901
- setupNpm
1635
+ setupNpm,
1636
+ setupBeta,
1637
+ promoteStable
902
1638
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i-santos/create-package-starter",
3
- "version": "1.4.0",
3
+ "version": "1.5.0-beta.2",
4
4
  "description": "Scaffold new npm packages with a standardized Changesets release workflow",
5
5
  "license": "MIT",
6
6
  "author": "Igor Santos",
@@ -5,6 +5,7 @@ on:
5
5
  push:
6
6
  branches:
7
7
  - __DEFAULT_BRANCH__
8
+ - __BETA_BRANCH__
8
9
 
9
10
  jobs:
10
11
  check:
@@ -4,6 +4,7 @@ on:
4
4
  push:
5
5
  branches:
6
6
  - __DEFAULT_BRANCH__
7
+ - __BETA_BRANCH__
7
8
 
8
9
  permissions:
9
10
  contents: write
@@ -12,6 +12,14 @@
12
12
  3. `.github/workflows/release.yml` opens/updates `chore: release packages`.
13
13
  4. Merge the release PR to publish.
14
14
 
15
+ ## Beta process
16
+
17
+ 1. Use branch `__BETA_BRANCH__` for prereleases.
18
+ 2. Run `npm run beta:enter` once on `__BETA_BRANCH__`.
19
+ 3. Publish beta versions via `.github/workflows/release.yml` on `__BETA_BRANCH__`.
20
+ 4. Run `npm run beta:promote` to exit prerelease mode and create stable promotion changeset.
21
+ 5. Open PR from `__BETA_BRANCH__` to `__DEFAULT_BRANCH__`.
22
+
15
23
  ## Trusted Publishing
16
24
 
17
25
  If the package does not exist on npm yet, the first publish can be manual:
@@ -8,6 +8,10 @@ Package created by `@i-santos/create-package-starter`.
8
8
  - `npm run changeset`
9
9
  - `npm run version-packages`
10
10
  - `npm run release`
11
+ - `npm run beta:enter`
12
+ - `npm run beta:exit`
13
+ - `npm run beta:publish`
14
+ - `npm run beta:promote`
11
15
 
12
16
  ## Release flow
13
17
 
@@ -16,6 +20,13 @@ Package created by `@i-santos/create-package-starter`.
16
20
  3. `.github/workflows/release.yml` creates or updates `chore: release packages`.
17
21
  4. Merge the release PR to publish.
18
22
 
23
+ ## Beta release flow
24
+
25
+ 1. Create `__BETA_BRANCH__` from `__DEFAULT_BRANCH__`.
26
+ 2. Run `npm run beta:enter` once on `__BETA_BRANCH__`.
27
+ 3. Push updates to `__BETA_BRANCH__` and let `.github/workflows/release.yml` publish beta versions.
28
+ 4. When ready for stable, run `npm run beta:promote`, open PR from `__BETA_BRANCH__` to `__DEFAULT_BRANCH__`, and merge.
29
+
19
30
  ## Trusted Publishing
20
31
 
21
32
  If this package does not exist on npm yet, first publish can be manual:
@@ -6,7 +6,12 @@
6
6
  "check": "node scripts/check.js",
7
7
  "changeset": "changeset",
8
8
  "version-packages": "changeset version",
9
- "release": "npm run check && changeset publish"
9
+ "release": "npm run check && changeset publish",
10
+ "beta:enter": "changeset pre enter beta",
11
+ "beta:exit": "changeset pre exit",
12
+ "beta:version": "changeset version",
13
+ "beta:publish": "changeset publish",
14
+ "beta:promote": "create-package-starter promote-stable --dir ."
10
15
  },
11
16
  "devDependencies": {
12
17
  "@changesets/cli": "^2.29.7"