Big News: Socket raises $60M Series C at a $1B valuation to secure software supply chains for AI-driven development.Announcement
Sign In

@hubspot/npm-scripts

Package Overview
Dependencies
Maintainers
39
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.6
to
0.1.0-beta.0
+4
src/constants/repo-sync.d.ts
export declare const DEFAULT_MAIN_BRANCH = "master";
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_MAIN_BRANCH = 'master';
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({ targetRepositoryUrl, excludePatterns, buildCommitMessage, mainBranch, 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_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({ targetRepositoryUrl, excludePatterns, buildCommitMessage, mainBranch, version, dryRun, sshKeyPath, }) {
if (!mainBranch) {
logger.error('Main branch is required');
process.exit(EXIT_CODES.ERROR);
}
const originalRepoPath = 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 !== mainBranch) {
const shouldCheckout = await confirm({
message: `You are on ${currentBranch}. Sync from ${mainBranch} branch instead?`,
});
if (shouldCheckout) {
await checkoutBranch(mainBranch, 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/${mainBranch}...${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({ targetRepositoryUrl, excludePatterns, buildCommitMessage, mainBranch, }) {
return async function handler({ version, dryRun, sshKeyPath, }) {
await handleRepoSync({
targetRepositoryUrl,
excludePatterns,
buildCommitMessage,
mainBranch,
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,
mainBranch: buildHandlerOptions.mainBranch || DEFAULT_MAIN_BRANCH,
}))
.version(false)
.help().argv;
}
catch (e) {
logger.error(e);
process.exit(EXIT_CODES.ERROR);
}
}
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);
}
}
+4
-2
{
"name": "@hubspot/npm-scripts",
"version": "0.0.6",
"version": "0.1.0-beta.0",
"description": "Scripts for working with npm packages in the HubSpot ecosystem",

@@ -17,2 +17,3 @@ "author": "",

"release": "yarn tsx ./scripts/release.ts release",
"repo-sync": "yarn tsx ./scripts/repo-sync.ts repo-sync",
"test": "echo \"Error: no test specified\" && exit 1"

@@ -25,2 +26,3 @@ },

"fs-extra": "^11.3.2",
"minimatch": "^10.0.1",
"open": "^10.2.0",

@@ -34,3 +36,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",

@@ -37,0 +39,0 @@ "@types/yargs": "^17.0.33",

@@ -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`.

@@ -7,2 +7,7 @@ import { TAG, TAG_OPTIONS, VERSION_INCREMENT_OPTIONS } from './constants/release.js';

};
export type RepoSyncArguments = {
version: string;
dryRun: boolean;
sshKeyPath?: string;
};
export type DistTags = {

@@ -28,2 +33,8 @@ [TAG.LATEST]: string;

}
export interface BuildRepoSyncScriptOptions {
targetRepositoryUrl: string;
buildCommitMessage: (version: string) => string;
excludePatterns?: string[];
mainBranch?: string;
}
export interface PackageJson {

@@ -30,0 +41,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) {