@i-santos/create-package-starter 1.3.0 → 1.5.0-beta.1

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,9 @@ 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"
14
+ npx @i-santos/create-package-starter setup-npm --dir ./existing-package --publish-first
12
15
  ```
13
16
 
14
17
  ## Commands
@@ -36,6 +39,32 @@ Configure GitHub repository settings:
36
39
  - `--ruleset <path>` (optional JSON override)
37
40
  - `--dry-run` (prints intended operations only)
38
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
+
61
+ Bootstrap npm publishing:
62
+
63
+ - `setup-npm`
64
+ - `--dir <directory>` (default: current directory)
65
+ - `--publish-first` (run `npm publish --access public` only when package is not found on npm)
66
+ - `--dry-run` (prints intended operations only)
67
+
39
68
  ## Managed Standards
40
69
 
41
70
  The generated and managed baseline includes:
@@ -84,6 +113,39 @@ All commands print a deterministic summary with:
84
113
 
85
114
  If `gh` is missing or unauthenticated, command exits non-zero with actionable guidance.
86
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
+ - ensures `release/beta` branch exists remotely (created from default branch if missing)
123
+ - applies beta branch protection ruleset on GitHub
124
+ - asks for confirmation before mutating repository settings and again before overwriting existing beta ruleset
125
+ - supports safe-merge by default and `--force` overwrite
126
+ - supports configurable beta branch (`release/beta` by default)
127
+
128
+ ## promote-stable Behavior
129
+
130
+ `promote-stable` prepares stable promotion from prerelease mode:
131
+
132
+ - validates `.changeset/pre.json` exists
133
+ - runs `changeset pre exit`
134
+ - creates a promotion changeset (`patch|minor|major`)
135
+ - prints next step guidance for opening beta->main PR
136
+
137
+ ## setup-npm Behavior
138
+
139
+ `setup-npm` validates npm publish readiness:
140
+
141
+ - checks npm CLI availability
142
+ - checks npm authentication (`npm whoami`)
143
+ - checks whether package already exists on npm
144
+ - optionally performs first publish (`--publish-first`)
145
+ - prints next steps for Trusted Publisher configuration
146
+
147
+ Important: Trusted Publisher still needs manual setup in npm package settings.
148
+
87
149
  ## Trusted Publishing Note
88
150
 
89
151
  If package does not exist on npm yet, first publish may be manual:
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,13 +26,19 @@ 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]',
31
+ ' create-package-starter setup-npm [--dir <directory>] [--publish-first] [--dry-run]',
28
32
  '',
29
33
  'Examples:',
30
34
  ' create-package-starter --name hello-package',
31
35
  ' create-package-starter --name @i-santos/swarm --out ./packages',
32
36
  ' create-package-starter init --dir ./my-package',
33
37
  ' create-package-starter init --cleanup-legacy-release',
34
- ' create-package-starter setup-github --repo i-santos/firestack --dry-run'
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"',
41
+ ' create-package-starter setup-npm --dir . --publish-first'
35
42
  ].join('\n');
36
43
  }
37
44
 
@@ -100,6 +107,12 @@ function parseInitArgs(argv) {
100
107
  continue;
101
108
  }
102
109
 
110
+ if (token === '--repo') {
111
+ args.repo = parseValueFlag(argv, i, '--repo');
112
+ i += 1;
113
+ continue;
114
+ }
115
+
103
116
  if (token === '--scope') {
104
117
  args.scope = parseValueFlag(argv, i, '--scope');
105
118
  i += 1;
@@ -176,6 +189,155 @@ function parseSetupGithubArgs(argv) {
176
189
  return args;
177
190
  }
178
191
 
192
+ function parseSetupNpmArgs(argv) {
193
+ const args = {
194
+ dir: process.cwd(),
195
+ publishFirst: false,
196
+ dryRun: false
197
+ };
198
+
199
+ for (let i = 0; i < argv.length; i += 1) {
200
+ const token = argv[i];
201
+
202
+ if (token === '--dir') {
203
+ args.dir = parseValueFlag(argv, i, '--dir');
204
+ i += 1;
205
+ continue;
206
+ }
207
+
208
+ if (token === '--publish-first') {
209
+ args.publishFirst = true;
210
+ continue;
211
+ }
212
+
213
+ if (token === '--dry-run') {
214
+ args.dryRun = true;
215
+ continue;
216
+ }
217
+
218
+ if (token === '--help' || token === '-h') {
219
+ args.help = true;
220
+ continue;
221
+ }
222
+
223
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
224
+ }
225
+
226
+ return args;
227
+ }
228
+
229
+ function parseSetupBetaArgs(argv) {
230
+ const args = {
231
+ dir: process.cwd(),
232
+ betaBranch: 'release/beta',
233
+ defaultBranch: DEFAULT_BASE_BRANCH,
234
+ force: false,
235
+ yes: false,
236
+ dryRun: false
237
+ };
238
+
239
+ for (let i = 0; i < argv.length; i += 1) {
240
+ const token = argv[i];
241
+
242
+ if (token === '--dir') {
243
+ args.dir = parseValueFlag(argv, i, '--dir');
244
+ i += 1;
245
+ continue;
246
+ }
247
+
248
+ if (token === '--repo') {
249
+ args.repo = parseValueFlag(argv, i, '--repo');
250
+ i += 1;
251
+ continue;
252
+ }
253
+
254
+ if (token === '--beta-branch') {
255
+ args.betaBranch = parseValueFlag(argv, i, '--beta-branch');
256
+ i += 1;
257
+ continue;
258
+ }
259
+
260
+ if (token === '--default-branch') {
261
+ args.defaultBranch = parseValueFlag(argv, i, '--default-branch');
262
+ i += 1;
263
+ continue;
264
+ }
265
+
266
+ if (token === '--force') {
267
+ args.force = true;
268
+ continue;
269
+ }
270
+
271
+ if (token === '--yes') {
272
+ args.yes = true;
273
+ continue;
274
+ }
275
+
276
+ if (token === '--dry-run') {
277
+ args.dryRun = true;
278
+ continue;
279
+ }
280
+
281
+ if (token === '--help' || token === '-h') {
282
+ args.help = true;
283
+ continue;
284
+ }
285
+
286
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
287
+ }
288
+
289
+ return args;
290
+ }
291
+
292
+ function parsePromoteStableArgs(argv) {
293
+ const args = {
294
+ dir: process.cwd(),
295
+ type: 'patch',
296
+ summary: 'Promote beta track to stable release.',
297
+ dryRun: false
298
+ };
299
+
300
+ for (let i = 0; i < argv.length; i += 1) {
301
+ const token = argv[i];
302
+
303
+ if (token === '--dir') {
304
+ args.dir = parseValueFlag(argv, i, '--dir');
305
+ i += 1;
306
+ continue;
307
+ }
308
+
309
+ if (token === '--type') {
310
+ args.type = parseValueFlag(argv, i, '--type');
311
+ i += 1;
312
+ continue;
313
+ }
314
+
315
+ if (token === '--summary') {
316
+ args.summary = parseValueFlag(argv, i, '--summary');
317
+ i += 1;
318
+ continue;
319
+ }
320
+
321
+ if (token === '--dry-run') {
322
+ args.dryRun = true;
323
+ continue;
324
+ }
325
+
326
+ if (token === '--help' || token === '-h') {
327
+ args.help = true;
328
+ continue;
329
+ }
330
+
331
+ throw new Error(`Invalid argument: ${token}\n\n${usage()}`);
332
+ }
333
+
334
+ if (!['patch', 'minor', 'major'].includes(args.type)) {
335
+ throw new Error(`Invalid --type value: ${args.type}. Expected patch, minor, or major.`);
336
+ }
337
+
338
+ return args;
339
+ }
340
+
179
341
  function parseArgs(argv) {
180
342
  if (argv[0] === 'init') {
181
343
  return {
@@ -191,6 +353,27 @@ function parseArgs(argv) {
191
353
  };
192
354
  }
193
355
 
356
+ if (argv[0] === 'setup-npm') {
357
+ return {
358
+ mode: 'setup-npm',
359
+ args: parseSetupNpmArgs(argv.slice(1))
360
+ };
361
+ }
362
+
363
+ if (argv[0] === 'setup-beta') {
364
+ return {
365
+ mode: 'setup-beta',
366
+ args: parseSetupBetaArgs(argv.slice(1))
367
+ };
368
+ }
369
+
370
+ if (argv[0] === 'promote-stable') {
371
+ return {
372
+ mode: 'promote-stable',
373
+ args: parsePromoteStableArgs(argv.slice(1))
374
+ };
375
+ }
376
+
194
377
  return {
195
378
  mode: 'create',
196
379
  args: parseCreateArgs(argv)
@@ -308,6 +491,38 @@ function printSummary(title, summary) {
308
491
  console.log(`warnings: ${list(summary.warnings)}`);
309
492
  }
310
493
 
494
+ function logStep(status, message) {
495
+ const labels = {
496
+ run: '[RUN ]',
497
+ ok: '[OK ]',
498
+ warn: '[WARN]',
499
+ err: '[ERR ]'
500
+ };
501
+ const prefix = labels[status] || '[INFO]';
502
+ const writer = status === 'err' ? console.error : console.log;
503
+ writer(`${prefix} ${message}`);
504
+ }
505
+
506
+ async function confirmOrThrow(questionText) {
507
+ if (!process.stdin.isTTY || !process.stdout.isTTY) {
508
+ throw new Error(`Confirmation required but no interactive terminal was detected. Re-run with --yes if you want to proceed non-interactively.`);
509
+ }
510
+
511
+ const rl = readline.createInterface({
512
+ input: process.stdin,
513
+ output: process.stdout
514
+ });
515
+
516
+ try {
517
+ const answer = await rl.question(`${questionText}\nType "yes" to continue: `);
518
+ if (answer.trim().toLowerCase() !== 'yes') {
519
+ throw new Error('Operation cancelled by user.');
520
+ }
521
+ } finally {
522
+ rl.close();
523
+ }
524
+ }
525
+
311
526
  function ensureFileFromTemplate(targetPath, templatePath, options) {
312
527
  const exists = fs.existsSync(targetPath);
313
528
 
@@ -328,6 +543,118 @@ function ensureFileFromTemplate(targetPath, templatePath, options) {
328
543
  return 'created';
329
544
  }
330
545
 
546
+ function ensureReleaseWorkflowBranches(content, defaultBranch, betaBranch) {
547
+ const lines = content.split('\n');
548
+ const onIndex = lines.findIndex((line) => line.trim() === 'on:');
549
+ const permissionsIndex = lines.findIndex((line) => line.trim() === 'permissions:');
550
+
551
+ if (onIndex < 0 || permissionsIndex < 0 || permissionsIndex <= onIndex) {
552
+ return null;
553
+ }
554
+
555
+ const onBlock = lines.slice(onIndex, permissionsIndex);
556
+ const pushRelativeIndex = onBlock.findIndex((line) => line.trim() === 'push:');
557
+ if (pushRelativeIndex < 0) {
558
+ return null;
559
+ }
560
+
561
+ const branchesRelativeIndex = onBlock.findIndex((line) => line.trim() === 'branches:');
562
+ if (branchesRelativeIndex < 0 || branchesRelativeIndex <= pushRelativeIndex) {
563
+ return null;
564
+ }
565
+
566
+ const listStart = branchesRelativeIndex + 1;
567
+ let listEnd = listStart;
568
+ while (listEnd < onBlock.length && onBlock[listEnd].trim().startsWith('- ')) {
569
+ listEnd += 1;
570
+ }
571
+
572
+ if (listEnd === listStart) {
573
+ return null;
574
+ }
575
+
576
+ const existingBranches = onBlock.slice(listStart, listEnd)
577
+ .map((line) => line.trim().replace(/^- /, '').trim())
578
+ .filter(Boolean);
579
+
580
+ const desiredBranches = [...new Set([defaultBranch, betaBranch])];
581
+ const mergedBranches = [...existingBranches];
582
+ for (const branch of desiredBranches) {
583
+ if (!mergedBranches.includes(branch)) {
584
+ mergedBranches.push(branch);
585
+ }
586
+ }
587
+
588
+ const changed = mergedBranches.length !== existingBranches.length
589
+ || mergedBranches.some((branch, index) => branch !== existingBranches[index]);
590
+
591
+ if (!changed) {
592
+ return {
593
+ changed: false,
594
+ content
595
+ };
596
+ }
597
+
598
+ const updatedOnBlock = [
599
+ ...onBlock.slice(0, listStart),
600
+ ...mergedBranches.map((branch) => ` - ${branch}`),
601
+ ...onBlock.slice(listEnd)
602
+ ];
603
+
604
+ const updatedLines = [
605
+ ...lines.slice(0, onIndex),
606
+ ...updatedOnBlock,
607
+ ...lines.slice(permissionsIndex)
608
+ ];
609
+
610
+ return {
611
+ changed: true,
612
+ content: updatedLines.join('\n')
613
+ };
614
+ }
615
+
616
+ function upsertReleaseWorkflow(targetPath, templatePath, options) {
617
+ const exists = fs.existsSync(targetPath);
618
+ if (!exists || options.force) {
619
+ if (options.dryRun) {
620
+ return {
621
+ result: exists ? 'overwritten' : 'created'
622
+ };
623
+ }
624
+
625
+ const result = ensureFileFromTemplate(targetPath, templatePath, {
626
+ force: options.force,
627
+ variables: options.variables
628
+ });
629
+ return { result };
630
+ }
631
+
632
+ const current = fs.readFileSync(targetPath, 'utf8');
633
+ const ensured = ensureReleaseWorkflowBranches(
634
+ current,
635
+ options.variables.DEFAULT_BRANCH,
636
+ options.variables.BETA_BRANCH
637
+ );
638
+
639
+ if (!ensured) {
640
+ return {
641
+ result: 'skipped',
642
+ warning: `Could not safely update trigger branches in ${path.basename(targetPath)}. Use --force to overwrite from template.`
643
+ };
644
+ }
645
+
646
+ if (!ensured.changed) {
647
+ return { result: 'skipped' };
648
+ }
649
+
650
+ if (options.dryRun) {
651
+ return { result: 'updated' };
652
+ }
653
+
654
+ fs.writeFileSync(targetPath, ensured.content);
655
+ return { result: 'updated' };
656
+ }
657
+
331
658
  function detectEquivalentManagedFile(packageDir, targetRelativePath) {
332
659
  if (targetRelativePath !== '.github/PULL_REQUEST_TEMPLATE.md') {
333
660
  return targetRelativePath;
@@ -410,7 +737,12 @@ function configureExistingPackage(packageDir, templateDir, options) {
410
737
  check: 'npm run test',
411
738
  changeset: 'changeset',
412
739
  'version-packages': 'changeset version',
413
- release: 'npm run check && changeset publish'
740
+ release: 'npm run check && changeset publish',
741
+ 'beta:enter': 'changeset pre enter beta',
742
+ 'beta:exit': 'changeset pre exit',
743
+ 'beta:version': 'changeset version',
744
+ 'beta:publish': 'changeset publish',
745
+ 'beta:promote': 'create-package-starter promote-stable --dir .'
414
746
  };
415
747
 
416
748
  let packageJsonChanged = false;
@@ -472,6 +804,7 @@ function configureExistingPackage(packageDir, templateDir, options) {
472
804
  variables: {
473
805
  PACKAGE_NAME: packageName,
474
806
  DEFAULT_BRANCH: options.defaultBranch,
807
+ BETA_BRANCH: options.betaBranch || 'release/beta',
475
808
  SCOPE: deriveScope(options.scope, packageName)
476
809
  }
477
810
  }, summary);
@@ -507,12 +840,14 @@ function createNewPackage(args) {
507
840
  const createdFiles = copyDirRecursive(templateDir, targetDir, {
508
841
  PACKAGE_NAME: args.name,
509
842
  DEFAULT_BRANCH: args.defaultBranch,
843
+ BETA_BRANCH: 'release/beta',
510
844
  SCOPE: deriveScope('', args.name)
511
845
  });
512
846
 
513
847
  summary.createdFiles.push(...createdFiles);
514
848
 
515
849
  summary.updatedScriptKeys.push('check', 'changeset', 'version-packages', 'release');
850
+ summary.updatedScriptKeys.push('beta:enter', 'beta:exit', 'beta:version', 'beta:publish', 'beta:promote');
516
851
  summary.updatedDependencyKeys.push(CHANGESETS_DEP);
517
852
 
518
853
  printSummary(`Package created in ${targetDir}`, summary);
@@ -592,6 +927,35 @@ function createBaseRulesetPayload(defaultBranch) {
592
927
  };
593
928
  }
594
929
 
930
+ function createBetaRulesetPayload(betaBranch) {
931
+ return {
932
+ name: `Beta branch protection (${betaBranch})`,
933
+ target: 'branch',
934
+ enforcement: 'active',
935
+ conditions: {
936
+ ref_name: {
937
+ include: [`refs/heads/${betaBranch}`],
938
+ exclude: []
939
+ }
940
+ },
941
+ bypass_actors: [],
942
+ rules: [
943
+ { type: 'deletion' },
944
+ { type: 'non_fast_forward' },
945
+ {
946
+ type: 'pull_request',
947
+ parameters: {
948
+ required_approving_review_count: 0,
949
+ dismiss_stale_reviews_on_push: true,
950
+ require_code_owner_review: false,
951
+ require_last_push_approval: false,
952
+ required_review_thread_resolution: true
953
+ }
954
+ }
955
+ ]
956
+ };
957
+ }
958
+
595
959
  function createRulesetPayload(args) {
596
960
  if (!args.ruleset) {
597
961
  return createBaseRulesetPayload(args.defaultBranch);
@@ -683,6 +1047,423 @@ function updateWorkflowPermissions(deps, repo) {
683
1047
  }
684
1048
  }
685
1049
 
1050
+ function isNotFoundResponse(result) {
1051
+ const output = `${result.stderr || ''}\n${result.stdout || ''}`.toLowerCase();
1052
+ return output.includes('404') || output.includes('not found');
1053
+ }
1054
+
1055
+ function ensureBranchExists(deps, repo, defaultBranch, targetBranch) {
1056
+ const encodedTarget = encodeURIComponent(targetBranch);
1057
+ const getTarget = ghApi(deps, 'GET', `/repos/${repo}/branches/${encodedTarget}`);
1058
+ if (getTarget.status === 0) {
1059
+ return 'exists';
1060
+ }
1061
+
1062
+ if (!isNotFoundResponse(getTarget)) {
1063
+ throw new Error(`Failed to check branch "${targetBranch}": ${getTarget.stderr || getTarget.stdout}`.trim());
1064
+ }
1065
+
1066
+ const encodedDefault = encodeURIComponent(defaultBranch);
1067
+ const getDefaultRef = ghApi(deps, 'GET', `/repos/${repo}/git/ref/heads/${encodedDefault}`);
1068
+ if (getDefaultRef.status !== 0) {
1069
+ throw new Error(`Failed to resolve default branch "${defaultBranch}": ${getDefaultRef.stderr || getDefaultRef.stdout}`.trim());
1070
+ }
1071
+
1072
+ const parsed = parseJsonOutput(getDefaultRef.stdout || '{}', 'Failed to parse default branch ref from GitHub API.');
1073
+ const sha = parsed && parsed.object && parsed.object.sha;
1074
+ if (!sha) {
1075
+ throw new Error(`Could not determine SHA for default branch "${defaultBranch}".`);
1076
+ }
1077
+
1078
+ const createRef = ghApi(deps, 'POST', `/repos/${repo}/git/refs`, {
1079
+ ref: `refs/heads/${targetBranch}`,
1080
+ sha
1081
+ });
1082
+ if (createRef.status !== 0) {
1083
+ throw new Error(`Failed to create branch "${targetBranch}": ${createRef.stderr || createRef.stdout}`.trim());
1084
+ }
1085
+
1086
+ return 'created';
1087
+ }
1088
+
1089
+ function branchExists(deps, repo, targetBranch) {
1090
+ const encodedTarget = encodeURIComponent(targetBranch);
1091
+ const getTarget = ghApi(deps, 'GET', `/repos/${repo}/branches/${encodedTarget}`);
1092
+ if (getTarget.status === 0) {
1093
+ return true;
1094
+ }
1095
+
1096
+ if (isNotFoundResponse(getTarget)) {
1097
+ return false;
1098
+ }
1099
+
1100
+ throw new Error(`Failed to check branch "${targetBranch}": ${getTarget.stderr || getTarget.stdout}`.trim());
1101
+ }
1102
+
1103
+ function findRulesetByName(deps, repo, name) {
1104
+ const listResult = ghApi(deps, 'GET', `/repos/${repo}/rulesets`);
1105
+ if (listResult.status !== 0) {
1106
+ throw new Error(`Failed to list rulesets: ${listResult.stderr || listResult.stdout}`.trim());
1107
+ }
1108
+
1109
+ const rulesets = parseJsonOutput(listResult.stdout || '[]', 'Failed to parse rulesets response from GitHub API.');
1110
+ return rulesets.find((ruleset) => ruleset.name === name) || null;
1111
+ }
1112
+
1113
+ function ensureNpmAvailable(deps) {
1114
+ const version = deps.exec('npm', ['--version']);
1115
+ if (version.status !== 0) {
1116
+ throw new Error('npm CLI is required. Install npm and rerun.');
1117
+ }
1118
+ }
1119
+
1120
+ function ensureNpmAuthenticated(deps) {
1121
+ const whoami = deps.exec('npm', ['whoami']);
1122
+ if (whoami.status !== 0) {
1123
+ throw new Error('npm CLI is not authenticated. Run "npm login" and rerun.');
1124
+ }
1125
+ }
1126
+
1127
+ function packageExistsOnNpm(deps, packageName) {
1128
+ const view = deps.exec('npm', ['view', packageName, 'version', '--json']);
1129
+ if (view.status === 0) {
1130
+ return true;
1131
+ }
1132
+
1133
+ const output = `${view.stderr || ''}\n${view.stdout || ''}`.toLowerCase();
1134
+ if (output.includes('e404') || output.includes('not found') || output.includes('404')) {
1135
+ return false;
1136
+ }
1137
+
1138
+ throw new Error(`Failed to check package on npm: ${view.stderr || view.stdout}`.trim());
1139
+ }
1140
+
1141
+ function setupNpm(args, dependencies = {}) {
1142
+ const deps = {
1143
+ exec: dependencies.exec || execCommand
1144
+ };
1145
+
1146
+ const targetDir = path.resolve(args.dir);
1147
+ if (!fs.existsSync(targetDir)) {
1148
+ throw new Error(`Directory not found: ${targetDir}`);
1149
+ }
1150
+
1151
+ const packageJsonPath = path.join(targetDir, 'package.json');
1152
+ if (!fs.existsSync(packageJsonPath)) {
1153
+ throw new Error(`package.json not found in ${targetDir}`);
1154
+ }
1155
+
1156
+ const packageJson = readJsonFile(packageJsonPath);
1157
+ if (!packageJson.name) {
1158
+ throw new Error(`package.json in ${targetDir} must define "name".`);
1159
+ }
1160
+
1161
+ ensureNpmAvailable(deps);
1162
+ ensureNpmAuthenticated(deps);
1163
+
1164
+ const summary = createSummary();
1165
+ summary.updatedScriptKeys.push('npm.auth', 'npm.package.lookup');
1166
+
1167
+ if (!packageJson.publishConfig || packageJson.publishConfig.access !== 'public') {
1168
+ summary.warnings.push('package.json publishConfig.access is not "public". First publish may fail for public packages.');
1169
+ }
1170
+
1171
+ const existsOnNpm = packageExistsOnNpm(deps, packageJson.name);
1172
+ if (existsOnNpm) {
1173
+ summary.skippedScriptKeys.push('npm.first_publish');
1174
+ } else {
1175
+ summary.updatedScriptKeys.push('npm.first_publish_required');
1176
+ }
1177
+
1178
+ if (!existsOnNpm && !args.publishFirst) {
1179
+ summary.warnings.push(`package "${packageJson.name}" was not found on npm. Run "create-package-starter setup-npm --dir ${targetDir} --publish-first" to perform first publish.`);
1180
+ }
1181
+
1182
+ if (args.publishFirst) {
1183
+ if (existsOnNpm) {
1184
+ summary.warnings.push(`package "${packageJson.name}" already exists on npm. Skipping first publish.`);
1185
+ } else if (args.dryRun) {
1186
+ summary.warnings.push(`dry-run: would run "npm publish --access public" in ${targetDir}`);
1187
+ } else {
1188
+ const publish = deps.exec('npm', ['publish', '--access', 'public'], { cwd: targetDir, stdio: 'inherit' });
1189
+ if (publish.status !== 0) {
1190
+ const publishOutput = `${publish.stderr || ''}\n${publish.stdout || ''}`.toLowerCase();
1191
+ const isOtpError = publishOutput.includes('eotp') || publishOutput.includes('one-time password');
1192
+
1193
+ if (isOtpError) {
1194
+ throw new Error(
1195
+ [
1196
+ 'First publish failed due to npm 2FA/OTP requirements.',
1197
+ 'This command already delegates to the standard npm publish flow.',
1198
+ 'If npm still requires manual OTP entry, complete publish manually:',
1199
+ ` (cd ${targetDir} && npm publish --access public)`
1200
+ ].join('\n')
1201
+ );
1202
+ }
1203
+
1204
+ throw new Error('First publish failed. Check npm output above and try again.');
1205
+ }
1206
+ summary.updatedScriptKeys.push('npm.first_publish_done');
1207
+ }
1208
+ }
1209
+
1210
+ summary.warnings.push('Configure npm Trusted Publisher manually in npm package settings after first publish.');
1211
+ summary.warnings.push('Trusted Publisher requires owner, repository, workflow file (.github/workflows/release.yml), and branch (main by default).');
1212
+
1213
+ printSummary(`npm setup completed for ${packageJson.name}`, summary);
1214
+ }
1215
+
1216
+ async function setupBeta(args, dependencies = {}) {
1217
+ const deps = {
1218
+ exec: dependencies.exec || execCommand
1219
+ };
1220
+
1221
+ const targetDir = path.resolve(args.dir);
1222
+ if (!fs.existsSync(targetDir)) {
1223
+ throw new Error(`Directory not found: ${targetDir}`);
1224
+ }
1225
+
1226
+ const packageJsonPath = path.join(targetDir, 'package.json');
1227
+ if (!fs.existsSync(packageJsonPath)) {
1228
+ throw new Error(`package.json not found in ${targetDir}`);
1229
+ }
1230
+
1231
+ const packageRoot = path.resolve(__dirname, '..');
1232
+ const templateDir = path.join(packageRoot, 'template');
1233
+ const packageJson = readJsonFile(packageJsonPath);
1234
+ packageJson.scripts = packageJson.scripts || {};
1235
+
1236
+ logStep('run', 'Checking GitHub CLI availability and authentication...');
1237
+ try {
1238
+ ensureGhAvailable(deps);
1239
+ logStep('ok', 'GitHub CLI is available and authenticated.');
1240
+ } catch (error) {
1241
+ logStep('err', error.message);
1242
+ if (error.message.includes('not authenticated')) {
1243
+ logStep('warn', 'Run "gh auth login" and retry.');
1244
+ }
1245
+ throw error;
1246
+ }
1247
+
1248
+ logStep('run', 'Resolving repository target...');
1249
+ const repo = resolveRepo(args, deps);
1250
+ logStep('ok', `Using repository ${repo}.`);
1251
+
1252
+ const summary = createSummary();
1253
+ summary.updatedScriptKeys.push('github.beta_branch', 'github.beta_ruleset', 'actions.default_workflow_permissions');
1254
+ const desiredScripts = {
1255
+ 'beta:enter': 'changeset pre enter beta',
1256
+ 'beta:exit': 'changeset pre exit',
1257
+ 'beta:version': 'changeset version',
1258
+ 'beta:publish': 'changeset publish',
1259
+ 'beta:promote': 'create-package-starter promote-stable --dir .'
1260
+ };
1261
+
1262
+ let packageJsonChanged = false;
1263
+ for (const [key, value] of Object.entries(desiredScripts)) {
1264
+ const exists = Object.prototype.hasOwnProperty.call(packageJson.scripts, key);
1265
+ if (!exists || args.force) {
1266
+ if (!exists || packageJson.scripts[key] !== value) {
1267
+ packageJson.scripts[key] = value;
1268
+ packageJsonChanged = true;
1269
+ }
1270
+ summary.updatedScriptKeys.push(key);
1271
+ } else {
1272
+ summary.skippedScriptKeys.push(key);
1273
+ }
1274
+ }
1275
+
1276
+ const workflowRelativePath = '.github/workflows/release.yml';
1277
+ const workflowTemplatePath = path.join(templateDir, workflowRelativePath);
1278
+ const workflowTargetPath = path.join(targetDir, workflowRelativePath);
1279
+ if (!fs.existsSync(workflowTemplatePath)) {
1280
+ throw new Error(`Template not found: ${workflowTemplatePath}`);
1281
+ }
1282
+
1283
+ if (args.dryRun) {
1284
+ logStep('warn', 'Dry-run mode enabled. No remote or file changes will be applied.');
1285
+ const workflowPreview = upsertReleaseWorkflow(workflowTargetPath, workflowTemplatePath, {
1286
+ force: args.force,
1287
+ dryRun: true,
1288
+ variables: {
1289
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
1290
+ DEFAULT_BRANCH: args.defaultBranch,
1291
+ BETA_BRANCH: args.betaBranch,
1292
+ SCOPE: deriveScope('', packageJson.name || '')
1293
+ }
1294
+ });
1295
+ if (workflowPreview.result === 'created') {
1296
+ summary.warnings.push(`dry-run: would create ${workflowRelativePath}`);
1297
+ } else if (workflowPreview.result === 'overwritten') {
1298
+ summary.warnings.push(`dry-run: would overwrite ${workflowRelativePath}`);
1299
+ } else if (workflowPreview.result === 'updated') {
1300
+ summary.warnings.push(`dry-run: would update ${workflowRelativePath} trigger branches`);
1301
+ } else {
1302
+ summary.warnings.push(`dry-run: would keep existing ${workflowRelativePath}`);
1303
+ if (workflowPreview.warning) {
1304
+ summary.warnings.push(`dry-run: ${workflowPreview.warning}`);
1305
+ }
1306
+ }
1307
+ if (packageJsonChanged) {
1308
+ summary.warnings.push('dry-run: would update package.json beta scripts');
1309
+ }
1310
+ summary.warnings.push(`dry-run: would ensure branch "${args.betaBranch}" exists in ${repo}`);
1311
+ summary.warnings.push(`dry-run: would upsert ruleset for refs/heads/${args.betaBranch}`);
1312
+ summary.warnings.push(`dry-run: would set Actions workflow permissions to write for ${repo}`);
1313
+ summary.warnings.push(`dry-run: beta branch configured as ${args.betaBranch}`);
1314
+ } else {
1315
+ const betaRulesetPayload = createBetaRulesetPayload(args.betaBranch);
1316
+ const doesBranchExist = branchExists(deps, repo, args.betaBranch);
1317
+ const existingRuleset = findRulesetByName(deps, repo, betaRulesetPayload.name);
1318
+
1319
+ if (args.yes) {
1320
+ logStep('warn', 'Confirmation prompts skipped due to --yes.');
1321
+ } else {
1322
+ await confirmOrThrow(
1323
+ [
1324
+ `This will modify GitHub repository settings for ${repo}:`,
1325
+ `- set Actions workflow permissions to write`,
1326
+ `- ensure branch "${args.betaBranch}" exists${doesBranchExist ? ' (already exists)' : ' (will be created)'}`,
1327
+ `- apply branch protection ruleset "${betaRulesetPayload.name}"`,
1328
+ `- update local ${workflowRelativePath} and package.json beta scripts`
1329
+ ].join('\n')
1330
+ );
1331
+
1332
+ if (existingRuleset) {
1333
+ await confirmOrThrow(
1334
+ `Ruleset "${betaRulesetPayload.name}" already exists and will be overwritten.`
1335
+ );
1336
+ }
1337
+ }
1338
+
1339
+ logStep('run', `Ensuring ${workflowRelativePath} includes stable+beta triggers...`);
1340
+ const workflowUpsert = upsertReleaseWorkflow(workflowTargetPath, workflowTemplatePath, {
1341
+ force: args.force,
1342
+ dryRun: false,
1343
+ variables: {
1344
+ PACKAGE_NAME: packageJson.name || packageDirFromName(path.basename(targetDir)),
1345
+ DEFAULT_BRANCH: args.defaultBranch,
1346
+ BETA_BRANCH: args.betaBranch,
1347
+ SCOPE: deriveScope('', packageJson.name || '')
1348
+ }
1349
+ });
1350
+ const workflowResult = workflowUpsert.result;
1351
+
1352
+ if (workflowResult === 'created') {
1353
+ summary.createdFiles.push(workflowRelativePath);
1354
+ logStep('ok', `${workflowRelativePath} created.`);
1355
+ } else if (workflowResult === 'overwritten') {
1356
+ summary.overwrittenFiles.push(workflowRelativePath);
1357
+ logStep('ok', `${workflowRelativePath} overwritten.`);
1358
+ } else if (workflowResult === 'updated') {
1359
+ summary.overwrittenFiles.push(workflowRelativePath);
1360
+ logStep('ok', `${workflowRelativePath} updated with missing branch triggers.`);
1361
+ } else {
1362
+ summary.skippedFiles.push(workflowRelativePath);
1363
+ if (workflowUpsert.warning) {
1364
+ summary.warnings.push(workflowUpsert.warning);
1365
+ logStep('warn', workflowUpsert.warning);
1366
+ } else {
1367
+ logStep('warn', `${workflowRelativePath} already configured; kept as-is.`);
1368
+ }
1369
+ }
1370
+
1371
+ if (packageJsonChanged) {
1372
+ logStep('run', 'Updating package.json beta scripts...');
1373
+ writeJsonFile(packageJsonPath, packageJson);
1374
+ logStep('ok', 'package.json beta scripts updated.');
1375
+ } else {
1376
+ logStep('warn', 'package.json beta scripts already present; no changes needed.');
1377
+ }
1378
+
1379
+ logStep('run', 'Applying GitHub Actions workflow permissions...');
1380
+ updateWorkflowPermissions(deps, repo);
1381
+ logStep('ok', 'Workflow permissions configured.');
1382
+
1383
+ logStep('run', `Ensuring branch "${args.betaBranch}" exists...`);
1384
+ const branchResult = ensureBranchExists(deps, repo, args.defaultBranch, args.betaBranch);
1385
+ if (branchResult === 'created') {
1386
+ summary.createdFiles.push(`github-branch:${args.betaBranch}`);
1387
+ logStep('ok', `Branch "${args.betaBranch}" created from "${args.defaultBranch}".`);
1388
+ } else {
1389
+ summary.skippedFiles.push(`github-branch:${args.betaBranch}`);
1390
+ logStep('warn', `Branch "${args.betaBranch}" already exists.`);
1391
+ }
1392
+
1393
+ logStep('run', `Applying protection ruleset to "${args.betaBranch}"...`);
1394
+ const upsertResult = upsertRuleset(deps, repo, betaRulesetPayload);
1395
+ summary.overwrittenFiles.push(`github-beta-ruleset:${upsertResult}`);
1396
+ logStep('ok', `Beta branch ruleset ${upsertResult}.`);
1397
+ }
1398
+
1399
+ summary.warnings.push(`Trusted Publisher supports a single workflow file per package. Keep publishing on .github/workflows/release.yml for both stable and beta.`);
1400
+ summary.warnings.push(`Next step: run "npm run beta:enter" once on "${args.betaBranch}", commit .changeset/pre.json, and push.`);
1401
+ printSummary(`beta setup completed for ${targetDir}`, summary);
1402
+ }
1403
+
1404
+ function createChangesetFile(targetDir, packageName, bumpType, summaryText) {
1405
+ const changesetDir = path.join(targetDir, '.changeset');
1406
+ fs.mkdirSync(changesetDir, { recursive: true });
1407
+ const fileName = `promote-stable-${Date.now()}.md`;
1408
+ const filePath = path.join(changesetDir, fileName);
1409
+ const content = [
1410
+ '---',
1411
+ `"${packageName}": ${bumpType}`,
1412
+ '---',
1413
+ '',
1414
+ summaryText
1415
+ ].join('\n');
1416
+ fs.writeFileSync(filePath, `${content}\n`);
1417
+ return path.posix.join('.changeset', fileName);
1418
+ }
1419
+
1420
+ function promoteStable(args, dependencies = {}) {
1421
+ const deps = {
1422
+ exec: dependencies.exec || execCommand
1423
+ };
1424
+
1425
+ const targetDir = path.resolve(args.dir);
1426
+ if (!fs.existsSync(targetDir)) {
1427
+ throw new Error(`Directory not found: ${targetDir}`);
1428
+ }
1429
+
1430
+ const packageJsonPath = path.join(targetDir, 'package.json');
1431
+ if (!fs.existsSync(packageJsonPath)) {
1432
+ throw new Error(`package.json not found in ${targetDir}`);
1433
+ }
1434
+
1435
+ const prePath = path.join(targetDir, '.changeset', 'pre.json');
1436
+ if (!fs.existsSync(prePath)) {
1437
+ throw new Error(`No prerelease state found in ${targetDir}. Run "changeset pre enter beta" first.`);
1438
+ }
1439
+
1440
+ const packageJson = readJsonFile(packageJsonPath);
1441
+ if (!packageJson.name) {
1442
+ throw new Error(`package.json in ${targetDir} must define "name".`);
1443
+ }
1444
+
1445
+ const summary = createSummary();
1446
+ summary.updatedScriptKeys.push('changeset.pre_exit', 'changeset.promote_stable');
1447
+
1448
+ if (args.dryRun) {
1449
+ summary.warnings.push(`dry-run: would run "npx @changesets/cli pre exit" in ${targetDir}`);
1450
+ summary.warnings.push(`dry-run: would create promotion changeset for ${packageJson.name} (${args.type})`);
1451
+ summary.warnings.push(`dry-run: promote flow targets stable branch ${DEFAULT_BASE_BRANCH}`);
1452
+ printSummary(`stable promotion dry-run for ${targetDir}`, summary);
1453
+ return;
1454
+ }
1455
+
1456
+ const preExit = deps.exec('npx', ['@changesets/cli', 'pre', 'exit'], { cwd: targetDir });
1457
+ if (preExit.status !== 0) {
1458
+ throw new Error(`Failed to exit prerelease mode: ${(preExit.stderr || preExit.stdout || '').trim()}`);
1459
+ }
1460
+
1461
+ const createdChangeset = createChangesetFile(targetDir, packageJson.name, args.type, args.summary);
1462
+ summary.createdFiles.push(createdChangeset);
1463
+ summary.warnings.push('Next step: open PR from beta branch to main and merge to publish stable.');
1464
+ printSummary(`stable promotion prepared for ${targetDir}`, summary);
1465
+ }
1466
+
686
1467
  function setupGithub(args, dependencies = {}) {
687
1468
  const deps = {
688
1469
  exec: dependencies.exec || execCommand
@@ -750,6 +1531,21 @@ async function run(argv, dependencies = {}) {
750
1531
  return;
751
1532
  }
752
1533
 
1534
+ if (parsed.mode === 'setup-beta') {
1535
+ setupBeta(parsed.args, dependencies);
1536
+ return;
1537
+ }
1538
+
1539
+ if (parsed.mode === 'promote-stable') {
1540
+ promoteStable(parsed.args, dependencies);
1541
+ return;
1542
+ }
1543
+
1544
+ if (parsed.mode === 'setup-npm') {
1545
+ setupNpm(parsed.args, dependencies);
1546
+ return;
1547
+ }
1548
+
753
1549
  createNewPackage(parsed.args);
754
1550
  }
755
1551
 
@@ -757,5 +1553,9 @@ module.exports = {
757
1553
  run,
758
1554
  parseRepoFromRemote,
759
1555
  createBaseRulesetPayload,
760
- setupGithub
1556
+ createBetaRulesetPayload,
1557
+ setupGithub,
1558
+ setupNpm,
1559
+ setupBeta,
1560
+ promoteStable
761
1561
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@i-santos/create-package-starter",
3
- "version": "1.3.0",
3
+ "version": "1.5.0-beta.1",
4
4
  "description": "Scaffold new npm packages with a standardized Changesets release workflow",
5
5
  "license": "MIT",
6
6
  "author": "Igor Santos",
@@ -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"