@hubspot/npm-scripts
Advanced tools
| export declare const DEFAULT_INTERNAL_MAIN_BRANCH = "master"; | ||
| export declare const DEFAULT_EXTERNAL_MAIN_BRANCH = "main"; | ||
| export declare const SYNC_BRANCH_PREFIX = "hs-repo-sync"; | ||
| export declare const TEMP_SYNC_DIR = "hs-temp-repo-sync"; | ||
| export declare const PUBLIC_README_PATH = "PUBLIC_README.md"; |
| export const DEFAULT_INTERNAL_MAIN_BRANCH = 'master'; | ||
| export const DEFAULT_EXTERNAL_MAIN_BRANCH = 'main'; | ||
| export const SYNC_BRANCH_PREFIX = 'hs-repo-sync'; | ||
| export const TEMP_SYNC_DIR = 'hs-temp-repo-sync'; | ||
| export const PUBLIC_README_PATH = 'PUBLIC_README.md'; |
| import { BuildRepoSyncScriptOptions, RepoSyncArguments } from './types.js'; | ||
| export declare function handleRepoSync({ sourceDirectory, targetRepositoryUrl, excludePatterns, buildCommitMessage, internalMainBranch, externalMainBranch, version, dryRun, sshKeyPath, }: BuildRepoSyncScriptOptions & RepoSyncArguments): Promise<void>; | ||
| export declare function buildRepoSyncScript({ buildHandlerOptions, }: { | ||
| buildHandlerOptions: BuildRepoSyncScriptOptions; | ||
| }): void; |
+141
| import yargs from 'yargs'; | ||
| import open from 'open'; | ||
| import fs from 'fs-extra'; | ||
| import path from 'path'; | ||
| import { logger } from './utils/logging.js'; | ||
| import { EXIT_CODES } from './utils/process.js'; | ||
| import { DEFAULT_INTERNAL_MAIN_BRANCH, DEFAULT_EXTERNAL_MAIN_BRANCH, SYNC_BRANCH_PREFIX, TEMP_SYNC_DIR, } from './constants/repo-sync.js'; | ||
| import { checkoutBranch, cloneRepo, commitChanges, createBranch, getCurrentGitBranch, getGitEnvWithSshKey, pushBranch, } from './utils/git.js'; | ||
| import { copyFiles } from './utils/filesystem.js'; | ||
| import { confirm } from './utils/prompting.js'; | ||
| export async function handleRepoSync({ sourceDirectory, targetRepositoryUrl, excludePatterns, buildCommitMessage, internalMainBranch, externalMainBranch, version, dryRun, sshKeyPath, }) { | ||
| if (!internalMainBranch) { | ||
| internalMainBranch = DEFAULT_INTERNAL_MAIN_BRANCH; | ||
| } | ||
| if (!externalMainBranch) { | ||
| externalMainBranch = DEFAULT_EXTERNAL_MAIN_BRANCH; | ||
| } | ||
| const originalRepoPath = sourceDirectory | ||
| ? path.resolve(sourceDirectory) | ||
| : process.cwd(); | ||
| let tempDir = ''; | ||
| try { | ||
| logger.log(`Starting sync to target repository: ${targetRepositoryUrl}...`); | ||
| if (dryRun) { | ||
| logger.log(); | ||
| logger.info('(DRY RUN) The script will not push any changes.'); | ||
| logger.log(); | ||
| } | ||
| // Ensure we are on the master branch of the current repository (or continue with confirmation) | ||
| const currentBranch = await getCurrentGitBranch(); | ||
| if (currentBranch !== internalMainBranch) { | ||
| const shouldCheckout = await confirm({ | ||
| message: `You are on ${currentBranch}. Sync from ${internalMainBranch} branch instead?`, | ||
| }); | ||
| if (shouldCheckout) { | ||
| await checkoutBranch(internalMainBranch, true); | ||
| } | ||
| } | ||
| // Create temporary directory | ||
| tempDir = path.join(originalRepoPath, TEMP_SYNC_DIR); | ||
| await fs.ensureDir(tempDir); | ||
| // Get git environment with ssh key to use for target repository operations | ||
| const gitEnvironment = await getGitEnvWithSshKey(sshKeyPath); | ||
| // Clone target repo | ||
| const targetRepoPath = await cloneRepo(targetRepositoryUrl, tempDir, gitEnvironment); | ||
| // Change to target repo directory | ||
| process.chdir(targetRepoPath); | ||
| // Create sync branch | ||
| const syncBranch = `${SYNC_BRANCH_PREFIX}-${version}`; | ||
| await createBranch(syncBranch); | ||
| // Copy files from current repository to target repository tmep dir | ||
| await copyFiles(originalRepoPath, targetRepoPath, excludePatterns); | ||
| // Commit changes | ||
| await commitChanges(buildCommitMessage(version)); | ||
| if (dryRun) { | ||
| logger.log(); | ||
| logger.info(`(DRY RUN) Exiting without pushing changes to ${targetRepositoryUrl}. Preview the files in ${tempDir}`); | ||
| process.exit(EXIT_CODES.SUCCESS); | ||
| } | ||
| const pushConfirmation = await confirm({ | ||
| message: `Push changes to ${targetRepositoryUrl}?`, | ||
| default: true, | ||
| }); | ||
| if (!pushConfirmation) { | ||
| logger.log(`Exiting without pushing changes to ${targetRepositoryUrl}.`); | ||
| process.exit(EXIT_CODES.SUCCESS); | ||
| } | ||
| // Push to target repository | ||
| await pushBranch(syncBranch, gitEnvironment); | ||
| // Create pull request | ||
| const prUrl = `${targetRepositoryUrl}/compare/${externalMainBranch}...${syncBranch}?expand=1`; | ||
| logger.log(); | ||
| logger.log('Opening target repository sync branch pull request URL in browser...'); | ||
| open(prUrl); | ||
| logger.log(); | ||
| logger.log(`Pull Request URL: ${prUrl}`); | ||
| logger.success('Sync to target repository completed successfully'); | ||
| } | ||
| catch (error) { | ||
| logger.error('Error syncing to target repository:', error); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| finally { | ||
| // Cleanup | ||
| if (tempDir && !dryRun) { | ||
| await fs.remove(tempDir); | ||
| } | ||
| process.chdir(originalRepoPath); | ||
| } | ||
| } | ||
| function buildHandler({ sourceDirectory, targetRepositoryUrl, excludePatterns, buildCommitMessage, internalMainBranch, externalMainBranch, }) { | ||
| return async function handler({ version, dryRun, sshKeyPath, }) { | ||
| await handleRepoSync({ | ||
| sourceDirectory, | ||
| targetRepositoryUrl, | ||
| excludePatterns, | ||
| buildCommitMessage, | ||
| internalMainBranch, | ||
| externalMainBranch, | ||
| version, | ||
| dryRun, | ||
| sshKeyPath, | ||
| }); | ||
| }; | ||
| } | ||
| async function builder(yargs) { | ||
| return yargs.options({ | ||
| version: { | ||
| alias: 'v', | ||
| demandOption: true, | ||
| describe: 'Version label for the sync branch', | ||
| type: 'string', | ||
| }, | ||
| dryRun: { | ||
| alias: 'd', | ||
| describe: 'Run through the sync process without pushing changes', | ||
| default: false, | ||
| type: 'boolean', | ||
| }, | ||
| sshKeyPath: { | ||
| alias: 'ssh-key', | ||
| describe: 'Path to the SSH key to use for commands targeting the destination repository', | ||
| type: 'string', | ||
| }, | ||
| }); | ||
| } | ||
| export function buildRepoSyncScript({ buildHandlerOptions, }) { | ||
| try { | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-expressions | ||
| yargs(process.argv.slice(2)) | ||
| .scriptName('yarn') | ||
| .usage('Hubspot repo sync script') | ||
| .command('repo-sync', `Syncs changes from the current repository to a target repository`, builder, buildHandler(buildHandlerOptions)) | ||
| .version(false) | ||
| .help().argv; | ||
| } | ||
| catch (e) { | ||
| logger.error(e); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } |
| export {}; |
| import { describe, it, expect, vi, beforeEach, afterEach, } from 'vitest'; | ||
| import { copyFiles } from '../filesystem.js'; | ||
| import fs from 'fs-extra'; | ||
| import * as path from 'path'; | ||
| import { logger } from '../logging.js'; | ||
| import { TEMP_SYNC_DIR, PUBLIC_README_PATH, } from '../../constants/repo-sync.js'; | ||
| vi.mock('fs-extra'); | ||
| vi.mock('../logging.js', () => ({ | ||
| logger: { | ||
| log: vi.fn(), | ||
| error: vi.fn(), | ||
| }, | ||
| })); | ||
| vi.mock('../process.js', () => ({ | ||
| EXIT_CODES: { | ||
| SUCCESS: 0, | ||
| ERROR: 1, | ||
| }, | ||
| })); | ||
| const mockedFs = fs; | ||
| describe('filesystem', () => { | ||
| beforeEach(() => { | ||
| vi.resetAllMocks(); | ||
| }); | ||
| afterEach(() => { | ||
| vi.restoreAllMocks(); | ||
| }); | ||
| describe('copyFiles', () => { | ||
| it('should copy files from source to destination', async () => { | ||
| const sourcePath = '/source'; | ||
| const destPath = '/dest'; | ||
| mockedFs.readdir | ||
| .mockImplementationOnce(() => Promise.resolve([])) // First call: clear destination (empty) | ||
| .mockImplementationOnce(() => Promise.resolve(['file1.txt', 'file2.txt'])); // Second call: read source | ||
| mockedFs.stat.mockImplementation(() => Promise.resolve({ | ||
| isDirectory: () => false, | ||
| })); | ||
| mockedFs.remove.mockImplementation(() => Promise.resolve(undefined)); | ||
| await copyFiles(sourcePath, destPath); | ||
| expect(mockedFs.readdir).toHaveBeenCalledWith(sourcePath); | ||
| expect(mockedFs.readdir).toHaveBeenCalledWith(destPath); | ||
| expect(mockedFs.copyFile).toHaveBeenCalledWith(path.join(sourcePath, 'file1.txt'), path.join(destPath, 'file1.txt')); | ||
| expect(mockedFs.copyFile).toHaveBeenCalledWith(path.join(sourcePath, 'file2.txt'), path.join(destPath, 'file2.txt')); | ||
| }); | ||
| it('should handle directories recursively', async () => { | ||
| const sourcePath = '/source'; | ||
| const destPath = '/dest'; | ||
| mockedFs.readdir | ||
| .mockImplementationOnce(() => Promise.resolve([])) // First call: clear destination (empty) | ||
| .mockImplementationOnce(() => Promise.resolve(['subdir'])) // Second call: read source | ||
| .mockImplementationOnce(() => Promise.resolve(['file.txt'])); | ||
| mockedFs.stat | ||
| .mockImplementationOnce(() => Promise.resolve({ | ||
| isDirectory: () => true, | ||
| })) | ||
| .mockImplementationOnce(() => Promise.resolve({ | ||
| isDirectory: () => false, | ||
| })); | ||
| mockedFs.remove.mockImplementation(() => Promise.resolve(undefined)); | ||
| await copyFiles(sourcePath, destPath); | ||
| expect(mockedFs.ensureDir).toHaveBeenCalledWith(path.join(destPath, 'subdir')); | ||
| expect(mockedFs.copyFile).toHaveBeenCalledWith(path.join(sourcePath, 'subdir', 'file.txt'), path.join(destPath, 'subdir', 'file.txt')); | ||
| }); | ||
| it('should exclude .git directory', async () => { | ||
| const sourcePath = '/source'; | ||
| const destPath = '/dest'; | ||
| mockedFs.readdir | ||
| .mockImplementationOnce(() => Promise.resolve([])) // First call: clear destination (empty) | ||
| .mockImplementationOnce(() => Promise.resolve(['.git', 'file.txt'])); // Second call: read source | ||
| mockedFs.stat.mockImplementation(() => Promise.resolve({ | ||
| isDirectory: () => false, | ||
| })); | ||
| mockedFs.remove.mockImplementation(() => Promise.resolve(undefined)); | ||
| await copyFiles(sourcePath, destPath); | ||
| expect(mockedFs.copyFile).not.toHaveBeenCalledWith(expect.stringContaining('.git'), expect.anything()); | ||
| expect(mockedFs.copyFile).toHaveBeenCalledWith(path.join(sourcePath, 'file.txt'), path.join(destPath, 'file.txt')); | ||
| }); | ||
| it('should exclude TEMP_SYNC_DIR', async () => { | ||
| const sourcePath = '/source'; | ||
| const destPath = '/dest'; | ||
| mockedFs.readdir | ||
| .mockImplementationOnce(() => Promise.resolve([])) // First call: clear destination (empty) | ||
| .mockImplementationOnce(() => Promise.resolve([TEMP_SYNC_DIR, 'file.txt'])); // Second call: read source | ||
| mockedFs.stat.mockImplementation(() => Promise.resolve({ | ||
| isDirectory: () => false, | ||
| })); | ||
| mockedFs.remove.mockImplementation(() => Promise.resolve(undefined)); | ||
| await copyFiles(sourcePath, destPath); | ||
| expect(mockedFs.copyFile).not.toHaveBeenCalledWith(expect.stringContaining(TEMP_SYNC_DIR), expect.anything()); | ||
| }); | ||
| it('should rename PUBLIC_README.md to README.md', async () => { | ||
| const sourcePath = '/source'; | ||
| const destPath = '/dest'; | ||
| mockedFs.readdir | ||
| .mockImplementationOnce(() => Promise.resolve([])) // First call: clear destination (empty) | ||
| .mockImplementationOnce(() => Promise.resolve([PUBLIC_README_PATH])); // Second call: read source | ||
| mockedFs.stat.mockImplementation(() => Promise.resolve({ | ||
| isDirectory: () => false, | ||
| })); | ||
| mockedFs.remove.mockImplementation(() => Promise.resolve(undefined)); | ||
| await copyFiles(sourcePath, destPath); | ||
| expect(mockedFs.copyFile).toHaveBeenCalledWith(path.join(sourcePath, PUBLIC_README_PATH), path.join(destPath, 'README.md')); | ||
| }); | ||
| it('should respect exclude patterns', async () => { | ||
| const sourcePath = '/source'; | ||
| const destPath = '/dest'; | ||
| const excludePatterns = ['*.log', '*.tmp']; | ||
| mockedFs.readdir | ||
| .mockImplementationOnce(() => Promise.resolve([])) // First call: clear destination (empty) | ||
| .mockImplementationOnce(() => Promise.resolve(['file.log', 'file.txt', 'file.tmp'])); // Second call: read source | ||
| mockedFs.stat.mockImplementation(() => Promise.resolve({ | ||
| isDirectory: () => false, | ||
| })); | ||
| mockedFs.remove.mockImplementation(() => Promise.resolve(undefined)); | ||
| await copyFiles(sourcePath, destPath, excludePatterns); | ||
| expect(mockedFs.copyFile).not.toHaveBeenCalledWith(expect.stringContaining('file.log'), expect.anything()); | ||
| expect(mockedFs.copyFile).not.toHaveBeenCalledWith(expect.stringContaining('file.tmp'), expect.anything()); | ||
| expect(mockedFs.copyFile).toHaveBeenCalledWith(path.join(sourcePath, 'file.txt'), path.join(destPath, 'file.txt')); | ||
| }); | ||
| it('should not remove .git from destination', async () => { | ||
| const sourcePath = '/source'; | ||
| const destPath = '/dest'; | ||
| mockedFs.readdir | ||
| .mockImplementationOnce(() => Promise.resolve(['.git', 'file.txt'])) // First call: clear destination (has .git) | ||
| .mockImplementationOnce(() => Promise.resolve(['file.txt'])); // Second call: read source | ||
| mockedFs.stat.mockImplementation(() => Promise.resolve({ | ||
| isDirectory: () => false, | ||
| })); | ||
| mockedFs.remove.mockImplementation(() => Promise.resolve(undefined)); | ||
| await copyFiles(sourcePath, destPath); | ||
| expect(mockedFs.remove).not.toHaveBeenCalledWith(path.join(destPath, '.git')); | ||
| expect(mockedFs.remove).toHaveBeenCalledWith(path.join(destPath, 'file.txt')); | ||
| }); | ||
| it('should handle errors and exit with error code', async () => { | ||
| const sourcePath = '/source'; | ||
| const destPath = '/dest'; | ||
| const error = new Error('Copy failed'); | ||
| // First readdir succeeds (for clearing dest), then copyToDest fails | ||
| mockedFs.readdir | ||
| .mockImplementationOnce(() => Promise.resolve([])) // First call: clear destination (empty) | ||
| .mockImplementationOnce(() => Promise.reject(error)); // Second call: read source (fails) | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| try { | ||
| await expect(copyFiles(sourcePath, destPath)).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.error)).toHaveBeenCalledWith('Error copying files:', error); | ||
| } | ||
| finally { | ||
| exitSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { getCurrentGitBranch, gitCleanup, getGitEnvWithSshKey, cloneRepo, checkoutBranch, createBranch, commitChanges, pushBranch, } from '../git.js'; | ||
| import { exec as _exec } from 'node:child_process'; | ||
| import fs from 'fs-extra'; | ||
| import { logger } from '../logging.js'; | ||
| import { input } from '../prompting.js'; | ||
| vi.mock('util', () => ({ | ||
| promisify: vi.fn(fn => fn), | ||
| })); | ||
| vi.mock('node:child_process', () => ({ | ||
| exec: vi.fn(), | ||
| })); | ||
| vi.mock('fs-extra', () => ({ | ||
| default: { | ||
| remove: vi.fn(), | ||
| }, | ||
| remove: vi.fn(), | ||
| })); | ||
| vi.mock('../logging.js', () => ({ | ||
| logger: { | ||
| log: vi.fn(), | ||
| error: vi.fn(), | ||
| }, | ||
| })); | ||
| vi.mock('../process.js', () => ({ | ||
| EXIT_CODES: { | ||
| SUCCESS: 0, | ||
| ERROR: 1, | ||
| }, | ||
| })); | ||
| vi.mock('../prompting.js', () => ({ | ||
| input: vi.fn(), | ||
| })); | ||
| const mockedExec = vi.mocked(_exec); | ||
| describe('git', () => { | ||
| beforeEach(() => { | ||
| vi.resetAllMocks(); | ||
| }); | ||
| describe('getCurrentGitBranch', () => { | ||
| it('should return the current git branch name', async () => { | ||
| mockedExec.mockImplementation(() => Promise.resolve({ | ||
| stdout: ' main \n', | ||
| stderr: '', | ||
| })); | ||
| const result = await getCurrentGitBranch(); | ||
| expect(mockedExec).toHaveBeenCalledWith('git rev-parse --abbrev-ref HEAD'); | ||
| expect(result).toBe('main'); | ||
| }); | ||
| it('should trim whitespace from branch name', async () => { | ||
| mockedExec.mockImplementation(() => Promise.resolve({ | ||
| stdout: ' feature-branch \n', | ||
| stderr: '', | ||
| })); | ||
| const result = await getCurrentGitBranch(); | ||
| expect(result).toBe('feature-branch'); | ||
| }); | ||
| }); | ||
| describe('gitCleanup', () => { | ||
| it('should reset HEAD and checkout files', async () => { | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await gitCleanup('1.0.0', false); | ||
| expect(mockedExec).toHaveBeenCalledWith('git reset HEAD~'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git checkout .'); | ||
| expect(mockedExec).not.toHaveBeenCalledWith(expect.stringContaining('git tag -d')); | ||
| }); | ||
| it('should delete tag when deleteTag is true', async () => { | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await gitCleanup('1.0.0', true); | ||
| expect(mockedExec).toHaveBeenCalledWith('git reset HEAD~'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git checkout .'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git tag -d v1.0.0'); | ||
| }); | ||
| }); | ||
| describe('getGitEnvWithSshKey', () => { | ||
| it('should return process.env when sshKeyPath is not provided and user leaves blank', async () => { | ||
| vi.mocked(input).mockResolvedValue(''); | ||
| const result = await getGitEnvWithSshKey(); | ||
| expect(result).toBe(process.env); | ||
| expect(input).toHaveBeenCalled(); | ||
| }); | ||
| it('should return process.env with GIT_SSH_COMMAND when sshKeyPath is provided', async () => { | ||
| const result = await getGitEnvWithSshKey('/path/to/key'); | ||
| expect(result).toEqual({ | ||
| ...process.env, | ||
| GIT_SSH_COMMAND: 'ssh -i /path/to/key -o IdentitiesOnly=yes', | ||
| }); | ||
| expect(input).not.toHaveBeenCalled(); | ||
| }); | ||
| it('should prompt for sshKeyPath when not provided', async () => { | ||
| vi.mocked(input).mockResolvedValue('/custom/path/key'); | ||
| const result = await getGitEnvWithSshKey(); | ||
| expect(input).toHaveBeenCalledWith({ | ||
| message: 'Enter the SSH key path to use (leave blank to use the default):', | ||
| }); | ||
| expect(result).toEqual({ | ||
| ...process.env, | ||
| GIT_SSH_COMMAND: 'ssh -i /custom/path/key -o IdentitiesOnly=yes', | ||
| }); | ||
| }); | ||
| }); | ||
| describe('cloneRepo', () => { | ||
| it('should clone repository with correct git URL format', async () => { | ||
| const repoUrl = 'https://git.mycorp.com/Org/repo-name'; | ||
| const destinationDir = '/tmp'; | ||
| const targetRepoPath = '/tmp/repo-name'; | ||
| vi.mocked(fs.remove).mockResolvedValue(); | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| const result = await cloneRepo(repoUrl, destinationDir); | ||
| expect(vi.mocked(fs.remove)).toHaveBeenCalledWith(targetRepoPath); | ||
| expect(mockedExec).toHaveBeenCalledWith('git clone git@git.mycorp.com:Org/repo-name.git /tmp/repo-name', { env: undefined }); | ||
| expect(result).toBe(targetRepoPath); | ||
| }); | ||
| it('should handle repo URL without protocol', async () => { | ||
| const repoUrl = 'git.mycorp.com/Org/repo-name'; | ||
| const destinationDir = '/tmp'; | ||
| vi.mocked(fs.remove).mockResolvedValue(); | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await cloneRepo(repoUrl, destinationDir); | ||
| expect(mockedExec).toHaveBeenCalledWith('git clone git@git.mycorp.com:Org/repo-name.git /tmp/repo-name', { env: undefined }); | ||
| }); | ||
| it('should use provided git environment', async () => { | ||
| const repoUrl = 'https://git.mycorp.com/Org/repo-name'; | ||
| const destinationDir = '/tmp'; | ||
| const gitEnv = { GIT_SSH_COMMAND: 'ssh -i /path/to/key' }; | ||
| vi.mocked(fs.remove).mockResolvedValue(); | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await cloneRepo(repoUrl, destinationDir, gitEnv); | ||
| expect(mockedExec).toHaveBeenCalledWith('git clone git@git.mycorp.com:Org/repo-name.git /tmp/repo-name', { env: gitEnv }); | ||
| }); | ||
| it('should handle errors and exit with error code', async () => { | ||
| const repoUrl = 'https://git.mycorp.com/Org/repo-name'; | ||
| const destinationDir = '/tmp'; | ||
| const error = new Error('Clone failed'); | ||
| vi.mocked(fs.remove).mockResolvedValue(); | ||
| mockedExec.mockImplementation(() => Promise.reject(error)); | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| try { | ||
| await expect(cloneRepo(repoUrl, destinationDir)).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.error)).toHaveBeenCalledWith('Error cloning repository:', error); | ||
| } | ||
| finally { | ||
| exitSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); | ||
| describe('checkoutBranch', () => { | ||
| it('should checkout branch without pulling', async () => { | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await checkoutBranch('feature-branch', false); | ||
| expect(mockedExec).toHaveBeenCalledWith('git checkout feature-branch'); | ||
| expect(mockedExec).not.toHaveBeenCalledWith(expect.stringContaining('git pull')); | ||
| }); | ||
| it('should checkout branch and pull when pullLatest is true', async () => { | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await checkoutBranch('feature-branch', true); | ||
| expect(mockedExec).toHaveBeenCalledWith('git checkout feature-branch'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git pull origin feature-branch'); | ||
| }); | ||
| it('should handle errors and exit with error code', async () => { | ||
| const error = new Error('Checkout failed'); | ||
| mockedExec.mockImplementation(() => Promise.reject(error)); | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| try { | ||
| await expect(checkoutBranch('feature-branch')).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.error)).toHaveBeenCalledWith('Error checking out branch feature-branch:', error); | ||
| } | ||
| finally { | ||
| exitSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); | ||
| describe('createBranch', () => { | ||
| it('should create and checkout new branch', async () => { | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await createBranch('new-branch'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git checkout -b new-branch'); | ||
| }); | ||
| it('should handle errors and exit with error code', async () => { | ||
| const error = new Error('Create branch failed'); | ||
| mockedExec.mockImplementation(() => Promise.reject(error)); | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| try { | ||
| await expect(createBranch('new-branch')).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.error)).toHaveBeenCalledWith('Error creating branch new-branch:', error); | ||
| } | ||
| finally { | ||
| exitSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); | ||
| describe('commitChanges', () => { | ||
| it('should commit changes when there are staged changes', async () => { | ||
| mockedExec | ||
| .mockImplementationOnce(() => Promise.resolve({ stdout: '', stderr: '' })) | ||
| .mockImplementationOnce(() => Promise.resolve({ stdout: 'M file.txt', stderr: '' })) | ||
| .mockImplementationOnce(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await commitChanges('Test commit message'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git add .'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git status --porcelain'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git commit -m "Test commit message"'); | ||
| }); | ||
| it('should not commit when there are no changes', async () => { | ||
| mockedExec | ||
| .mockImplementationOnce(() => Promise.resolve({ stdout: '', stderr: '' })) | ||
| .mockImplementationOnce(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await commitChanges('Test commit message'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git add .'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git status --porcelain'); | ||
| expect(mockedExec).not.toHaveBeenCalledWith(expect.stringContaining('git commit')); | ||
| expect(vi.mocked(logger.log)).toHaveBeenCalledWith('No changes to commit'); | ||
| }); | ||
| it('should handle errors and exit with error code', async () => { | ||
| const error = new Error('Status check failed'); | ||
| mockedExec | ||
| .mockImplementationOnce(() => Promise.resolve({ stdout: '', stderr: '' })) | ||
| .mockImplementationOnce(() => Promise.reject(error)); | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| try { | ||
| await expect(commitChanges('Test commit message')).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.error)).toHaveBeenCalledWith('Error checking git status'); | ||
| } | ||
| finally { | ||
| exitSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); | ||
| describe('pushBranch', () => { | ||
| it('should push branch to origin', async () => { | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await pushBranch('feature-branch'); | ||
| expect(vi.mocked(logger.log)).toHaveBeenCalledWith('Pushing branch feature-branch...'); | ||
| expect(mockedExec).toHaveBeenCalledWith('git push origin feature-branch', { | ||
| env: undefined, | ||
| }); | ||
| }); | ||
| it('should use provided git environment', async () => { | ||
| const gitEnv = { GIT_SSH_COMMAND: 'ssh -i /path/to/key' }; | ||
| mockedExec.mockImplementation(() => Promise.resolve({ stdout: '', stderr: '' })); | ||
| await pushBranch('feature-branch', gitEnv); | ||
| expect(mockedExec).toHaveBeenCalledWith('git push origin feature-branch', { | ||
| env: gitEnv, | ||
| }); | ||
| }); | ||
| it('should handle errors and exit with error code', async () => { | ||
| const error = new Error('Push failed'); | ||
| mockedExec.mockImplementation(() => Promise.reject(error)); | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| try { | ||
| await expect(pushBranch('feature-branch')).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.error)).toHaveBeenCalledWith('Error pushing branch feature-branch:', error); | ||
| } | ||
| finally { | ||
| exitSpy.mockRestore(); | ||
| } | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; | ||
| import { logger } from '../logging.js'; | ||
| describe('logging', () => { | ||
| const consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => { }); | ||
| beforeEach(() => { | ||
| consoleLogSpy.mockClear(); | ||
| }); | ||
| afterEach(() => { | ||
| consoleLogSpy.mockClear(); | ||
| }); | ||
| describe('logger.log', () => { | ||
| it('should call console.log with provided arguments', () => { | ||
| logger.log('test message', 123); | ||
| expect(consoleLogSpy).toHaveBeenCalledWith('test message', 123); | ||
| }); | ||
| }); | ||
| describe('logger.info', () => { | ||
| it('should call console.log with [INFO] prefix', () => { | ||
| logger.info('info message'); | ||
| expect(consoleLogSpy).toHaveBeenCalled(); | ||
| const callArgs = consoleLogSpy.mock.calls[0]; | ||
| expect(callArgs[0]).toContain('[INFO]'); | ||
| expect(callArgs[1]).toBe('info message'); | ||
| }); | ||
| }); | ||
| describe('logger.success', () => { | ||
| it('should call console.log with [SUCCESS] prefix', () => { | ||
| logger.success('success message'); | ||
| expect(consoleLogSpy).toHaveBeenCalled(); | ||
| const callArgs = consoleLogSpy.mock.calls[0]; | ||
| expect(callArgs[0]).toContain('[SUCCESS]'); | ||
| expect(callArgs[1]).toBe('success message'); | ||
| }); | ||
| }); | ||
| describe('logger.warn', () => { | ||
| it('should call console.log with [WARN] prefix', () => { | ||
| logger.warn('warning message'); | ||
| expect(consoleLogSpy).toHaveBeenCalled(); | ||
| const callArgs = consoleLogSpy.mock.calls[0]; | ||
| expect(callArgs[0]).toContain('[WARN]'); | ||
| expect(callArgs[1]).toBe('warning message'); | ||
| }); | ||
| }); | ||
| describe('logger.error', () => { | ||
| it('should call console.log with [ERROR] prefix', () => { | ||
| logger.error('error message'); | ||
| expect(consoleLogSpy).toHaveBeenCalled(); | ||
| const callArgs = consoleLogSpy.mock.calls[0]; | ||
| expect(callArgs[0]).toContain('[ERROR]'); | ||
| expect(callArgs[1]).toBe('error message'); | ||
| }); | ||
| }); | ||
| describe('logger.table', () => { | ||
| it('should call console.table with provided arguments', () => { | ||
| const consoleTableSpy = vi | ||
| .spyOn(console, 'table') | ||
| .mockImplementation(() => { }); | ||
| const data = { a: 1, b: 2 }; | ||
| logger.table(data); | ||
| expect(consoleTableSpy).toHaveBeenCalledWith(data); | ||
| consoleTableSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { getDistTags, publish, updateDistTag, updateNextTag, updatePreviousTag, } from '../npm.js'; | ||
| import { exec as _exec, spawn } from 'node:child_process'; | ||
| import { logger } from '../logging.js'; | ||
| import { EXIT_CODES } from '../process.js'; | ||
| import { TAG } from '../../constants/release.js'; | ||
| vi.mock('util', () => ({ | ||
| promisify: vi.fn(fn => fn), | ||
| })); | ||
| vi.mock('node:child_process', () => ({ | ||
| exec: vi.fn(), | ||
| spawn: vi.fn(), | ||
| })); | ||
| vi.mock('../logging.js', () => ({ | ||
| logger: { | ||
| log: vi.fn(), | ||
| success: vi.fn(), | ||
| }, | ||
| })); | ||
| vi.mock('../process.js', () => ({ | ||
| EXIT_CODES: { | ||
| SUCCESS: 0, | ||
| ERROR: 1, | ||
| }, | ||
| })); | ||
| const mockedExec = vi.mocked(_exec); | ||
| const mockedSpawn = vi.mocked(spawn); | ||
| describe('npm', () => { | ||
| beforeEach(() => { | ||
| vi.resetAllMocks(); | ||
| }); | ||
| describe('getDistTags', () => { | ||
| it('should return parsed dist tags from npm view', async () => { | ||
| const mockDistTags = { | ||
| latest: '1.0.0', | ||
| next: '1.1.0-beta.1', | ||
| experimental: '1.2.0-experimental.1', | ||
| }; | ||
| mockedExec.mockImplementation(() => Promise.resolve({ | ||
| stdout: JSON.stringify(mockDistTags), | ||
| stderr: '', | ||
| })); | ||
| const result = await getDistTags('@hubspot/test-package'); | ||
| expect(mockedExec).toHaveBeenCalledWith('npm view @hubspot/test-package dist-tags --json'); | ||
| expect(result).toEqual(mockDistTags); | ||
| }); | ||
| it('should handle whitespace in stdout', async () => { | ||
| const mockDistTags = { | ||
| latest: '1.0.0', | ||
| next: '1.1.0-beta.1', | ||
| }; | ||
| mockedExec.mockImplementation(() => Promise.resolve({ | ||
| stdout: `\n${JSON.stringify(mockDistTags)}\n`, | ||
| stderr: '', | ||
| })); | ||
| const result = await getDistTags('@hubspot/test-package'); | ||
| expect(result).toEqual(mockDistTags); | ||
| }); | ||
| }); | ||
| describe('publish', () => { | ||
| it('should publish with correct arguments', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.SUCCESS), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await publish(TAG.LATEST, '123456', false, false, 'https://registry.npmjs.org/'); | ||
| expect(mockedSpawn).toHaveBeenCalledWith('npm', [ | ||
| 'publish', | ||
| '--tag', | ||
| TAG.LATEST, | ||
| '--registry', | ||
| 'https://registry.npmjs.org/', | ||
| '--otp', | ||
| '123456', | ||
| ], { | ||
| stdio: 'inherit', | ||
| cwd: './dist', | ||
| }); | ||
| }); | ||
| it('should include --dry-run flag when isDryRun is true', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.SUCCESS), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await publish(TAG.NEXT, '123456', true, false, 'https://registry.npmjs.org/'); | ||
| expect(mockedSpawn).toHaveBeenCalledWith('npm', [ | ||
| 'publish', | ||
| '--tag', | ||
| TAG.NEXT, | ||
| '--registry', | ||
| 'https://registry.npmjs.org/', | ||
| '--otp', | ||
| '123456', | ||
| '--dry-run', | ||
| ], { | ||
| stdio: 'inherit', | ||
| cwd: './dist', | ||
| }); | ||
| }); | ||
| it('should include --ignore-scripts flag when ignoreScripts is true', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.SUCCESS), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await publish(TAG.LATEST, '123456', false, true, 'https://registry.npmjs.org/'); | ||
| expect(mockedSpawn).toHaveBeenCalledWith('npm', [ | ||
| 'publish', | ||
| '--tag', | ||
| TAG.LATEST, | ||
| '--registry', | ||
| 'https://registry.npmjs.org/', | ||
| '--otp', | ||
| '123456', | ||
| '--ignore-scripts', | ||
| ], { | ||
| stdio: 'inherit', | ||
| cwd: './dist', | ||
| }); | ||
| }); | ||
| it('should include both --dry-run and --ignore-scripts flags when both are true', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.SUCCESS), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await publish(TAG.LATEST, '123456', true, true, 'https://registry.npmjs.org/'); | ||
| expect(mockedSpawn).toHaveBeenCalledWith('npm', [ | ||
| 'publish', | ||
| '--tag', | ||
| TAG.LATEST, | ||
| '--registry', | ||
| 'https://registry.npmjs.org/', | ||
| '--otp', | ||
| '123456', | ||
| '--dry-run', | ||
| '--ignore-scripts', | ||
| ], { | ||
| stdio: 'inherit', | ||
| cwd: './dist', | ||
| }); | ||
| }); | ||
| it('should reject on non-zero exit code', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.ERROR), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await expect(publish(TAG.LATEST, '123456', false, false, 'https://registry.npmjs.org/')).rejects.toThrow('npm publish failed with exit code 1'); | ||
| }); | ||
| }); | ||
| describe('updateDistTag', () => { | ||
| it('should update dist tag with correct arguments', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.SUCCESS), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await updateDistTag('@hubspot/test-package', '1.0.0', 'custom-tag', '123456', false, 'https://registry.npmjs.org/'); | ||
| expect(mockedSpawn).toHaveBeenCalledWith('npm', [ | ||
| 'dist-tag', | ||
| 'add', | ||
| '@hubspot/test-package@1.0.0', | ||
| 'custom-tag', | ||
| '--registry', | ||
| 'https://registry.npmjs.org/', | ||
| '--otp', | ||
| '123456', | ||
| ], { stdio: 'inherit' }); | ||
| expect(vi.mocked(logger.success)).toHaveBeenCalledWith('custom-tag tag updated successfully'); | ||
| }); | ||
| it('should skip execution and log dry run message when isDryRun is true', async () => { | ||
| await updateDistTag('@hubspot/test-package', '1.0.0', 'custom-tag', '123456', true, 'https://registry.npmjs.org/'); | ||
| expect(mockedSpawn).not.toHaveBeenCalled(); | ||
| expect(vi.mocked(logger.log)).toHaveBeenCalledWith(expect.stringContaining('Dry run: skipping run of')); | ||
| }); | ||
| it('should reject on non-zero exit code', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.ERROR), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await expect(updateDistTag('@hubspot/test-package', '1.0.0', 'custom-tag', '123456', false, 'https://registry.npmjs.org/')).rejects.toThrow('npm dist-tag add failed with exit code 1'); | ||
| }); | ||
| }); | ||
| describe('updateNextTag', () => { | ||
| it('should call updateDistTag with TAG.NEXT', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.SUCCESS), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await updateNextTag('@hubspot/test-package', '1.0.0', '123456', false, 'https://registry.npmjs.org/'); | ||
| expect(mockedSpawn).toHaveBeenCalledWith('npm', [ | ||
| 'dist-tag', | ||
| 'add', | ||
| '@hubspot/test-package@1.0.0', | ||
| TAG.NEXT, | ||
| '--registry', | ||
| 'https://registry.npmjs.org/', | ||
| '--otp', | ||
| '123456', | ||
| ], { stdio: 'inherit' }); | ||
| expect(vi.mocked(logger.success)).toHaveBeenCalledWith(`${TAG.NEXT} tag updated successfully`); | ||
| }); | ||
| }); | ||
| describe('updatePreviousTag', () => { | ||
| it('should call updateDistTag with TAG.PREVIOUS', async () => { | ||
| const mockChildProcess = { | ||
| on: vi.fn((event, callback) => { | ||
| if (event === 'close') { | ||
| setTimeout(() => callback(EXIT_CODES.SUCCESS), 0); | ||
| } | ||
| return mockChildProcess; | ||
| }), | ||
| }; | ||
| mockedSpawn.mockImplementation(() => mockChildProcess); | ||
| await updatePreviousTag('@hubspot/test-package', '8.3.6', '123456', false, 'https://registry.npmjs.org/'); | ||
| expect(mockedSpawn).toHaveBeenCalledWith('npm', [ | ||
| 'dist-tag', | ||
| 'add', | ||
| '@hubspot/test-package@8.3.6', | ||
| TAG.PREVIOUS, | ||
| '--registry', | ||
| 'https://registry.npmjs.org/', | ||
| '--otp', | ||
| '123456', | ||
| ], { stdio: 'inherit' }); | ||
| expect(vi.mocked(logger.success)).toHaveBeenCalledWith(`${TAG.PREVIOUS} tag updated successfully`); | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| import { describe, it, expect } from 'vitest'; | ||
| import { EXIT_CODES } from '../process.js'; | ||
| describe('process', () => { | ||
| describe('EXIT_CODES', () => { | ||
| it('should have SUCCESS equal to 0', () => { | ||
| expect(EXIT_CODES.SUCCESS).toBe(0); | ||
| }); | ||
| it('should have ERROR equal to 1', () => { | ||
| expect(EXIT_CODES.ERROR).toBe(1); | ||
| }); | ||
| }); | ||
| }); |
| export {}; |
| import { describe, it, expect, vi, beforeEach } from 'vitest'; | ||
| import { confirm, input, checkbox } from '../prompting.js'; | ||
| import { logger } from '../logging.js'; | ||
| import _confirm from '@inquirer/confirm'; | ||
| import _input from '@inquirer/input'; | ||
| import _checkbox from '@inquirer/checkbox'; | ||
| vi.mock('@inquirer/confirm', () => ({ | ||
| default: vi.fn(), | ||
| })); | ||
| vi.mock('@inquirer/input', () => ({ | ||
| default: vi.fn(), | ||
| })); | ||
| vi.mock('@inquirer/checkbox', () => ({ | ||
| default: vi.fn(), | ||
| })); | ||
| vi.mock('../logging.js', () => ({ | ||
| logger: { | ||
| log: vi.fn(), | ||
| }, | ||
| })); | ||
| describe('prompting', () => { | ||
| beforeEach(() => { | ||
| vi.clearAllMocks(); | ||
| }); | ||
| describe('confirm', () => { | ||
| it('should return the result from inquirer confirm', async () => { | ||
| const mockConfirmFn = vi.mocked(_confirm); | ||
| mockConfirmFn.mockResolvedValue(true); | ||
| const result = await confirm({ message: 'Are you sure?' }); | ||
| expect(result).toBe(true); | ||
| expect(mockConfirmFn).toHaveBeenCalledWith({ message: 'Are you sure?' }); | ||
| }); | ||
| it('should exit on ExitPromptError', async () => { | ||
| const mockConfirmFn = vi.mocked(_confirm); | ||
| const exitError = new Error('User exited'); | ||
| exitError.name = 'ExitPromptError'; | ||
| mockConfirmFn.mockRejectedValue(exitError); | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| await expect(confirm({ message: 'Test' })).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.log)).toHaveBeenCalledWith('Exiting...'); | ||
| exitSpy.mockRestore(); | ||
| }); | ||
| it('should rethrow non-ExitPromptError', async () => { | ||
| const mockConfirmFn = vi.mocked(_confirm); | ||
| const otherError = new Error('Other error'); | ||
| mockConfirmFn.mockRejectedValue(otherError); | ||
| await expect(confirm({ message: 'Test' })).rejects.toThrow('Other error'); | ||
| }); | ||
| }); | ||
| describe('input', () => { | ||
| it('should return the result from inquirer input', async () => { | ||
| const mockInputFn = vi.mocked(_input); | ||
| mockInputFn.mockResolvedValue('test input'); | ||
| const result = await input({ message: 'Enter value:' }); | ||
| expect(result).toBe('test input'); | ||
| expect(mockInputFn).toHaveBeenCalledWith({ message: 'Enter value:' }); | ||
| }); | ||
| it('should exit on ExitPromptError', async () => { | ||
| const mockInputFn = vi.mocked(_input); | ||
| const exitError = new Error('User exited'); | ||
| exitError.name = 'ExitPromptError'; | ||
| mockInputFn.mockRejectedValue(exitError); | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| await expect(input({ message: 'Test' })).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.log)).toHaveBeenCalledWith('Exiting...'); | ||
| exitSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| describe('checkbox', () => { | ||
| it('should return the result from inquirer checkbox', async () => { | ||
| const mockCheckboxFn = vi.mocked(_checkbox); | ||
| mockCheckboxFn.mockResolvedValue(['option1', 'option2']); | ||
| const result = await checkbox({ | ||
| message: 'Select options:', | ||
| choices: [{ value: 'option1' }, { value: 'option2' }], | ||
| }); | ||
| expect(result).toEqual(['option1', 'option2']); | ||
| expect(mockCheckboxFn).toHaveBeenCalledWith({ | ||
| message: 'Select options:', | ||
| choices: [{ value: 'option1' }, { value: 'option2' }], | ||
| }); | ||
| }); | ||
| it('should exit on ExitPromptError', async () => { | ||
| const mockCheckboxFn = vi.mocked(_checkbox); | ||
| const exitError = new Error('User exited'); | ||
| exitError.name = 'ExitPromptError'; | ||
| mockCheckboxFn.mockRejectedValue(exitError); | ||
| const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { | ||
| throw new Error('process.exit called'); | ||
| }); | ||
| await expect(checkbox({ message: 'Test', choices: [{ value: 'option1' }] })).rejects.toThrow('process.exit called'); | ||
| expect(vi.mocked(logger.log)).toHaveBeenCalledWith('Exiting...'); | ||
| exitSpy.mockRestore(); | ||
| }); | ||
| }); | ||
| }); |
| export declare function copyFiles(sourcePath: string, destPath: string, excludePatterns?: string[]): Promise<void>; |
| import path from 'path'; | ||
| import fs from 'fs-extra'; | ||
| import { minimatch } from 'minimatch'; | ||
| import { logger } from './logging.js'; | ||
| import { PUBLIC_README_PATH, TEMP_SYNC_DIR } from '../constants/repo-sync.js'; | ||
| import { EXIT_CODES } from './process.js'; | ||
| function getDestItemPath(destPath, item) { | ||
| // Rename PUBLIC_README.md to README.md | ||
| if (item === PUBLIC_README_PATH) { | ||
| return path.join(destPath, 'README.md'); | ||
| } | ||
| return path.join(destPath, item); | ||
| } | ||
| function shouldExclude(relativePath, excludePatterns) { | ||
| // Always exclude the temporary sync directory | ||
| if (relativePath === TEMP_SYNC_DIR || | ||
| relativePath.startsWith(TEMP_SYNC_DIR + '/')) { | ||
| return true; | ||
| } | ||
| // Always exclude .git directory | ||
| if (relativePath === '.git' || relativePath.startsWith('.git/')) { | ||
| return true; | ||
| } | ||
| if (!excludePatterns) { | ||
| return false; | ||
| } | ||
| return excludePatterns.some(pattern => { | ||
| // Use minimatch for proper glob pattern matching | ||
| // matchBase: true allows patterns like "*.log" to match at any level if no "/" is in pattern | ||
| // dot: true allows matching dotfiles | ||
| return minimatch(relativePath, pattern, { matchBase: true, dot: true }); | ||
| }); | ||
| } | ||
| async function copyToDest(sourcePath, destPath, sourceRoot, excludePatterns) { | ||
| const items = await fs.readdir(sourcePath); | ||
| for (const item of items) { | ||
| const sourceItem = path.join(sourcePath, item); | ||
| const destItem = getDestItemPath(destPath, item); | ||
| // Calculate relative path from the source root for pattern matching | ||
| const relativePath = path.relative(sourceRoot, sourceItem); | ||
| // Skip excluded patterns and temp directories | ||
| if (shouldExclude(relativePath, excludePatterns)) { | ||
| continue; | ||
| } | ||
| const stats = await fs.stat(sourceItem); | ||
| if (stats.isDirectory()) { | ||
| await fs.ensureDir(destItem); | ||
| await copyToDest(sourceItem, destItem, sourceRoot, excludePatterns); | ||
| } | ||
| else { | ||
| await fs.copyFile(sourceItem, destItem); | ||
| } | ||
| } | ||
| } | ||
| export async function copyFiles(sourcePath, destPath, excludePatterns) { | ||
| logger.log(`Copying files from ${sourcePath} to ${destPath}...`); | ||
| // Clear the destination directory first (except .git) | ||
| const items = await fs.readdir(destPath); | ||
| for (const item of items) { | ||
| if (item !== '.git') { | ||
| const itemPath = path.join(destPath, item); | ||
| await fs.remove(itemPath); | ||
| } | ||
| } | ||
| try { | ||
| await copyToDest(sourcePath, destPath, sourcePath, excludePatterns); | ||
| } | ||
| catch (error) { | ||
| logger.error('Error copying files:', error); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } |
| import { VscodeReleaseScriptBase } from './types.js'; | ||
| export declare function buildVscodeReleaseScript({ packageJsonLocation, buildHandlerOptions, }: { | ||
| packageJsonLocation: string; | ||
| buildHandlerOptions: VscodeReleaseScriptBase; | ||
| }): void; |
| import { exec as _exec, spawn } from 'node:child_process'; | ||
| import { promisify } from 'util'; | ||
| import path from 'path'; | ||
| import yargs from 'yargs'; | ||
| import semver from 'semver'; | ||
| import open from 'open'; | ||
| import fs from 'fs-extra'; | ||
| import { logger } from './utils/logging.js'; | ||
| import { EXIT_CODES } from './utils/process.js'; | ||
| import { DEFAULT_MAIN_BRANCH, VSCODE_VERSION_INCREMENT_OPTIONS, } from './constants/release.js'; | ||
| import { getCurrentGitBranch } from './utils/git.js'; | ||
| import { confirm, input } from './utils/prompting.js'; | ||
| const exec = promisify(_exec); | ||
| function runVsceCommand(script) { | ||
| return new Promise((resolve, reject) => { | ||
| const child = spawn('yarn', [script], { | ||
| stdio: ['pipe', 'inherit', 'inherit'], | ||
| }); | ||
| child.stdin?.write('y\n'); | ||
| child.stdin?.end(); | ||
| child.on('close', code => { | ||
| if (code !== EXIT_CODES.SUCCESS) { | ||
| reject(new Error(`"yarn ${script}" failed with exit code ${code}`)); | ||
| } | ||
| else { | ||
| resolve(); | ||
| } | ||
| }); | ||
| }); | ||
| } | ||
| async function getCommitsSinceLastTag() { | ||
| try { | ||
| const { stdout: lastTag } = await exec('git describe --tags --abbrev=0 2>/dev/null'); | ||
| const { stdout } = await exec(`git log --oneline ${lastTag.trim()}..HEAD`); | ||
| return stdout | ||
| .trim() | ||
| .split('\n') | ||
| .filter(line => line.length > 0); | ||
| } | ||
| catch { | ||
| const { stdout } = await exec('git log --oneline -20'); | ||
| return stdout | ||
| .trim() | ||
| .split('\n') | ||
| .filter(line => line.length > 0); | ||
| } | ||
| } | ||
| async function promptForChangelogEntries(commits) { | ||
| if (commits.length > 0) { | ||
| logger.log('\nRecent commits since last release:'); | ||
| commits.forEach(commit => logger.log(` ${commit}`)); | ||
| } | ||
| logger.log('\nEnter changelog entries (one per prompt, empty line to finish):'); | ||
| const entries = []; | ||
| let entry = await input({ | ||
| message: 'Changelog entry (leave empty to finish):', | ||
| }); | ||
| while (entry.trim().length > 0) { | ||
| entries.push(entry.trim()); | ||
| entry = await input({ | ||
| message: 'Changelog entry (leave empty to finish):', | ||
| }); | ||
| } | ||
| return entries; | ||
| } | ||
| async function updateChangelog(version, entries, changelogPath) { | ||
| const entriesText = entries.map(e => `- ${e}`).join('\n'); | ||
| const newSection = `## [${version}]\n${entriesText}\n`; | ||
| let existing = ''; | ||
| try { | ||
| existing = await fs.readFile(changelogPath, 'utf-8'); | ||
| } | ||
| catch { | ||
| logger.log('No existing CHANGELOG.md found, creating a new one.'); | ||
| } | ||
| const headerMatch = existing.match(/^(# .+\n+)/); | ||
| let updated; | ||
| if (headerMatch) { | ||
| const header = headerMatch[1]; | ||
| const rest = existing.slice(header.length); | ||
| updated = `${header}${newSection}\n${rest}`; | ||
| } | ||
| else if (existing.length > 0) { | ||
| updated = `${newSection}\n${existing}`; | ||
| } | ||
| else { | ||
| updated = `# Changelog\n\n${newSection}\n`; | ||
| } | ||
| await fs.writeFile(changelogPath, updated, 'utf-8'); | ||
| logger.success(`Updated ${changelogPath}`); | ||
| } | ||
| async function createDraftPR({ baseBranch, title, body, }) { | ||
| logger.log('\nCreating draft pull request...'); | ||
| try { | ||
| const { stdout } = await exec(`gh pr create --draft --base ${baseBranch} --title "${title}" --body "${body.replace(/"/g, '\\"')}"`); | ||
| const prUrl = stdout.trim(); | ||
| logger.success(`Draft PR created: ${prUrl}`); | ||
| return prUrl; | ||
| } | ||
| catch (error) { | ||
| logger.error('Failed to create pull request:', error); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } | ||
| async function createGithubRelease({ tag, title, notes, }) { | ||
| logger.log('\nCreating GitHub release...'); | ||
| try { | ||
| const { stdout } = await exec(`gh release create ${tag} --title "${title}" --notes "${notes.replace(/"/g, '\\"')}" --draft`); | ||
| const releaseUrl = stdout.trim(); | ||
| logger.success(`Draft GitHub release created: ${releaseUrl}`); | ||
| return releaseUrl; | ||
| } | ||
| catch (error) { | ||
| logger.error('Failed to create GitHub release:', error); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } | ||
| function generateSlackMessage({ extensionName, version, changelogEntries, marketplaceUrl, repositoryUrl, }) { | ||
| const changes = changelogEntries.map(e => `- ${e}`).join('\n'); | ||
| return [ | ||
| `\u{1F4E6} ${extensionName} VSCode Extension v${version} Released`, | ||
| '', | ||
| '*Changes:*', | ||
| changes, | ||
| '', | ||
| '*Links:*', | ||
| `- Marketplace: ${marketplaceUrl}`, | ||
| `- Changelog: ${repositoryUrl}/blob/master/CHANGELOG.md`, | ||
| ].join('\n'); | ||
| } | ||
| function printSlackMessage(message, channel) { | ||
| logger.log('\n' + '='.repeat(60)); | ||
| logger.log(`Slack message for ${channel}:`); | ||
| logger.log('='.repeat(60)); | ||
| logger.log(message); | ||
| logger.log('='.repeat(60)); | ||
| } | ||
| function buildHandler({ build, packageName, localVersion, repositoryUrl, mainBranch, marketplaceUrl, extensionName, slackChannel, }) { | ||
| return async function handler({ versionIncrement, dryRun, skipTests, }) { | ||
| const branch = await getCurrentGitBranch(); | ||
| const isDryRun = Boolean(dryRun); | ||
| const resolvedSlackChannel = slackChannel || '#cli-dev'; | ||
| if (branch !== mainBranch) { | ||
| logger.error(`Releases can only be published from the ${mainBranch} branch. Current branch: ${branch}`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| if (!skipTests) { | ||
| const userHasTested = await confirm({ | ||
| message: 'Have you tested the pre-release package locally?', | ||
| default: false, | ||
| }); | ||
| if (!userHasTested) { | ||
| logger.log('\nBuilding and packaging the pre-release for testing...\n'); | ||
| if (build && typeof build === 'function') { | ||
| await build(); | ||
| } | ||
| else { | ||
| logger.log('Installing dependencies...'); | ||
| await exec('yarn install'); | ||
| logger.log('Running linting...'); | ||
| await exec('yarn lint'); | ||
| } | ||
| logger.log('\nPackaging pre-release...'); | ||
| await runVsceCommand('vsce:prerelease'); | ||
| logger.success('Pre-release package created.'); | ||
| logger.log('\nTo test the package locally:'); | ||
| logger.log(' 1. Open VS Code'); | ||
| logger.log(' 2. Go to the Extensions panel'); | ||
| logger.log(' 3. Click "..." menu → "Install from VSIX..."'); | ||
| logger.log(' 4. Select the .vsix file created above'); | ||
| logger.log(' 5. Test key functionality\n'); | ||
| const readyToContinue = await confirm({ | ||
| message: 'Continue when you have finished testing the pre-release.', | ||
| default: false, | ||
| }); | ||
| if (!readyToContinue) { | ||
| logger.log('Release aborted.'); | ||
| process.exit(EXIT_CODES.SUCCESS); | ||
| } | ||
| } | ||
| } | ||
| const newVersion = semver.inc(localVersion, versionIncrement); | ||
| if (!newVersion) { | ||
| logger.error('Error incrementing version.'); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| if (isDryRun) { | ||
| logger.log('\nDRY RUN'); | ||
| } | ||
| logger.log(`\nCurrent version: ${localVersion}`); | ||
| logger.log(`New version to release: ${newVersion}`); | ||
| const shouldRelease = await confirm({ | ||
| message: `Release version ${newVersion}?`, | ||
| }); | ||
| if (!shouldRelease) { | ||
| process.exit(EXIT_CODES.SUCCESS); | ||
| } | ||
| logger.log(`\nUpdating version to ${newVersion}...`); | ||
| await exec(`yarn version --no-git-tag-version --new-version ${newVersion}`); | ||
| logger.success('Version updated in package.json'); | ||
| const changelogPath = path.resolve('CHANGELOG.md'); | ||
| const commits = await getCommitsSinceLastTag(); | ||
| const changelogEntries = await promptForChangelogEntries(commits); | ||
| if (changelogEntries.length === 0) { | ||
| logger.warn('No changelog entries provided. Skipping CHANGELOG update.'); | ||
| } | ||
| else { | ||
| await updateChangelog(newVersion, changelogEntries, changelogPath); | ||
| } | ||
| logger.log('\nPackaging regular release...'); | ||
| await runVsceCommand('vsce:package'); | ||
| logger.success('Regular release package created.'); | ||
| const confirmRegularRelease = await confirm({ | ||
| message: 'Test the regular release .vsix and confirm it works. Ready to proceed?', | ||
| default: false, | ||
| }); | ||
| if (!confirmRegularRelease) { | ||
| logger.log('Release aborted after regular release packaging.'); | ||
| logger.log('Note: package.json version and CHANGELOG.md have been updated locally. Revert if needed.'); | ||
| process.exit(EXIT_CODES.SUCCESS); | ||
| } | ||
| const releaseBranch = `release/v${newVersion}`; | ||
| const changelogNotes = changelogEntries.length > 0 | ||
| ? changelogEntries.map(e => `- ${e}`).join('\n') | ||
| : 'See CHANGELOG.md for details.'; | ||
| if (isDryRun) { | ||
| logger.log(`\nDry run: would create branch ${releaseBranch}`); | ||
| logger.log('Dry run: would commit package.json + CHANGELOG.md'); | ||
| logger.log(`Dry run: would push ${releaseBranch}`); | ||
| logger.log(`Dry run: would create draft PR against ${mainBranch}`); | ||
| logger.log(`Dry run: would create draft GH release v${newVersion}`); | ||
| printSlackMessage(generateSlackMessage({ | ||
| extensionName, | ||
| version: newVersion, | ||
| changelogEntries, | ||
| marketplaceUrl, | ||
| repositoryUrl, | ||
| }), resolvedSlackChannel); | ||
| logger.log('\nDry run release finished successfully.'); | ||
| process.exit(EXIT_CODES.SUCCESS); | ||
| } | ||
| logger.log(`\nCreating release branch ${releaseBranch}...`); | ||
| await exec(`git checkout -b ${releaseBranch}`); | ||
| logger.log('Committing changes...'); | ||
| await exec('git add package.json CHANGELOG.md'); | ||
| await exec(`git commit -m "Bump version to ${newVersion}"`); | ||
| logger.log(`Pushing ${releaseBranch}...`); | ||
| await exec(`git push -u origin ${releaseBranch}`); | ||
| const prUrl = await createDraftPR({ | ||
| baseBranch: mainBranch, | ||
| title: `Release v${newVersion}`, | ||
| body: `## Release v${newVersion}\n\n${changelogNotes}`, | ||
| }); | ||
| const releaseUrl = await createGithubRelease({ | ||
| tag: `v${newVersion}`, | ||
| title: `Version ${newVersion}`, | ||
| notes: changelogNotes, | ||
| }); | ||
| printSlackMessage(generateSlackMessage({ | ||
| extensionName, | ||
| version: newVersion, | ||
| changelogEntries, | ||
| marketplaceUrl, | ||
| repositoryUrl, | ||
| }), resolvedSlackChannel); | ||
| logger.log('\nNext steps:'); | ||
| logger.log(' 1. Upload the .vsix file to the VS Code Marketplace:'); | ||
| logger.log(` ${marketplaceUrl.replace('/items?itemName=', '/manage/publishers/')}`); | ||
| logger.log(' 2. Copy the Slack message above and post it'); | ||
| logger.log(` 3. Merge the PR: ${prUrl}`); | ||
| if (releaseUrl) { | ||
| logger.log(` 4. Publish the GH release: ${releaseUrl}`); | ||
| } | ||
| await open('https://marketplace.visualstudio.com/manage/publishers/hubspot'); | ||
| logger.success(`\n${packageName} version ${newVersion} release prepared successfully!`); | ||
| }; | ||
| } | ||
| async function builder(yargs) { | ||
| return yargs.options({ | ||
| versionIncrement: { | ||
| alias: 'v', | ||
| demandOption: true, | ||
| describe: 'SemVer increment type for the next release', | ||
| choices: VSCODE_VERSION_INCREMENT_OPTIONS, | ||
| }, | ||
| dryRun: { | ||
| alias: 'd', | ||
| describe: 'Run through the release process without pushing or creating releases', | ||
| default: false, | ||
| type: 'boolean', | ||
| }, | ||
| skipTests: { | ||
| describe: 'Skip the pre-release testing prompt', | ||
| default: false, | ||
| type: 'boolean', | ||
| }, | ||
| }); | ||
| } | ||
| export function buildVscodeReleaseScript({ packageJsonLocation, buildHandlerOptions, }) { | ||
| try { | ||
| const packageJsonContents = fs.readJsonSync(packageJsonLocation); | ||
| // eslint-disable-next-line @typescript-eslint/no-unused-expressions | ||
| yargs(process.argv.slice(2)) | ||
| .scriptName('yarn') | ||
| .usage('HubSpot VSCode extension release script') | ||
| .command('release', `Create a new release of the ${packageJsonContents.name} VSCode extension`, builder, buildHandler({ | ||
| ...buildHandlerOptions, | ||
| packageName: packageJsonContents.name, | ||
| localVersion: packageJsonContents.version, | ||
| mainBranch: buildHandlerOptions.mainBranch || DEFAULT_MAIN_BRANCH, | ||
| })) | ||
| .version(false) | ||
| .help().argv; | ||
| } | ||
| catch (e) { | ||
| logger.error(e); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } |
+8
-4
| { | ||
| "name": "@hubspot/npm-scripts", | ||
| "version": "0.0.4-experimental.1", | ||
| "version": "0.0.4-experimental.2", | ||
| "description": "Scripts for working with npm packages in the HubSpot ecosystem", | ||
@@ -17,3 +17,5 @@ "author": "", | ||
| "release": "yarn tsx ./scripts/release.ts release", | ||
| "test": "echo \"Error: no test specified\" && exit 1" | ||
| "repo-sync": "yarn tsx ./scripts/repo-sync.ts repo-sync", | ||
| "test": "vitest run", | ||
| "test-dev": "vitest" | ||
| }, | ||
@@ -25,2 +27,3 @@ "dependencies": { | ||
| "fs-extra": "^11.3.2", | ||
| "minimatch": "^10.0.1", | ||
| "open": "^10.2.0", | ||
@@ -34,3 +37,3 @@ "semver": "^7.7.2", | ||
| "@types/fs-extra": "^11.0.4", | ||
| "@types/node": "^24.5.2", | ||
| "@types/node": "^25.0.3", | ||
| "@types/semver": "^7.7.1", | ||
@@ -47,3 +50,4 @@ "@types/yargs": "^17.0.33", | ||
| "tsx": "^4.20.5", | ||
| "typescript": "^5.6.2" | ||
| "typescript": "^5.6.2", | ||
| "vitest": "^2.1.8" | ||
| }, | ||
@@ -50,0 +54,0 @@ "lint-staged": { |
+67
-2
@@ -14,2 +14,3 @@ # @hubspot/npm-scripts | ||
| It can be used via npx | ||
| ```bash | ||
@@ -68,4 +69,4 @@ npx @hubspot/npm-scripts hubspot-linking | ||
| // Optional function to run after latest releases | ||
| } | ||
| } | ||
| }, | ||
| }, | ||
| }); | ||
@@ -118,1 +119,65 @@ ``` | ||
| ### Repo Sync Script Builder (`buildRepoSyncScript`) | ||
| A utility function that creates a script to sync changes from an internal repository to a target repository (e.g., syncing internal packages to public GitHub). | ||
| #### Usage | ||
| Create a repo sync script file (e.g., `scripts/repo-sync.ts`): | ||
| ```typescript | ||
| import { buildRepoSyncScript } from '@hubspot/npm-scripts'; | ||
| buildRepoSyncScript({ | ||
| buildHandlerOptions: { | ||
| targetRepositoryOrg: 'https://github.com/your-org', | ||
| targetRepositoryName: 'public-repo', | ||
| buildCommitMessage: version => | ||
| `Sync changes from internal repo for version ${version}`, | ||
| mainBranch: 'main', // optional, defaults to 'master' | ||
| excludePatterns: [ | ||
| // optional, custom patterns to exclude from sync | ||
| 'internal-only-file.ts', | ||
| 'tests', | ||
| 'tests/**', | ||
| ], | ||
| }, | ||
| }); | ||
| ``` | ||
| **NOTE:** The sync script will always ignore the `hs-temp-repo-sync` and `.git` directories | ||
| Then add to your `package.json`: | ||
| ```json | ||
| { | ||
| "scripts": { | ||
| "repo-sync": "tsx ./scripts/repo-sync.ts repo-sync" | ||
| } | ||
| } | ||
| ``` | ||
| #### Repo Sync Commands | ||
| ```bash | ||
| # Sync changes to target repository | ||
| yarn repo-sync -v=1.0.0 --ssh-key=~/.ssh/id_rsa | ||
| # Dry run (test without pushing) | ||
| yarn repo-sync -v=1.0.0 -d | ||
| ``` | ||
| #### Parameters | ||
| - `-v, --version`: Version to label the sync branch (required) | ||
| - `-d, --dryRun`: Run without pushing changes | ||
| - `--ssh-key`: Path to SSH key for target repository operations | ||
| #### Features | ||
| - **Branch protection**: Ensures sync happens from the correct branch | ||
| - **Automatic branching**: Creates a sync branch in the target repository | ||
| - **GitHub integration**: Opens PR page automatically after push | ||
| - **Cleanup support**: Removes temporary directories after completion | ||
| This script supports a `PUBLIC_README.md` pattern that allows you to maintain an internal readme file that does not sync to the target repo. The `PUBLIC_README.md` file will sync into the target repo as `README.md`. |
@@ -6,2 +6,3 @@ export declare const DEFAULT_MAIN_BRANCH = "master"; | ||
| readonly EXPERIMENTAL: "experimental"; | ||
| readonly PREVIOUS: "previous"; | ||
| }; | ||
@@ -15,3 +16,4 @@ export declare const INCREMENT: { | ||
| export declare const VERSION_INCREMENT_OPTIONS: readonly ["patch", "minor", "major", "prerelease"]; | ||
| export declare const TAG_OPTIONS: readonly ["latest", "next", "experimental"]; | ||
| export declare const TAG_OPTIONS: readonly ["latest", "next", "experimental", "previous"]; | ||
| export declare const VSCODE_VERSION_INCREMENT_OPTIONS: readonly ["patch", "minor", "major"]; | ||
| export declare const PRERELEASE_IDENTIFIER: { | ||
@@ -18,0 +20,0 @@ readonly NEXT: "beta"; |
@@ -6,2 +6,3 @@ export const DEFAULT_MAIN_BRANCH = 'master'; | ||
| EXPERIMENTAL: 'experimental', | ||
| PREVIOUS: 'previous', | ||
| }; | ||
@@ -20,3 +21,13 @@ export const INCREMENT = { | ||
| ]; | ||
| export const TAG_OPTIONS = [TAG.LATEST, TAG.NEXT, TAG.EXPERIMENTAL]; | ||
| export const TAG_OPTIONS = [ | ||
| TAG.LATEST, | ||
| TAG.NEXT, | ||
| TAG.EXPERIMENTAL, | ||
| TAG.PREVIOUS, | ||
| ]; | ||
| export const VSCODE_VERSION_INCREMENT_OPTIONS = [ | ||
| INCREMENT.PATCH, | ||
| INCREMENT.MINOR, | ||
| INCREMENT.MAJOR, | ||
| ]; | ||
| export const PRERELEASE_IDENTIFIER = { | ||
@@ -23,0 +34,0 @@ NEXT: 'beta', |
+75
-23
@@ -11,26 +11,45 @@ import { exec as _exec, execSync } from 'node:child_process'; | ||
| import { gitCleanup, getCurrentGitBranch } from './utils/git.js'; | ||
| import { getDistTags, publish, updateNextTag } from './utils/npm.js'; | ||
| import { getDistTags, publish, updateNextTag, updatePreviousTag, } from './utils/npm.js'; | ||
| import { confirm, input } from './utils/prompting.js'; | ||
| const exec = promisify(_exec); | ||
| function buildHandler({ build, postLatestRelease, packageName, localVersion, registry, repositoryUrl, mainBranch, }) { | ||
| return async function handler({ versionIncrement, tag, dryRun, }) { | ||
| function buildHandler({ build, postLatestRelease, packageName, localVersion, registry, repositoryUrl, mainBranch, previousBranch, ignoreScripts, }) { | ||
| return async function handler({ versionIncrement, tag, dryRun, skipBranchValidation, skipVersionCheck, }) { | ||
| const branch = await getCurrentGitBranch(); | ||
| const isExperimental = tag === TAG.EXPERIMENTAL; | ||
| const isPrevious = tag === TAG.PREVIOUS; | ||
| const isDryRun = Boolean(dryRun); | ||
| if (isExperimental && branch === mainBranch) { | ||
| logger.error(`Releases to experimental tag cannot be published from the ${mainBranch} branch`); | ||
| if (isPrevious && !previousBranch) { | ||
| logger.error('Cannot release to previous tag: previousBranch is not configured'); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| else if (!isExperimental && branch !== mainBranch) { | ||
| logger.error(`Releases to latest and next tags can only be published from the ${mainBranch} branch`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| if (!skipBranchValidation) { | ||
| if (isExperimental && | ||
| (branch === mainBranch || branch === previousBranch)) { | ||
| logger.error(`Releases to experimental tag cannot be published from the ${branch} branch`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| else if (isPrevious && branch !== previousBranch) { | ||
| logger.error(`Releases to previous tag can only be published from the ${previousBranch} branch`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| else if (!isExperimental && !isPrevious && branch !== mainBranch) { | ||
| logger.error(`Releases to latest and next tags can only be published from the ${mainBranch} branch`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } | ||
| if (tag === TAG.LATEST && versionIncrement === INCREMENT.PRERELEASE) { | ||
| logger.error('Invalid release: cannot increment prerelease number on latest tag.'); | ||
| if ((tag === TAG.LATEST || tag === TAG.PREVIOUS) && | ||
| versionIncrement === INCREMENT.PRERELEASE) { | ||
| logger.error(`Invalid release: cannot increment prerelease number on ${tag} tag.`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| const { next: currentNextTag, experimental: currentExperimentalTag } = await getDistTags(packageName); | ||
| if (!isExperimental && currentNextTag !== localVersion) { | ||
| logger.error(`Local package.json version ${localVersion} is out of sync with published version ${currentNextTag}`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| const { latest: currentLatestTag, next: currentNextTag, experimental: currentExperimentalTag, previous: currentPreviousTag, } = await getDistTags(packageName); | ||
| if (!skipVersionCheck) { | ||
| if (!isExperimental && !isPrevious && currentNextTag !== localVersion) { | ||
| logger.error(`Local package.json version ${localVersion} is out of sync with published version ${currentNextTag}`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| if (isPrevious && currentPreviousTag !== localVersion) { | ||
| logger.error(`Local package.json version ${localVersion} is out of sync with published version ${currentPreviousTag}`); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } | ||
@@ -43,3 +62,5 @@ const currentVersion = isExperimental | ||
| : PRERELEASE_IDENTIFIER.NEXT; | ||
| const incrementType = tag === TAG.LATEST || versionIncrement === INCREMENT.PRERELEASE | ||
| const incrementType = tag === TAG.LATEST || | ||
| tag === TAG.PREVIOUS || | ||
| versionIncrement === INCREMENT.PRERELEASE | ||
| ? versionIncrement | ||
@@ -64,3 +85,3 @@ : `pre${versionIncrement}`; | ||
| } | ||
| if (branch === mainBranch && !dryRun) { | ||
| if ((branch === mainBranch || branch === previousBranch) && !dryRun) { | ||
| logger.log(`Creating a temporary branch '${tempBranch}' for the release`); | ||
@@ -92,2 +113,5 @@ await exec(`git checkout -b ${tempBranch}`); | ||
| else { | ||
| logger.log('Installing dependencies...'); | ||
| await exec('yarn'); | ||
| logger.log('Building...'); | ||
| await exec(`yarn build`); | ||
@@ -101,3 +125,9 @@ } | ||
| catch (err) { | ||
| execSync(`npm adduser --registry ${registry}`, { stdio: 'inherit' }); | ||
| try { | ||
| execSync(`npm adduser --registry ${registry}`, { stdio: 'inherit' }); | ||
| } | ||
| catch (authErr) { | ||
| logger.error('Failed to authenticate with npm'); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } | ||
@@ -112,7 +142,7 @@ otp = await input({ | ||
| try { | ||
| await publish(tag, otp, isDryRun, registry); | ||
| await publish(tag, otp, isDryRun, ignoreScripts || false, registry); | ||
| } | ||
| catch (e) { | ||
| logger.error(e); | ||
| logger.error('An error occurred while releasing the CLI. Correct the error and re-run `yarn build`.'); | ||
| logger.error('An error occurred while releasing this package. Correct the error and re-run `yarn build`.'); | ||
| await gitCleanup(newVersion, !isExperimental); | ||
@@ -131,2 +161,11 @@ process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| if (versionIncrement === INCREMENT.MAJOR) { | ||
| try { | ||
| await updatePreviousTag(packageName, currentLatestTag, otp, isDryRun, registry); | ||
| } | ||
| catch (e) { | ||
| logger.error(`An error occurred while updating the ${TAG.PREVIOUS} tag. To finish this release, run the following command:`); | ||
| logger.log(`npm dist-tag add ${packageName}@${currentLatestTag} ${TAG.PREVIOUS}`); | ||
| } | ||
| } | ||
| } | ||
@@ -148,10 +187,13 @@ if (isDryRun) { | ||
| logger.log(`Changes pushed successfully`); | ||
| await open(`${repositoryUrl}/compare/${mainBranch}...${tempBranch}`); | ||
| const baseBranch = isPrevious ? previousBranch : mainBranch; | ||
| await open(`${repositoryUrl}/compare/${baseBranch}...${tempBranch}`); | ||
| } | ||
| logger.success(`HubSpot CLI version ${newVersion} published successfully`); | ||
| logger.success(`${packageName} version ${newVersion} published successfully`); | ||
| logger.log(`https://www.npmjs.com/package/${packageName}?activeTab=versions`); | ||
| if (tag === TAG.LATEST) { | ||
| if (tag === TAG.LATEST || tag === TAG.PREVIOUS) { | ||
| logger.log(`\nRemember to create a new release on Github!`); | ||
| await open(`${repositoryUrl}/releases/new`); | ||
| if (postLatestRelease && typeof postLatestRelease === 'function') { | ||
| if (tag === TAG.LATEST && | ||
| postLatestRelease && | ||
| typeof postLatestRelease === 'function') { | ||
| await postLatestRelease({ newVersion, dryRun }); | ||
@@ -182,2 +224,12 @@ } | ||
| }, | ||
| skipBranchValidation: { | ||
| describe: 'Bypass the branch validation check', | ||
| default: false, | ||
| type: 'boolean', | ||
| }, | ||
| skipVersionCheck: { | ||
| describe: 'Bypass checking that the local version matches the published version', | ||
| default: false, | ||
| type: 'boolean', | ||
| }, | ||
| }); | ||
@@ -184,0 +236,0 @@ } |
+36
-1
@@ -1,2 +0,2 @@ | ||
| import { TAG, TAG_OPTIONS, VERSION_INCREMENT_OPTIONS } from './constants/release.js'; | ||
| import { TAG, TAG_OPTIONS, VERSION_INCREMENT_OPTIONS, VSCODE_VERSION_INCREMENT_OPTIONS } from './constants/release.js'; | ||
| export type ReleaseArguments = { | ||
@@ -6,3 +6,10 @@ versionIncrement: (typeof VERSION_INCREMENT_OPTIONS)[number]; | ||
| dryRun: boolean; | ||
| skipBranchValidation: boolean; | ||
| skipVersionCheck: boolean; | ||
| }; | ||
| export type RepoSyncArguments = { | ||
| version: string; | ||
| dryRun: boolean; | ||
| sshKeyPath?: string; | ||
| }; | ||
| export type DistTags = { | ||
@@ -12,2 +19,3 @@ [TAG.LATEST]: string; | ||
| [TAG.EXPERIMENTAL]: string; | ||
| [TAG.PREVIOUS]: string; | ||
| }; | ||
@@ -22,3 +30,5 @@ export type Tag = (typeof TAG_OPTIONS)[number]; | ||
| repositoryUrl: string; | ||
| ignoreScripts?: boolean; | ||
| mainBranch?: string; | ||
| previousBranch?: string; | ||
| } | ||
@@ -30,2 +40,27 @@ export interface BuildReleaseScriptOptions extends BuildReleaseScriptBase { | ||
| } | ||
| export type VscodeReleaseArguments = { | ||
| versionIncrement: (typeof VSCODE_VERSION_INCREMENT_OPTIONS)[number]; | ||
| dryRun: boolean; | ||
| skipTests: boolean; | ||
| }; | ||
| export interface VscodeReleaseScriptBase { | ||
| repositoryUrl: string; | ||
| mainBranch?: string; | ||
| marketplaceUrl: string; | ||
| extensionName: string; | ||
| slackChannel?: string; | ||
| build?: () => Promise<void> | void; | ||
| } | ||
| export interface VscodeReleaseScriptOptions extends VscodeReleaseScriptBase { | ||
| packageName: string; | ||
| localVersion: string; | ||
| } | ||
| export interface BuildRepoSyncScriptOptions { | ||
| sourceDirectory?: string; | ||
| targetRepositoryUrl: string; | ||
| buildCommitMessage: (version: string) => string; | ||
| excludePatterns?: string[]; | ||
| internalMainBranch?: string; | ||
| externalMainBranch?: string; | ||
| } | ||
| export interface PackageJson { | ||
@@ -32,0 +67,0 @@ name: string; |
| export declare function getCurrentGitBranch(): Promise<string>; | ||
| export declare function gitCleanup(newVersion: string, deleteTag: boolean): Promise<void>; | ||
| export declare function getGitEnvWithSshKey(sshKeyPath?: string): Promise<typeof process.env | undefined>; | ||
| export declare function cloneRepo(repoUrl: string, destinationDir: string, gitEnvironment?: typeof process.env): Promise<string>; | ||
| export declare function checkoutBranch(branchName: string, pullLatest?: boolean): Promise<void>; | ||
| export declare function createBranch(branchName: string): Promise<void>; | ||
| export declare function commitChanges(commitMessage: string): Promise<void>; | ||
| export declare function pushBranch(branchName: string, gitEnvironment?: typeof process.env): Promise<void>; |
+100
-0
| import { promisify } from 'util'; | ||
| import path from 'path'; | ||
| import fs from 'fs-extra'; | ||
| import { exec as _exec } from 'node:child_process'; | ||
| import { logger } from './logging.js'; | ||
| import { EXIT_CODES } from './process.js'; | ||
| import { input } from './prompting.js'; | ||
| const exec = promisify(_exec); | ||
| function getRepoPartsFromUrl(repoUrl) { | ||
| // Strip protocol if present, e.g. "https://git.mycorp.com/Org/repo-name" -> "git.mycorp.com/Org/repo-name" | ||
| const withoutProtocol = repoUrl.trim().replace(/^[a-z]+:\/\//i, ''); | ||
| const [host, org, repo] = withoutProtocol.split('/'); | ||
| return { host, org, repo }; | ||
| } | ||
| export async function getCurrentGitBranch() { | ||
@@ -15,1 +26,90 @@ const { stdout } = await exec('git rev-parse --abbrev-ref HEAD'); | ||
| } | ||
| export async function getGitEnvWithSshKey(sshKeyPath) { | ||
| if (!sshKeyPath) { | ||
| sshKeyPath = await input({ | ||
| message: 'Enter the SSH key path to use (leave blank to use the default):', | ||
| }); | ||
| if (!sshKeyPath) { | ||
| return process.env; | ||
| } | ||
| } | ||
| return { | ||
| ...process.env, | ||
| GIT_SSH_COMMAND: `ssh -i ${sshKeyPath} -o IdentitiesOnly=yes`, | ||
| }; | ||
| } | ||
| export async function cloneRepo(repoUrl, destinationDir, gitEnvironment) { | ||
| logger.log(`Cloning repository into ${destinationDir}...`); | ||
| const { host, org, repo } = getRepoPartsFromUrl(repoUrl); | ||
| const targetRepoPath = path.join(destinationDir, repo); | ||
| // Remove existing directory if it exists | ||
| try { | ||
| await fs.remove(targetRepoPath); | ||
| } | ||
| catch (error) { | ||
| // Ignore error if directory doesn't exist | ||
| } | ||
| try { | ||
| await exec(`git clone git@${host}:${org}/${repo}.git ${targetRepoPath}`, { | ||
| env: gitEnvironment, | ||
| }); | ||
| } | ||
| catch (error) { | ||
| logger.error(`Error cloning repository:`, error); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| return targetRepoPath; | ||
| } | ||
| export async function checkoutBranch(branchName, pullLatest = false) { | ||
| try { | ||
| await exec(`git checkout ${branchName}`); | ||
| if (pullLatest) { | ||
| await exec(`git pull origin ${branchName}`); | ||
| } | ||
| } | ||
| catch (error) { | ||
| logger.error(`Error checking out branch ${branchName}:`, error); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } | ||
| export async function createBranch(branchName) { | ||
| try { | ||
| await exec(`git checkout -b ${branchName}`); | ||
| } | ||
| catch (error) { | ||
| logger.error(`Error creating branch ${branchName}:`, error); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } | ||
| export async function commitChanges(commitMessage) { | ||
| // Add all changes | ||
| await exec('git add .'); | ||
| // Check if there are any changes to commit | ||
| try { | ||
| const { stdout } = await exec('git status --porcelain'); | ||
| if (!stdout.trim()) { | ||
| logger.log('No changes to commit'); | ||
| return; | ||
| } | ||
| } | ||
| catch (error) { | ||
| logger.error('Error checking git status'); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| await exec(`git commit -m "${commitMessage}"`); | ||
| } | ||
| // ssh url: git@github.com:HubSpotEngineering/Dev-Experience-Fe-Scripts.git | ||
| // targetRepositoryOrg = https://github.com/HubSpotEngineering | ||
| // targetRepositoryName = Dev-Experience-Fe-Scripts.git | ||
| export async function pushBranch(branchName, gitEnvironment) { | ||
| logger.log(`Pushing branch ${branchName}...`); | ||
| try { | ||
| await exec(`git push origin ${branchName}`, { | ||
| env: gitEnvironment, | ||
| }); | ||
| } | ||
| catch (error) { | ||
| logger.error(`Error pushing branch ${branchName}:`, error); | ||
| process.exit(EXIT_CODES.ERROR); | ||
| } | ||
| } |
@@ -7,6 +7,6 @@ import chalk from 'chalk'; | ||
| info(...args) { | ||
| console.log(`${chalk.bold('[INFO]')}`, ...args); | ||
| console.log(`${chalk.cyan('[INFO]')}`, ...args); | ||
| }, | ||
| success(...args) { | ||
| console.log(`${chalk.cyan('[SUCCESS]')}`, ...args); | ||
| console.log(`${chalk.green('[SUCCESS]')}`, ...args); | ||
| }, | ||
@@ -13,0 +13,0 @@ warn(...args) { |
| import { DistTags, Tag } from '../types.js'; | ||
| export declare function getDistTags(packageName: string): Promise<DistTags>; | ||
| export declare function publish(tag: Tag, otp: string, isDryRun: boolean, registry: string): Promise<void>; | ||
| export declare function publish(tag: Tag, otp: string, isDryRun: boolean, ignoreScripts: boolean, registry: string): Promise<void>; | ||
| export declare function updateDistTag(packageName: string, version: string, tag: string, otp: string, isDryRun: boolean, registry: string): Promise<void>; | ||
| export declare function updateNextTag(packageName: string, newVersion: string, otp: string, isDryRun: boolean, registry: string): Promise<void>; | ||
| export declare function updatePreviousTag(packageName: string, oldLatestVersion: string, otp: string, isDryRun: boolean, registry: string): Promise<void>; |
+17
-8
@@ -12,3 +12,3 @@ import { promisify } from 'util'; | ||
| } | ||
| export async function publish(tag, otp, isDryRun, registry) { | ||
| export async function publish(tag, otp, isDryRun, ignoreScripts, registry) { | ||
| logger.log(`\nPublishing to ${tag}...`); | ||
@@ -29,2 +29,5 @@ logger.log('-'.repeat(50)); | ||
| } | ||
| if (ignoreScripts) { | ||
| commandArgs.push('--ignore-scripts'); | ||
| } | ||
| return new Promise((resolve, reject) => { | ||
@@ -37,3 +40,3 @@ const childProcess = spawn('npm', commandArgs, { | ||
| if (code !== EXIT_CODES.SUCCESS) { | ||
| reject(); | ||
| reject(new Error(`npm publish failed with exit code ${code}`)); | ||
| } | ||
@@ -46,10 +49,10 @@ else { | ||
| } | ||
| export async function updateNextTag(packageName, newVersion, otp, isDryRun, registry) { | ||
| export async function updateDistTag(packageName, version, tag, otp, isDryRun, registry) { | ||
| logger.log(); | ||
| logger.log(`Updating ${TAG.NEXT} tag...`); | ||
| logger.log(`Updating ${tag} tag to ${version}...`); | ||
| const commandArgs = [ | ||
| 'dist-tag', | ||
| 'add', | ||
| `${packageName}@${newVersion}`, | ||
| TAG.NEXT, | ||
| `${packageName}@${version}`, | ||
| tag, | ||
| '--registry', | ||
@@ -70,6 +73,6 @@ registry, | ||
| if (code !== EXIT_CODES.SUCCESS) { | ||
| reject(); | ||
| reject(new Error(`npm dist-tag add failed with exit code ${code}`)); | ||
| } | ||
| else { | ||
| logger.success(`${TAG.NEXT} tag updated successfully`); | ||
| logger.success(`${tag} tag updated successfully`); | ||
| resolve(); | ||
@@ -81,1 +84,7 @@ } | ||
| } | ||
| export async function updateNextTag(packageName, newVersion, otp, isDryRun, registry) { | ||
| return updateDistTag(packageName, newVersion, TAG.NEXT, otp, isDryRun, registry); | ||
| } | ||
| export async function updatePreviousTag(packageName, oldLatestVersion, otp, isDryRun, registry) { | ||
| return updateDistTag(packageName, oldLatestVersion, TAG.PREVIOUS, otp, isDryRun, registry); | ||
| } |
Major refactor
Supply chain riskPackage has recently undergone a major refactor. It may be unstable or indicate significant internal changes. Use caution when updating to versions that include significant changes.
Found 1 instance in 1 package
New author
Supply chain riskA new npm collaborator published a version of the package for the first time. New collaborators are usually benign additions to a project, but do indicate a change to the security surface area of a package.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
No tests
QualityPackage does not have any tests. This is a strong signal of a poorly maintained or low quality package.
Found 1 instance in 1 package
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Found 1 instance in 1 package
99422
292.88%41
95.24%2178
304.08%2
-33.33%181
56.03%8
14.29%17
6.25%24
500%2
Infinity%+ Added
+ Added
+ Added
+ Added