@hubspot/npm-scripts
Advanced tools
| 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; |
+138
| 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", |
+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`. |
+11
-0
@@ -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>; |
+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) { |
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
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
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
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
41007
60.74%27
28.57%886
62.57%181
56.03%8
14.29%13
225%+ Added
+ Added
+ Added
+ Added