browser-commander 0.2.0

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 (82) hide show
  1. package/.changeset/README.md +8 -0
  2. package/.changeset/config.json +11 -0
  3. package/.github/workflows/release.yml +296 -0
  4. package/.husky/pre-commit +1 -0
  5. package/.jscpd.json +20 -0
  6. package/.prettierignore +7 -0
  7. package/.prettierrc +10 -0
  8. package/CHANGELOG.md +32 -0
  9. package/LICENSE +24 -0
  10. package/README.md +320 -0
  11. package/bunfig.toml +3 -0
  12. package/deno.json +7 -0
  13. package/eslint.config.js +125 -0
  14. package/examples/react-test-app/index.html +25 -0
  15. package/examples/react-test-app/package.json +19 -0
  16. package/examples/react-test-app/src/App.jsx +473 -0
  17. package/examples/react-test-app/src/main.jsx +10 -0
  18. package/examples/react-test-app/src/styles.css +323 -0
  19. package/examples/react-test-app/vite.config.js +9 -0
  20. package/package.json +89 -0
  21. package/scripts/changeset-version.mjs +38 -0
  22. package/scripts/create-github-release.mjs +93 -0
  23. package/scripts/create-manual-changeset.mjs +86 -0
  24. package/scripts/format-github-release.mjs +83 -0
  25. package/scripts/format-release-notes.mjs +216 -0
  26. package/scripts/instant-version-bump.mjs +121 -0
  27. package/scripts/merge-changesets.mjs +260 -0
  28. package/scripts/publish-to-npm.mjs +126 -0
  29. package/scripts/setup-npm.mjs +37 -0
  30. package/scripts/validate-changeset.mjs +262 -0
  31. package/scripts/version-and-commit.mjs +237 -0
  32. package/src/ARCHITECTURE.md +270 -0
  33. package/src/README.md +517 -0
  34. package/src/bindings.js +298 -0
  35. package/src/browser/launcher.js +93 -0
  36. package/src/browser/navigation.js +513 -0
  37. package/src/core/constants.js +24 -0
  38. package/src/core/engine-adapter.js +466 -0
  39. package/src/core/engine-detection.js +49 -0
  40. package/src/core/logger.js +21 -0
  41. package/src/core/navigation-manager.js +503 -0
  42. package/src/core/navigation-safety.js +160 -0
  43. package/src/core/network-tracker.js +373 -0
  44. package/src/core/page-session.js +299 -0
  45. package/src/core/page-trigger-manager.js +564 -0
  46. package/src/core/preferences.js +46 -0
  47. package/src/elements/content.js +197 -0
  48. package/src/elements/locators.js +243 -0
  49. package/src/elements/selectors.js +360 -0
  50. package/src/elements/visibility.js +166 -0
  51. package/src/exports.js +121 -0
  52. package/src/factory.js +192 -0
  53. package/src/high-level/universal-logic.js +206 -0
  54. package/src/index.js +17 -0
  55. package/src/interactions/click.js +684 -0
  56. package/src/interactions/fill.js +383 -0
  57. package/src/interactions/scroll.js +341 -0
  58. package/src/utilities/url.js +33 -0
  59. package/src/utilities/wait.js +135 -0
  60. package/tests/e2e/playwright.e2e.test.js +442 -0
  61. package/tests/e2e/puppeteer.e2e.test.js +408 -0
  62. package/tests/helpers/mocks.js +542 -0
  63. package/tests/unit/bindings.test.js +218 -0
  64. package/tests/unit/browser/navigation.test.js +345 -0
  65. package/tests/unit/core/constants.test.js +72 -0
  66. package/tests/unit/core/engine-adapter.test.js +170 -0
  67. package/tests/unit/core/engine-detection.test.js +81 -0
  68. package/tests/unit/core/logger.test.js +80 -0
  69. package/tests/unit/core/navigation-safety.test.js +202 -0
  70. package/tests/unit/core/network-tracker.test.js +198 -0
  71. package/tests/unit/core/page-trigger-manager.test.js +358 -0
  72. package/tests/unit/elements/content.test.js +318 -0
  73. package/tests/unit/elements/locators.test.js +236 -0
  74. package/tests/unit/elements/selectors.test.js +302 -0
  75. package/tests/unit/elements/visibility.test.js +234 -0
  76. package/tests/unit/factory.test.js +174 -0
  77. package/tests/unit/high-level/universal-logic.test.js +299 -0
  78. package/tests/unit/interactions/click.test.js +340 -0
  79. package/tests/unit/interactions/fill.test.js +378 -0
  80. package/tests/unit/interactions/scroll.test.js +330 -0
  81. package/tests/unit/utilities/url.test.js +63 -0
  82. package/tests/unit/utilities/wait.test.js +207 -0
