Latest Threat Research:SANDWORM_MODE: Shai-Hulud-Style npm Worm Hijacks CI Workflows and Poisons AI Toolchains.Details
Socket
Book a DemoInstallSign in
Socket

@hubspot/npm-scripts

Package Overview
Dependencies
Maintainers
40
Versions
34
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@hubspot/npm-scripts - npm Package Compare versions

Comparing version
0.0.4-experimental.1
to
0.0.4-experimental.2
+5
src/constants/repo-sync.d.ts
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;
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);
}
}
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();
}
});
});
});
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();
}
});
});
});
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();
});
});
});
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`);
});
});
});
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);
});
});
});
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": {

@@ -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',

@@ -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 @@ }

@@ -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>;
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>;

@@ -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);
}