@@ -0,0 +1,126 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Publish to npm using OIDC trusted publishing
5
+ * Usage: node scripts/publish-to-npm.mjs [--should-pull]
6
+ * should_pull: Optional flag to pull latest changes before publishing (for release job)
7
+ *
8
+ * Uses link-foundation libraries:
9
+ * - use-m: Dynamic package loading without package.json dependencies
10
+ * - command-stream: Modern shell command execution with streaming support
11
+ * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files
12
+ */
13
+
14
+ import { readFileSync, appendFileSync } from 'fs';
15
+
16
+ const PACKAGE_NAME = 'browser-commander';
17
+
18
+ // Load use-m dynamically
19
+ const { use } = eval(
20
+ await (await fetch('https://unpkg.com/use-m/use.js')).text()
21
+ );
22
+
23
+ // Import link-foundation libraries
24
+ const { $ } = await use('command-stream');
25
+ const { makeConfig } = await use('lino-arguments');
26
+
27
+ // Parse CLI arguments using lino-arguments
28
+ const config = makeConfig({
29
+ yargs: ({ yargs, getenv }) =>
30
+ yargs.option('should-pull', {
31
+ type: 'boolean',
32
+ default: getenv('SHOULD_PULL', false),
33
+ describe: 'Pull latest changes before publishing',
34
+ }),
35
+ });
36
+
37
+ const { shouldPull } = config;
38
+ const MAX_RETRIES = 3;
39
+ const RETRY_DELAY = 10000; // 10 seconds
40
+
41
+ /**
42
+ * Sleep for specified milliseconds
43
+ * @param {number} ms
44
+ */
45
+ function sleep(ms) {
46
+ return new Promise((resolve) => globalThis.setTimeout(resolve, ms));
47
+ }
48
+
49
+ /**
50
+ * Append to GitHub Actions output file
51
+ * @param {string} key
52
+ * @param {string} value
53
+ */
54
+ function setOutput(key, value) {
55
+ const outputFile = process.env.GITHUB_OUTPUT;
56
+ if (outputFile) {
57
+ appendFileSync(outputFile, `${key}=${value}\n`);
58
+ }
59
+ }
60
+
61
+ async function main() {
62
+ try {
63
+ if (shouldPull) {
64
+ // Pull the latest changes we just pushed
65
+ await $`git pull origin main`;
66
+ }
67
+
68
+ // Get current version
69
+ const packageJson = JSON.parse(readFileSync('./package.json', 'utf8'));
70
+ const currentVersion = packageJson.version;
71
+ console.log(`Current version to publish: ${currentVersion}`);
72
+
73
+ // Check if this version is already published on npm
74
+ console.log(
75
+ `Checking if version ${currentVersion} is already published...`
76
+ );
77
+ const checkResult =
78
+ await $`npm view "${PACKAGE_NAME}@${currentVersion}" version`.run({
79
+ capture: true,
80
+ });
81
+
82
+ // command-stream returns { code: 0 } on success, { code: 1 } on failure (e.g., E404)
83
+ // Exit code 0 means version exists, non-zero means version not found
84
+ if (checkResult.code === 0) {
85
+ console.log(`Version ${currentVersion} is already published to npm`);
86
+ setOutput('published', 'true');
87
+ setOutput('published_version', currentVersion);
88
+ setOutput('already_published', 'true');
89
+ return;
90
+ } else {
91
+ // Version not found on npm (E404), proceed with publish
92
+ console.log(
93
+ `Version ${currentVersion} not found on npm, proceeding with publish...`
94
+ );
95
+ }
96
+
97
+ // Publish to npm using OIDC trusted publishing with retry logic
98
+ for (let i = 1; i <= MAX_RETRIES; i++) {
99
+ console.log(`Publish attempt ${i} of ${MAX_RETRIES}...`);
100
+ try {
101
+ await $`npm run changeset:publish`;
102
+ setOutput('published', 'true');
103
+ setOutput('published_version', currentVersion);
104
+ console.log(
105
+ `\u2705 Published ${PACKAGE_NAME}@${currentVersion} to npm`
106
+ );
107
+ return;
108
+ } catch (error) {
109
+ if (i < MAX_RETRIES) {
110
+ console.log(
111
+ `Publish failed: ${error.message}, waiting ${RETRY_DELAY / 1000}s before retry...`
112
+ );
113
+ await sleep(RETRY_DELAY);
114
+ }
115
+ }
116
+ }
117
+
118
+ console.error(`\u274C Failed to publish after ${MAX_RETRIES} attempts`);
119
+ process.exit(1);
120
+ } catch (error) {
121
+ console.error('Error:', error.message);
122
+ process.exit(1);
123
+ }
124
+ }
125
+
126
+ main();
@@ -0,0 +1,37 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Update npm for OIDC trusted publishing
5
+ * npm trusted publishing requires npm >= 11.5.1
6
+ * Node.js 20.x ships with npm 10.x, so we need to update
7
+ *
8
+ * Uses link-foundation libraries:
9
+ * - use-m: Dynamic package loading without package.json dependencies
10
+ * - command-stream: Modern shell command execution with streaming support
11
+ */
12
+
13
+ // Load use-m dynamically
14
+ const { use } = eval(
15
+ await (await fetch('https://unpkg.com/use-m/use.js')).text()
16
+ );
17
+
18
+ // Import command-stream for shell command execution
19
+ const { $ } = await use('command-stream');
20
+
21
+ try {
22
+ // Get current npm version
23
+ const currentResult = await $`npm --version`.run({ capture: true });
24
+ const currentVersion = currentResult.stdout.trim();
25
+ console.log(`Current npm version: ${currentVersion}`);
26
+
27
+ // Update npm to latest
28
+ await $`npm install -g npm@latest`;
29
+
30
+ // Get updated npm version
31
+ const updatedResult = await $`npm --version`.run({ capture: true });
32
+ const updatedVersion = updatedResult.stdout.trim();
33
+ console.log(`Updated npm version: ${updatedVersion}`);
34
+ } catch (error) {
35
+ console.error('Error updating npm:', error.message);
36
+ process.exit(1);
37
+ }
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Validate changeset for CI - ensures exactly one valid changeset is added by the PR
5
+ *
6
+ * Key behavior:
7
+ * - Only checks changeset files ADDED by the current PR (not pre-existing ones)
8
+ * - Uses git diff to compare PR head against base branch
9
+ * - Validates that the PR adds exactly one changeset with proper format
10
+ * - Falls back to checking all changesets for local development
11
+ */
12
+
13
+ import { execSync } from 'child_process';
14
+ import { readFileSync, readdirSync, existsSync } from 'fs';
15
+ import { join } from 'path';
16
+
17
+ const PACKAGE_NAME = 'browser-commander';
18
+ const CHANGESET_DIR = '.changeset';
19
+
20
+ /**
21
+ * Ensure a git commit is available locally, fetching if necessary
22
+ * @param {string} sha The commit SHA to check
23
+ */
24
+ function ensureCommitAvailable(sha) {
25
+ try {
26
+ execSync(`git cat-file -e ${sha}`, { stdio: 'ignore' });
27
+ } catch {
28
+ console.log('Base commit not available locally, attempting fetch...');
29
+ try {
30
+ execSync(`git fetch origin ${sha}`, { stdio: 'inherit' });
31
+ } catch {
32
+ execSync(`git fetch origin`, { stdio: 'inherit' });
33
+ }
34
+ }
35
+ }
36
+
37
+ /**
38
+ * Parse git diff output and extract added changeset files
39
+ * @param {string} diffOutput Output from git diff --name-status
40
+ * @returns {string[]} Array of added changeset file names
41
+ */
42
+ function parseAddedChangesets(diffOutput) {
43
+ const addedChangesets = [];
44
+ for (const line of diffOutput.trim().split('\n')) {
45
+ if (!line) {
46
+ continue;
47
+ }
48
+ const [status, filePath] = line.split('\t');
49
+ if (
50
+ status === 'A' &&
51
+ filePath.startsWith(`${CHANGESET_DIR}/`) &&
52
+ filePath.endsWith('.md') &&
53
+ !filePath.endsWith('README.md')
54
+ ) {
55
+ addedChangesets.push(filePath.replace(`${CHANGESET_DIR}/`, ''));
56
+ }
57
+ }
58
+ return addedChangesets;
59
+ }
60
+
61
+ /**
62
+ * Try to get changesets using explicit SHA comparison
63
+ * @param {string} baseSha Base commit SHA
64
+ * @param {string} headSha Head commit SHA
65
+ * @returns {string[] | null} Array of changeset files or null if failed
66
+ */
67
+ function tryExplicitShaComparison(baseSha, headSha) {
68
+ console.log(`Comparing ${baseSha}...${headSha}`);
69
+ try {
70
+ ensureCommitAvailable(baseSha);
71
+ const diffOutput = execSync(
72
+ `git diff --name-status ${baseSha} ${headSha}`,
73
+ { encoding: 'utf-8' }
74
+ );
75
+ return parseAddedChangesets(diffOutput);
76
+ } catch (error) {
77
+ console.log(`Git diff with explicit SHAs failed: ${error.message}`);
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Try to get changesets using base branch comparison
84
+ * @param {string} prBase Base branch name
85
+ * @returns {string[] | null} Array of changeset files or null if failed
86
+ */
87
+ function tryBaseBranchComparison(prBase) {
88
+ console.log(`Comparing against base branch: ${prBase}`);
89
+ try {
90
+ try {
91
+ execSync(`git fetch origin ${prBase}`, { stdio: 'inherit' });
92
+ } catch {
93
+ // Ignore fetch errors, we might already have it
94
+ }
95
+ const diffOutput = execSync(
96
+ `git diff --name-status origin/${prBase}...HEAD`,
97
+ { encoding: 'utf-8' }
98
+ );
99
+ return parseAddedChangesets(diffOutput);
100
+ } catch (error) {
101
+ console.log(`Git diff with base ref failed: ${error.message}`);
102
+ return null;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * Fallback: get all changesets in directory
108
+ * @returns {string[]} Array of all changeset file names
109
+ */
110
+ function getAllChangesets() {
111
+ console.log(
112
+ 'Warning: Could not determine PR diff, checking all changesets in directory'
113
+ );
114
+ if (!existsSync(CHANGESET_DIR)) {
115
+ return [];
116
+ }
117
+ return readdirSync(CHANGESET_DIR).filter(
118
+ (file) => file.endsWith('.md') && file !== 'README.md'
119
+ );
120
+ }
121
+
122
+ /**
123
+ * Get changeset files added in the current PR using git diff
124
+ * @returns {string[]} Array of added changeset file names
125
+ */
126
+ function getAddedChangesetFiles() {
127
+ const baseSha = process.env.GITHUB_BASE_SHA || process.env.BASE_SHA;
128
+ const headSha = process.env.GITHUB_HEAD_SHA || process.env.HEAD_SHA;
129
+
130
+ // Try explicit SHAs first
131
+ if (baseSha && headSha) {
132
+ const result = tryExplicitShaComparison(baseSha, headSha);
133
+ if (result !== null) {
134
+ return result;
135
+ }
136
+ }
137
+
138
+ // Try base branch comparison
139
+ const prBase = process.env.GITHUB_BASE_REF;
140
+ if (prBase) {
141
+ const result = tryBaseBranchComparison(prBase);
142
+ if (result !== null) {
143
+ return result;
144
+ }
145
+ }
146
+
147
+ // Fallback to checking all changesets
148
+ return getAllChangesets();
149
+ }
150
+
151
+ /**
152
+ * Validate a single changeset file
153
+ * @param {string} filePath Full path to the changeset file
154
+ * @returns {{valid: boolean, type?: string, description?: string, error?: string}}
155
+ */
156
+ function validateChangesetFile(filePath) {
157
+ try {
158
+ const content = readFileSync(filePath, 'utf-8');
159
+
160
+ // Check if changeset has a valid type (major, minor, or patch)
161
+ const versionTypeRegex = new RegExp(
162
+ `^['"]${PACKAGE_NAME.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"]:\\s+(major|minor|patch)`,
163
+ 'm'
164
+ );
165
+ const versionTypeMatch = content.match(versionTypeRegex);
166
+
167
+ if (!versionTypeMatch) {
168
+ return {
169
+ valid: false,
170
+ error: `Changeset must specify a version type: major, minor, or patch\nExpected format:\n---\n'${PACKAGE_NAME}': patch\n---\n\nYour description here`,
171
+ };
172
+ }
173
+
174
+ // Extract description (everything after the closing ---) and check it's not empty
175
+ const parts = content.split('---');
176
+ if (parts.length < 3) {
177
+ return {
178
+ valid: false,
179
+ error:
180
+ "Changeset must include a description of the changes (after the closing '---')",
181
+ };
182
+ }
183
+
184
+ const description = parts.slice(2).join('---').trim();
185
+ if (!description) {
186
+ return {
187
+ valid: false,
188
+ error: 'Changeset must include a non-empty description of the changes',
189
+ };
190
+ }
191
+
192
+ return {
193
+ valid: true,
194
+ type: versionTypeMatch[1],
195
+ description,
196
+ };
197
+ } catch (error) {
198
+ return {
199
+ valid: false,
200
+ error: `Failed to read changeset file: ${error.message}`,
201
+ };
202
+ }
203
+ }
204
+
205
+ try {
206
+ console.log('Validating changesets added by this PR...');
207
+
208
+ // Get changeset files added in this PR
209
+ const addedChangesetFiles = getAddedChangesetFiles();
210
+ const changesetCount = addedChangesetFiles.length;
211
+
212
+ console.log(`Found ${changesetCount} changeset file(s) added by this PR`);
213
+ if (changesetCount > 0) {
214
+ console.log('Added changesets:');
215
+ addedChangesetFiles.forEach((file) => console.log(` - ${file}`));
216
+ }
217
+
218
+ // Ensure exactly one changeset file was added
219
+ if (changesetCount === 0) {
220
+ console.error(
221
+ "::error::No changeset found in this PR. Please add a changeset by running 'npm run changeset' and commit the result."
222
+ );
223
+ process.exit(1);
224
+ } else if (changesetCount > 1) {
225
+ console.error(
226
+ `::error::Multiple changesets found in this PR (${changesetCount}). Each PR should add exactly ONE changeset.`
227
+ );
228
+ console.error('::error::Found changeset files added by this PR:');
229
+ addedChangesetFiles.forEach((file) => console.error(` ${file}`));
230
+ console.error(
231
+ '\n::error::Please combine these into a single changeset or remove the extras.'
232
+ );
233
+ process.exit(1);
234
+ }
235
+
236
+ // Validate the single changeset file
237
+ const changesetFile = join(CHANGESET_DIR, addedChangesetFiles[0]);
238
+ console.log(`Validating changeset: ${changesetFile}`);
239
+
240
+ const validation = validateChangesetFile(changesetFile);
241
+
242
+ if (!validation.valid) {
243
+ console.error(`::error::${validation.error}`);
244
+ console.error(`\nFile content of ${changesetFile}:`);
245
+ try {
246
+ console.error(readFileSync(changesetFile, 'utf-8'));
247
+ } catch {
248
+ console.error('(could not read file)');
249
+ }
250
+ process.exit(1);
251
+ }
252
+
253
+ console.log('Changeset validation passed');
254
+ console.log(` Type: ${validation.type}`);
255
+ console.log(` Description: ${validation.description}`);
256
+ } catch (error) {
257
+ console.error('Error during changeset validation:', error.message);
258
+ if (process.env.DEBUG) {
259
+ console.error('Stack trace:', error.stack);
260
+ }
261
+ process.exit(1);
262
+ }
@@ -0,0 +1,237 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Version packages and commit to main
5
+ * Usage: node scripts/version-and-commit.mjs --mode <changeset|instant> [--bump-type <type>] [--description <desc>]
6
+ * changeset: Run changeset version
7
+ * instant: Run instant version bump with bump_type (patch|minor|major) and optional description
8
+ *
9
+ * Uses link-foundation libraries:
10
+ * - use-m: Dynamic package loading without package.json dependencies
11
+ * - command-stream: Modern shell command execution with streaming support
12
+ * - lino-arguments: Unified configuration from CLI args, env vars, and .lenv files
13
+ */
14
+
15
+ import { readFileSync, appendFileSync, readdirSync } from 'fs';
16
+
17
+ // Load use-m dynamically
18
+ const { use } = eval(
19
+ await (await fetch('https://unpkg.com/use-m/use.js')).text()
20
+ );
21
+
22
+ // Import link-foundation libraries
23
+ const { $ } = await use('command-stream');
24
+ const { makeConfig } = await use('lino-arguments');
25
+
26
+ // Parse CLI arguments using lino-arguments
27
+ const config = makeConfig({
28
+ yargs: ({ yargs, getenv }) =>
29
+ yargs
30
+ .option('mode', {
31
+ type: 'string',
32
+ default: getenv('MODE', 'changeset'),
33
+ describe: 'Version mode: changeset or instant',
34
+ choices: ['changeset', 'instant'],
35
+ })
36
+ .option('bump-type', {
37
+ type: 'string',
38
+ default: getenv('BUMP_TYPE', ''),
39
+ describe: 'Version bump type for instant mode: major, minor, or patch',
40
+ })
41
+ .option('description', {
42
+ type: 'string',
43
+ default: getenv('DESCRIPTION', ''),
44
+ describe: 'Description for instant version bump',
45
+ }),
46
+ });
47
+
48
+ const { mode, bumpType, description } = config;
49
+
50
+ // Debug: Log parsed configuration
51
+ console.log('Parsed configuration:', {
52
+ mode,
53
+ bumpType,
54
+ description: description || '(none)',
55
+ });
56
+
57
+ // Detect if positional arguments were used (common mistake)
58
+ const args = process.argv.slice(2);
59
+ if (args.length > 0 && !args[0].startsWith('--')) {
60
+ console.error('Error: Positional arguments detected!');
61
+ console.error('Command line arguments:', args);
62
+ console.error('');
63
+ console.error(
64
+ 'This script requires named arguments (--mode, --bump-type, --description).'
65
+ );
66
+ console.error('Usage:');
67
+ console.error(' Changeset mode:');
68
+ console.error(' node scripts/version-and-commit.mjs --mode changeset');
69
+ console.error(' Instant mode:');
70
+ console.error(
71
+ ' node scripts/version-and-commit.mjs --mode instant --bump-type <major|minor|patch> [--description <desc>]'
72
+ );
73
+ console.error('');
74
+ console.error('Examples:');
75
+ console.error(
76
+ ' node scripts/version-and-commit.mjs --mode instant --bump-type patch --description "Fix bug"'
77
+ );
78
+ console.error(' node scripts/version-and-commit.mjs --mode changeset');
79
+ process.exit(1);
80
+ }
81
+
82
+ // Validation: Ensure mode is set correctly
83
+ if (mode !== 'changeset' && mode !== 'instant') {
84
+ console.error(`Invalid mode: "${mode}". Expected "changeset" or "instant".`);
85
+ console.error('Command line arguments:', process.argv.slice(2));
86
+ process.exit(1);
87
+ }
88
+
89
+ // Validation: Ensure bump type is provided for instant mode
90
+ if (mode === 'instant' && !bumpType) {
91
+ console.error('Error: --bump-type is required for instant mode');
92
+ console.error(
93
+ 'Usage: node scripts/version-and-commit.mjs --mode instant --bump-type <major|minor|patch> [--description <desc>]'
94
+ );
95
+ process.exit(1);
96
+ }
97
+
98
+ /**
99
+ * Append to GitHub Actions output file
100
+ * @param {string} key
101
+ * @param {string} value
102
+ */
103
+ function setOutput(key, value) {
104
+ const outputFile = process.env.GITHUB_OUTPUT;
105
+ if (outputFile) {
106
+ appendFileSync(outputFile, `${key}=${value}\n`);
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Count changeset files (excluding README.md)
112
+ */
113
+ function countChangesets() {
114
+ try {
115
+ const changesetDir = '.changeset';
116
+ const files = readdirSync(changesetDir);
117
+ return files.filter((f) => f.endsWith('.md') && f !== 'README.md').length;
118
+ } catch {
119
+ return 0;
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Get package version
125
+ * @param {string} source - 'local' or 'remote'
126
+ */
127
+ async function getVersion(source = 'local') {
128
+ if (source === 'remote') {
129
+ const result = await $`git show origin/main:package.json`.run({
130
+ capture: true,
131
+ });
132
+ return JSON.parse(result.stdout).version;
133
+ }
134
+ return JSON.parse(readFileSync('./package.json', 'utf8')).version;
135
+ }
136
+
137
+ async function main() {
138
+ try {
139
+ // Configure git
140
+ await $`git config user.name "github-actions[bot]"`;
141
+ await $`git config user.email "github-actions[bot]@users.noreply.github.com"`;
142
+
143
+ // Check if remote main has advanced (handles re-runs after partial success)
144
+ console.log('Checking for remote changes...');
145
+ await $`git fetch origin main`;
146
+
147
+ const localHeadResult = await $`git rev-parse HEAD`.run({ capture: true });
148
+ const localHead = localHeadResult.stdout.trim();
149
+
150
+ const remoteHeadResult = await $`git rev-parse origin/main`.run({
151
+ capture: true,
152
+ });
153
+ const remoteHead = remoteHeadResult.stdout.trim();
154
+
155
+ if (localHead !== remoteHead) {
156
+ console.log(
157
+ `Remote main has advanced (local: ${localHead}, remote: ${remoteHead})`
158
+ );
159
+ console.log('This may indicate a previous attempt partially succeeded.');
160
+
161
+ // Check if the remote version is already the expected bump
162
+ const remoteVersion = await getVersion('remote');
163
+ console.log(`Remote version: ${remoteVersion}`);
164
+
165
+ // Check if there are changesets to process
166
+ const changesetCount = countChangesets();
167
+
168
+ if (changesetCount === 0) {
169
+ console.log('No changesets to process and remote has advanced.');
170
+ console.log(
171
+ 'Assuming version bump was already completed in a previous attempt.'
172
+ );
173
+ setOutput('version_committed', 'false');
174
+ setOutput('already_released', 'true');
175
+ setOutput('new_version', remoteVersion);
176
+ return;
177
+ } else {
178
+ console.log('Rebasing on remote main to incorporate changes...');
179
+ await $`git rebase origin/main`;
180
+ }
181
+ }
182
+
183
+ // Get current version before bump
184
+ const oldVersion = await getVersion();
185
+ console.log(`Current version: ${oldVersion}`);
186
+
187
+ if (mode === 'instant') {
188
+ console.log('Running instant version bump...');
189
+ // Run instant version bump script
190
+ // Rely on command-stream's auto-quoting for proper argument handling
191
+ if (description) {
192
+ await $`node scripts/instant-version-bump.mjs --bump-type ${bumpType} --description ${description}`;
193
+ } else {
194
+ await $`node scripts/instant-version-bump.mjs --bump-type ${bumpType}`;
195
+ }
196
+ } else {
197
+ console.log('Running changeset version...');
198
+ // Run changeset version to bump versions and update CHANGELOG
199
+ await $`npm run changeset:version`;
200
+ }
201
+
202
+ // Get new version after bump
203
+ const newVersion = await getVersion();
204
+ console.log(`New version: ${newVersion}`);
205
+ setOutput('new_version', newVersion);
206
+
207
+ // Check if there are changes to commit
208
+ const statusResult = await $`git status --porcelain`.run({ capture: true });
209
+ const status = statusResult.stdout.trim();
210
+
211
+ if (status) {
212
+ console.log('Changes detected, committing...');
213
+
214
+ // Stage all changes (package.json, package-lock.json, CHANGELOG.md, deleted changesets)
215
+ await $`git add -A`;
216
+
217
+ // Commit with version number as message
218
+ const commitMessage = newVersion;
219
+ const escapedMessage = commitMessage.replace(/"/g, '\\"');
220
+ await $`git commit -m "${escapedMessage}"`;
221
+
222
+ // Push directly to main
223
+ await $`git push origin main`;
224
+
225
+ console.log('\u2705 Version bump committed and pushed to main');
226
+ setOutput('version_committed', 'true');
227
+ } else {
228
+ console.log('No changes to commit');
229
+ setOutput('version_committed', 'false');
230
+ }
231
+ } catch (error) {
232
+ console.error('Error:', error.message);
233
+ process.exit(1);
234
+ }
235
+ }
236
+
237
+ main();